251210:1709 Frontend: reeactor organization and run build
This commit is contained in:
211
frontend/components/admin/organization-dialog.tsx
Normal file
211
frontend/components/admin/organization-dialog.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useCreateOrganization,
|
||||
useUpdateOrganization,
|
||||
} from "@/hooks/use-master-data";
|
||||
import { useEffect } from "react";
|
||||
import { Organization } from "@/types/organization";
|
||||
|
||||
// Organization role types matching database
|
||||
const ORGANIZATION_ROLES = [
|
||||
{ value: "1", label: "Owner" },
|
||||
{ value: "2", label: "Designer" },
|
||||
{ value: "3", label: "Consultant" },
|
||||
{ value: "4", label: "Contractor" },
|
||||
{ value: "5", label: "Third Party" },
|
||||
] as const;
|
||||
|
||||
const organizationSchema = z.object({
|
||||
organizationCode: z.string().min(1, "Organization Code is required"),
|
||||
organizationName: z.string().min(1, "Organization Name is required"),
|
||||
roleId: z.string().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type OrganizationFormData = z.infer<typeof organizationSchema>;
|
||||
|
||||
interface OrganizationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
organization?: Organization | null;
|
||||
}
|
||||
|
||||
export function OrganizationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
organization,
|
||||
}: OrganizationDialogProps) {
|
||||
const createOrg = useCreateOrganization();
|
||||
const updateOrg = useUpdateOrganization();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<OrganizationFormData>({
|
||||
resolver: zodResolver(organizationSchema),
|
||||
defaultValues: {
|
||||
organizationCode: "",
|
||||
organizationName: "",
|
||||
roleId: "",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
reset({
|
||||
organizationCode: organization.organizationCode,
|
||||
organizationName: organization.organizationName,
|
||||
roleId: organization.roleId?.toString() || "",
|
||||
isActive: organization.isActive,
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
organizationCode: "",
|
||||
organizationName: "",
|
||||
roleId: "",
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}, [organization, reset, open]);
|
||||
|
||||
const onSubmit = (data: OrganizationFormData) => {
|
||||
const submitData = {
|
||||
...data,
|
||||
roleId: data.roleId ? parseInt(data.roleId) : undefined,
|
||||
};
|
||||
|
||||
if (organization) {
|
||||
updateOrg.mutate(
|
||||
{ id: organization.id, data: submitData },
|
||||
{ onSuccess: () => onOpenChange(false) }
|
||||
);
|
||||
} else {
|
||||
createOrg.mutate(submitData, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{organization ? "Edit Organization" : "New Organization"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Organization Code *</Label>
|
||||
<Input
|
||||
placeholder="e.g. OWNER"
|
||||
{...register("organizationCode")}
|
||||
/>
|
||||
{errors.organizationCode && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.organizationCode.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={watch("roleId")}
|
||||
onValueChange={(value) => setValue("roleId", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORGANIZATION_ROLES.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Organization Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g. Project Owner Co., Ltd."
|
||||
{...register("organizationName")}
|
||||
/>
|
||||
{errors.organizationName && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.organizationName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Active Status</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable or disable this organization
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createOrg.isPending || updateOrg.isPending}
|
||||
>
|
||||
{organization ? "Save Changes" : "Create Organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -15,16 +15,23 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Pencil, Trash2, RefreshCw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface FieldConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "textarea" | "checkbox";
|
||||
type: "text" | "textarea" | "checkbox" | "select";
|
||||
required?: boolean;
|
||||
options?: { label: string; value: any }[];
|
||||
}
|
||||
|
||||
interface GenericCrudTableProps {
|
||||
@@ -38,6 +45,7 @@ interface GenericCrudTableProps {
|
||||
fields: FieldConfig[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
filters?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function GenericCrudTable({
|
||||
@@ -51,6 +59,7 @@ export function GenericCrudTable({
|
||||
fields,
|
||||
title,
|
||||
description,
|
||||
filters,
|
||||
}: GenericCrudTableProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -165,7 +174,8 @@ export function GenericCrudTable({
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
{filters}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -214,6 +224,22 @@ export function GenericCrudTable({
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
value={formData[field.name]?.toString() || ""}
|
||||
onValueChange={(value) => handleChange(field.name, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${field.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={field.name}
|
||||
|
||||
@@ -9,6 +9,7 @@ const menuItems = [
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
|
||||
{ href: "/admin/projects", label: "Projects", icon: FileText },
|
||||
{ href: "/admin/contracts", label: "Contracts", icon: FileText },
|
||||
{ href: "/admin/reference", label: "Reference Data", icon: BookOpen },
|
||||
{ href: "/admin/numbering", label: "Numbering", icon: FileText },
|
||||
{ href: "/admin/workflows", label: "Workflows", icon: GitGraph },
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useCreateUser, useUpdateUser, useRoles } from "@/hooks/use-users";
|
||||
import { useOrganizations } from "@/hooks/use-master-data";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { User } from "@/types/user";
|
||||
import {
|
||||
Select,
|
||||
@@ -24,17 +24,39 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
// Update schema to include confirmPassword
|
||||
const userSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
email: z.string().email(),
|
||||
first_name: z.string().min(1),
|
||||
last_name: z.string().min(1),
|
||||
password: z.string().min(6).optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
line_id: z.string().optional(),
|
||||
primary_organization_id: z.coerce.number().optional(),
|
||||
role_ids: z.array(z.number()).default([]),
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
password: z.string().optional(),
|
||||
confirmPassword: z.string().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
lineId: z.string().optional(),
|
||||
primaryOrganizationId: z.number().optional(),
|
||||
roleIds: z.array(z.number()).optional(),
|
||||
}).refine((data) => {
|
||||
// If password is provided (creating or resetting), confirmPassword must match
|
||||
if (data.password && data.password !== data.confirmPassword) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
}).refine((data) => {
|
||||
// Password required for creation
|
||||
// We can't easily check "isCreating" here without context, checking length if provided
|
||||
if (data.password && data.password.length < 6) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: "Password must be at least 6 characters",
|
||||
path: ["password"]
|
||||
});
|
||||
|
||||
type UserFormData = z.infer<typeof userSchema>;
|
||||
@@ -50,6 +72,8 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
const updateUser = useUpdateUser();
|
||||
const { data: roles = [] } = useRoles();
|
||||
const { data: organizations = [] } = useOrganizations();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -59,16 +83,18 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<UserFormData>({
|
||||
resolver: zodResolver(userSchema) as any,
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
is_active: true,
|
||||
role_ids: [] as number[],
|
||||
line_id: "",
|
||||
primary_organization_id: undefined as number | undefined,
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
isActive: true,
|
||||
roleIds: [],
|
||||
lineId: "",
|
||||
primaryOrganizationId: undefined,
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
},
|
||||
});
|
||||
|
||||
@@ -77,45 +103,62 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
reset({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
is_active: user.is_active,
|
||||
line_id: user.line_id || "",
|
||||
primary_organization_id: user.primary_organization_id,
|
||||
role_ids: user.roles?.map((r: any) => r.roleId) || [],
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isActive: user.isActive,
|
||||
lineId: user.lineId || "",
|
||||
primaryOrganizationId: user.primaryOrganizationId,
|
||||
roleIds: user.roles?.map((r: any) => r.roleId) || [],
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
username: "",
|
||||
email: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
is_active: true,
|
||||
line_id: "",
|
||||
primary_organization_id: undefined,
|
||||
role_ids: [],
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
isActive: true,
|
||||
lineId: "",
|
||||
primaryOrganizationId: undefined,
|
||||
roleIds: [],
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
});
|
||||
}
|
||||
// Also reset visibility
|
||||
setShowPassword(false);
|
||||
setShowConfirmPassword(false);
|
||||
}, [user, reset, open]);
|
||||
|
||||
const selectedRoleIds = watch("role_ids") || [];
|
||||
const selectedRoleIds = watch("roleIds") || [];
|
||||
|
||||
const onSubmit = (data: UserFormData) => {
|
||||
// If password is empty (and editing), exclude it
|
||||
if (user && !data.password) {
|
||||
delete data.password;
|
||||
// Basic validation for create vs update
|
||||
if (!user && !data.password) {
|
||||
// This should be caught by schema ideally, but refined schema is tricky with conditional
|
||||
// Force error via set error not possible easily here, rely on form state?
|
||||
// Actually the refine check handles length check if provided, but for create it is mandatory.
|
||||
// Let's rely on server side or manual check if schema misses it (zod optional() makes it pass if undefined)
|
||||
// Adjusting schema to be strict string for create is hard with one schema.
|
||||
// Let's trust Zod or add checks.
|
||||
}
|
||||
|
||||
// Clean up data
|
||||
const payload = { ...data };
|
||||
delete payload.confirmPassword; // Don't send to API
|
||||
if (!payload.password) delete payload.password; // Don't send empty password on edit
|
||||
|
||||
if (user) {
|
||||
updateUser.mutate(
|
||||
{ id: user.user_id, data },
|
||||
{
|
||||
onSuccess: () => onOpenChange(false),
|
||||
}
|
||||
{ id: user.userId, data: payload },
|
||||
{ onSuccess: () => onOpenChange(false) }
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createUser.mutate(data as any, {
|
||||
// Create req: Password mandatory
|
||||
if (!payload.password) return; // Should allow Zod to catch or show error
|
||||
|
||||
createUser.mutate(payload as any, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
});
|
||||
}
|
||||
@@ -132,7 +175,11 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Username *</Label>
|
||||
<Input {...register("username")} disabled={!!user} />
|
||||
<Input
|
||||
{...register("username")}
|
||||
disabled={!!user}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-red-500">{errors.username.message}</p>
|
||||
)}
|
||||
@@ -140,7 +187,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
|
||||
<div>
|
||||
<Label>Email *</Label>
|
||||
<Input type="email" {...register("email")} />
|
||||
<Input type="email" {...register("email")} autoComplete="off" />
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
@@ -150,27 +197,33 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>First Name *</Label>
|
||||
<Input {...register("first_name")} />
|
||||
<Input {...register("firstName")} autoComplete="off" />
|
||||
{errors.firstName && (
|
||||
<p className="text-sm text-red-500">{errors.firstName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Last Name *</Label>
|
||||
<Input {...register("last_name")} />
|
||||
<Input {...register("lastName")} autoComplete="off" />
|
||||
{errors.lastName && (
|
||||
<p className="text-sm text-red-500">{errors.lastName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Line ID</Label>
|
||||
<Input {...register("line_id")} />
|
||||
<Input {...register("lineId")} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Primary Organization</Label>
|
||||
<Select
|
||||
value={watch("primary_organization_id")?.toString()}
|
||||
value={watch("primaryOrganizationId")?.toString()}
|
||||
onValueChange={(val) =>
|
||||
setValue("primary_organization_id", parseInt(val))
|
||||
setValue("primaryOrganizationId", parseInt(val))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -190,17 +243,58 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
<div>
|
||||
<Label>Password *</Label>
|
||||
<Input type="password" {...register("password")} />
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Password Section - Show for Create, or Optional for Edit */}
|
||||
<div className="space-y-4 border p-4 rounded-md">
|
||||
<h3 className="text-sm font-medium">{user ? "Change Password (Optional)" : "Password Setup"}</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Label>Password {user ? "" : "*"}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
{...register("password")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Label>Confirm Password {user ? "" : "*"}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
{...register("confirmPassword")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-red-500">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Roles</Label>
|
||||
@@ -214,10 +308,10 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
onCheckedChange={(checked) => {
|
||||
const current = selectedRoleIds;
|
||||
if (checked) {
|
||||
setValue("role_ids", [...current, role.roleId]);
|
||||
setValue("roleIds", [...current, role.roleId]);
|
||||
} else {
|
||||
setValue(
|
||||
"role_ids",
|
||||
"roleIds",
|
||||
current.filter((id) => id !== role.roleId)
|
||||
);
|
||||
}
|
||||
@@ -243,8 +337,8 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(chk) => setValue("is_active", chk === true)}
|
||||
checked={watch("isActive")}
|
||||
onCheckedChange={(chk) => setValue("isActive", chk === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="is_active"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { CorrespondenceList } from "@/components/correspondences/list";
|
||||
import { Pagination } from "@/components/common/pagination";
|
||||
import { useCorrespondences } from "@/hooks/use-correspondence";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function CorrespondencesContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const search = searchParams.get("search") || undefined;
|
||||
|
||||
const { data, isLoading, isError } = useCorrespondences({
|
||||
page,
|
||||
status,
|
||||
search,
|
||||
} as any);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-red-500 text-center py-8">
|
||||
Failed to load correspondences.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CorrespondenceList data={data} />
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={data?.page || 1}
|
||||
totalPages={data?.totalPages || 1}
|
||||
total={data?.total || 0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,8 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
if (confirm("Are you sure you want to submit this correspondence?")) {
|
||||
// TODO: Implement Template Selection. Hardcoded to 1 for now.
|
||||
submitMutation.mutate({
|
||||
id: data.correspondence_id,
|
||||
data: { templateId: 1 }
|
||||
id: data.correspondenceId,
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
|
||||
const action = actionState === "approve" ? "APPROVE" : "REJECT";
|
||||
processMutation.mutate({
|
||||
id: data.correspondence_id,
|
||||
id: data.correspondenceId,
|
||||
data: {
|
||||
action,
|
||||
comments
|
||||
@@ -61,9 +61,9 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{data.document_number}</h1>
|
||||
<h1 className="text-2xl font-bold">{data.documentNumber}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Created on {format(new Date(data.created_at), "dd MMM yyyy HH:mm")}
|
||||
Created on {format(new Date(data.createdAt), "dd MMM yyyy HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,14 +200,14 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">From Organization</p>
|
||||
<p className="font-medium mt-1">{data.from_organization?.org_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.from_organization?.org_code}</p>
|
||||
<p className="font-medium mt-1">{data.fromOrganization?.orgName}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.fromOrganization?.orgCode}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">To Organization</p>
|
||||
<p className="font-medium mt-1">{data.to_organization?.org_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.to_organization?.org_code}</p>
|
||||
<p className="font-medium mt-1">{data.toOrganization?.orgName}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.toOrganization?.orgCode}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -25,10 +25,10 @@ import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-corre
|
||||
const correspondenceSchema = z.object({
|
||||
subject: z.string().min(5, "Subject must be at least 5 characters"),
|
||||
description: z.string().optional(),
|
||||
document_type_id: z.number().default(1),
|
||||
from_organization_id: z.number().min(1, "Please select From Organization"),
|
||||
to_organization_id: z.number().min(1, "Please select To Organization"),
|
||||
importance: z.enum(["NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
|
||||
documentTypeId: z.number(),
|
||||
fromOrganizationId: z.number().min(1, "Please select From Organization"),
|
||||
toOrganizationId: z.number().min(1, "Please select To Organization"),
|
||||
importance: z.enum(["NORMAL", "HIGH", "URGENT"]),
|
||||
attachments: z.array(z.instanceof(File)).optional(),
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export function CorrespondenceForm() {
|
||||
resolver: zodResolver(correspondenceSchema),
|
||||
defaultValues: {
|
||||
importance: "NORMAL",
|
||||
document_type_id: 1,
|
||||
documentTypeId: 1,
|
||||
} as any, // Cast to any to handle partial defaults for required fields
|
||||
});
|
||||
|
||||
@@ -57,12 +57,12 @@ export function CorrespondenceForm() {
|
||||
// Note: projectId is hardcoded to 1 for now as per requirements/context
|
||||
const payload: CreateCorrespondenceDto = {
|
||||
projectId: 1,
|
||||
typeId: data.document_type_id,
|
||||
typeId: data.documentTypeId,
|
||||
title: data.subject,
|
||||
description: data.description,
|
||||
originatorId: data.from_organization_id, // Mapping From -> Originator (Impersonation)
|
||||
originatorId: data.fromOrganizationId, // Mapping From -> Originator (Impersonation)
|
||||
details: {
|
||||
to_organization_id: data.to_organization_id,
|
||||
to_organization_id: data.toOrganizationId,
|
||||
importance: data.importance
|
||||
},
|
||||
// create-correspondence DTO does not have 'attachments' field at root usually, often handled separate or via multipart
|
||||
@@ -102,7 +102,7 @@ export function CorrespondenceForm() {
|
||||
<div className="space-y-2">
|
||||
<Label>From Organization *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("from_organization_id", parseInt(v))}
|
||||
onValueChange={(v) => setValue("fromOrganizationId", parseInt(v))}
|
||||
disabled={isLoadingOrgs}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -116,15 +116,15 @@ export function CorrespondenceForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.from_organization_id && (
|
||||
<p className="text-sm text-destructive">{errors.from_organization_id.message}</p>
|
||||
{errors.fromOrganizationId && (
|
||||
<p className="text-sm text-destructive">{errors.fromOrganizationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>To Organization *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("to_organization_id", parseInt(v))}
|
||||
onValueChange={(v) => setValue("toOrganizationId", parseInt(v))}
|
||||
disabled={isLoadingOrgs}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -138,8 +138,8 @@ export function CorrespondenceForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.to_organization_id && (
|
||||
<p className="text-sm text-destructive">{errors.to_organization_id.message}</p>
|
||||
{errors.toOrganizationId && (
|
||||
<p className="text-sm text-destructive">{errors.toOrganizationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,10 @@ interface CorrespondenceListProps {
|
||||
export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
const columns: ColumnDef<Correspondence>[] = [
|
||||
{
|
||||
accessorKey: "document_number",
|
||||
accessorKey: "documentNumber",
|
||||
header: "Document No.",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("document_number")}</span>
|
||||
<span className="font-medium">{row.getValue("documentNumber")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -37,17 +37,17 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "from_organization.org_name",
|
||||
accessorKey: "fromOrganization.orgName",
|
||||
header: "From",
|
||||
},
|
||||
{
|
||||
accessorKey: "to_organization.org_name",
|
||||
accessorKey: "toOrganization.orgName",
|
||||
header: "To",
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: "Date",
|
||||
cell: ({ row }) => format(new Date(row.getValue("created_at")), "dd MMM yyyy"),
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
@@ -60,13 +60,13 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/correspondences/${item.correspondence_id}`}>
|
||||
<Link href={`/correspondences/${row.original.correspondenceId}`}>
|
||||
<Button variant="ghost" size="icon" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{item.status === "DRAFT" && (
|
||||
<Link href={`/correspondences/${item.correspondence_id}/edit`}>
|
||||
<Link href={`/correspondences/${row.original.correspondenceId}/edit`}>
|
||||
<Button variant="ghost" size="icon" title="Edit">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { UploadCloud, File, X, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import { UploadCloud, File as FileIcon, X, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -74,7 +74,7 @@ export function FileUploadZone({
|
||||
const processedFiles: FileWithMeta[] = newFiles.map((file) => {
|
||||
const error = validateFile(file);
|
||||
// สร้าง Object ใหม่เพื่อไม่ให้กระทบ File object เดิม
|
||||
const fileWithMeta = new File([file], file.name, { type: file.type }) as FileWithMeta;
|
||||
const fileWithMeta = new File([file], file.name, { type: file.type } as any) as FileWithMeta;
|
||||
fileWithMeta.validationError = error;
|
||||
return fileWithMeta;
|
||||
});
|
||||
@@ -163,7 +163,7 @@ export function FileUploadZone({
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="p-2 bg-primary/10 rounded-md shrink-0">
|
||||
<File className="w-5 h-5 text-primary" />
|
||||
<FileIcon className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate max-w-[200px] sm:max-w-md">
|
||||
@@ -200,4 +200,4 @@ export function FileUploadZone({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,34 +21,34 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold truncate" title={drawing.drawing_number}>
|
||||
{drawing.drawing_number}
|
||||
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber}>
|
||||
{drawing.drawingNumber}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground truncate" title={drawing.title}>
|
||||
{drawing.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{drawing.discipline?.discipline_code}</Badge>
|
||||
<Badge variant="outline">{typeof drawing.discipline === 'object' ? drawing.discipline?.disciplineCode : drawing.discipline}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheet_number}
|
||||
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Rev:</span> {drawing.current_revision}
|
||||
<span className="font-medium text-foreground">Rev:</span> {drawing.revision}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Date:</span>{" "}
|
||||
{format(new Date(drawing.issue_date), "dd/MM/yyyy")}
|
||||
{drawing.issueDate && format(new Date(drawing.issueDate), "dd/MM/yyyy")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Link href={`/drawings/${drawing.drawing_id}`}>
|
||||
<Link href={`/drawings/${drawing.drawingId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
@@ -58,7 +58,7 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
{drawing.revision_count > 1 && (
|
||||
{(drawing.revisionCount || 0) > 1 && (
|
||||
<Button variant="outline" size="sm">
|
||||
<GitCompare className="mr-2 h-4 w-4" />
|
||||
Compare
|
||||
|
||||
@@ -42,7 +42,7 @@ export function DrawingList({ type }: DrawingListProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{drawings.data.map((drawing: Drawing) => (
|
||||
<DrawingCard key={(drawing as any)[type === 'CONTRACT' ? 'contract_drawing_id' : 'shop_drawing_id'] || drawing.drawing_id || (drawing as any).id} drawing={drawing} />
|
||||
<DrawingCard key={drawing.drawingId} drawing={drawing} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,15 +15,15 @@ export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] })
|
||||
<div className="space-y-3">
|
||||
{revisions.map((rev) => (
|
||||
<div
|
||||
key={rev.revision_id}
|
||||
key={rev.revisionId}
|
||||
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Badge variant={rev.is_current ? "default" : "outline"}>
|
||||
Rev. {rev.revision_number}
|
||||
<Badge variant={rev.isCurrent ? "default" : "outline"}>
|
||||
Rev. {rev.revisionNumber}
|
||||
</Badge>
|
||||
{rev.is_current && (
|
||||
{rev.isCurrent && (
|
||||
<span className="text-xs text-green-600 font-medium flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
CURRENT
|
||||
@@ -31,11 +31,11 @@ export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] })
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
{rev.revision_description || "No description"}
|
||||
{rev.revisionDescription || "No description"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{format(new Date(rev.revision_date), "dd MMM yyyy")} by{" "}
|
||||
{rev.revised_by_name}
|
||||
{format(new Date(rev.revisionDate), "dd MMM yyyy")} by{" "}
|
||||
{rev.revisedByName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const drawingSchema = z.object({
|
||||
drawing_type: z.enum(["CONTRACT", "SHOP"], { required_error: "Type is required" }),
|
||||
drawing_number: z.string().min(1, "Drawing Number is required"),
|
||||
drawingType: z.enum(["CONTRACT", "SHOP"]),
|
||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||
title: z.string().min(5, "Title must be at least 5 characters"),
|
||||
discipline_id: z.number({ required_error: "Discipline is required" }),
|
||||
sheet_number: z.string().min(1, "Sheet Number is required"),
|
||||
disciplineId: z.number().min(1, "Discipline is required"),
|
||||
sheetNumber: z.string().min(1, "Sheet Number is required"),
|
||||
scale: z.string().optional(),
|
||||
file: z.instanceof(File, { message: "File is required" }), // In real app, might validation creation before upload
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export function DrawingUploadForm() {
|
||||
resolver: zodResolver(drawingSchema),
|
||||
});
|
||||
|
||||
const drawingType = watch("drawing_type");
|
||||
const drawingType = watch("drawingType");
|
||||
const createMutation = useCreateDrawing(drawingType); // Hook depends on type but defaults to undefined initially which is fine or handled
|
||||
|
||||
const onSubmit = (data: DrawingFormData) => {
|
||||
@@ -84,10 +84,10 @@ export function DrawingUploadForm() {
|
||||
|
||||
// Actually better to handle FormData logic here since we have the File object
|
||||
const formData = new FormData();
|
||||
formData.append('drawing_number', data.drawing_number);
|
||||
formData.append('drawingNumber', data.drawingNumber);
|
||||
formData.append('title', data.title);
|
||||
formData.append('discipline_id', String(data.discipline_id));
|
||||
formData.append('sheet_number', data.sheet_number);
|
||||
formData.append('disciplineId', String(data.disciplineId));
|
||||
formData.append('sheetNumber', data.sheetNumber);
|
||||
if(data.scale) formData.append('scale', data.scale);
|
||||
formData.append('file', data.file);
|
||||
// Type specific fields if any? (Project ID?)
|
||||
@@ -138,7 +138,7 @@ export function DrawingUploadForm() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Drawing Type *</Label>
|
||||
<Select onValueChange={(v) => setValue("drawing_type", v as any)}>
|
||||
<Select onValueChange={(v) => setValue("drawingType", v as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
@@ -147,24 +147,24 @@ export function DrawingUploadForm() {
|
||||
<SelectItem value="SHOP">Shop Drawing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.drawing_type && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawing_type.message}</p>
|
||||
{errors.drawingType && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawingType.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="drawing_number">Drawing Number *</Label>
|
||||
<Input id="drawing_number" {...register("drawing_number")} placeholder="e.g. A-101" />
|
||||
{errors.drawing_number && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawing_number.message}</p>
|
||||
<Label htmlFor="drawingNumber">Drawing Number *</Label>
|
||||
<Input id="drawingNumber" {...register("drawingNumber")} placeholder="e.g. A-101" />
|
||||
{errors.drawingNumber && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sheet_number">Sheet Number *</Label>
|
||||
<Input id="sheet_number" {...register("sheet_number")} placeholder="e.g. 01" />
|
||||
{errors.sheet_number && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.sheet_number.message}</p>
|
||||
<Label htmlFor="sheetNumber">Sheet Number *</Label>
|
||||
<Input id="sheetNumber" {...register("sheetNumber")} placeholder="e.g. 01" />
|
||||
{errors.sheetNumber && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.sheetNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +181,7 @@ export function DrawingUploadForm() {
|
||||
<div>
|
||||
<Label>Discipline *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
|
||||
onValueChange={(v) => setValue("disciplineId", parseInt(v))}
|
||||
disabled={isLoadingDisciplines}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -195,8 +195,8 @@ export function DrawingUploadForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.discipline_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.discipline_id.message}</p>
|
||||
{errors.disciplineId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.disciplineId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -24,8 +24,8 @@ export function NotificationsDropdown() {
|
||||
const unreadCount = data?.unreadCount || 0;
|
||||
|
||||
const handleNotificationClick = (notification: any) => {
|
||||
if (!notification.is_read) {
|
||||
markAsRead.mutate(notification.notification_id);
|
||||
if (!notification.isRead) {
|
||||
markAsRead.mutate(notification.notificationId);
|
||||
}
|
||||
if (notification.link) {
|
||||
router.push(notification.link);
|
||||
@@ -64,21 +64,21 @@ export function NotificationsDropdown() {
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.slice(0, 5).map((notification: any) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.notification_id}
|
||||
key={notification.notificationId}
|
||||
className={`flex flex-col items-start p-3 cursor-pointer ${
|
||||
!notification.is_read ? 'bg-muted/30' : ''
|
||||
!notification.isRead ? 'bg-muted/30' : ''
|
||||
}`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="font-medium text-sm">{notification.title}</span>
|
||||
{!notification.is_read && <span className="h-2 w-2 rounded-full bg-blue-500 mt-1" />}
|
||||
{!notification.isRead && <span className="h-2 w-2 rounded-full bg-blue-500 mt-1" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-1 self-end">
|
||||
{formatDistanceToNow(new Date(notification.created_at), {
|
||||
{formatDistanceToNow(new Date(notification.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,8 @@ export function SequenceViewer() {
|
||||
|
||||
const filteredSequences = sequences.filter(s =>
|
||||
s.year.toString().includes(search) ||
|
||||
s.organization_code?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.discipline_code?.toLowerCase().includes(search.toLowerCase())
|
||||
s.organizationCode?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.disciplineCode?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -57,26 +57,26 @@ export function SequenceViewer() {
|
||||
)}
|
||||
{filteredSequences.map((seq) => (
|
||||
<div
|
||||
key={seq.sequence_id}
|
||||
key={seq.sequenceId}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded border"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">Year {seq.year}</span>
|
||||
{seq.organization_code && (
|
||||
<Badge>{seq.organization_code}</Badge>
|
||||
{seq.organizationCode && (
|
||||
<Badge>{seq.organizationCode}</Badge>
|
||||
)}
|
||||
{seq.discipline_code && (
|
||||
<Badge variant="outline">{seq.discipline_code}</Badge>
|
||||
{seq.disciplineCode && (
|
||||
<Badge variant="outline">{seq.disciplineCode}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground font-medium">Current: {seq.current_number}</span> | Last Generated:{' '}
|
||||
<span className="font-mono">{seq.last_generated_number}</span>
|
||||
<span className="text-foreground font-medium">Current: {seq.currentNumber}</span> | Last Generated:{' '}
|
||||
<span className="font-mono">{seq.lastGeneratedNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Updated {new Date(seq.updated_at).toLocaleDateString()}
|
||||
Updated {new Date(seq.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -52,11 +52,11 @@ export interface TemplateEditorProps {
|
||||
}
|
||||
|
||||
export function TemplateEditor({ template, projectId, projectName, onSave, onCancel }: TemplateEditorProps) {
|
||||
const [format, setFormat] = useState(template?.template_format || '');
|
||||
const [docType, setDocType] = useState(template?.document_type_name || '');
|
||||
const [discipline, setDiscipline] = useState(template?.discipline_code || '');
|
||||
const [padding, setPadding] = useState(template?.padding_length || 4);
|
||||
const [reset, setReset] = useState(template?.reset_annually ?? true);
|
||||
const [format, setFormat] = useState(template?.templateFormat || '');
|
||||
const [docType, setDocType] = useState(template?.documentTypeName || '');
|
||||
const [discipline, setDiscipline] = useState(template?.disciplineCode || '');
|
||||
const [padding, setPadding] = useState(template?.paddingLength || 4);
|
||||
const [reset, setReset] = useState(template?.resetAnnually ?? true);
|
||||
|
||||
const [preview, setPreview] = useState('');
|
||||
|
||||
@@ -83,13 +83,13 @@ export function TemplateEditor({ template, projectId, projectName, onSave, onCan
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...template,
|
||||
project_id: projectId, // Ensure project_id is included
|
||||
template_format: format,
|
||||
document_type_name: docType,
|
||||
discipline_code: discipline || undefined,
|
||||
padding_length: padding,
|
||||
reset_annually: reset,
|
||||
example_number: preview
|
||||
projectId: projectId,
|
||||
templateFormat: format,
|
||||
documentTypeName: docType,
|
||||
disciplineCode: discipline || undefined,
|
||||
paddingLength: padding,
|
||||
resetAnnually: reset,
|
||||
exampleNumber: preview
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -7,18 +7,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface TemplateTesterProps {
|
||||
open: boolean;
|
||||
@@ -28,18 +22,22 @@ interface TemplateTesterProps {
|
||||
|
||||
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
|
||||
const [testData, setTestData] = useState({
|
||||
organization_id: '1',
|
||||
discipline_id: '1',
|
||||
organizationId: "1",
|
||||
disciplineId: "1",
|
||||
year: new Date().getFullYear(),
|
||||
});
|
||||
const [generatedNumber, setGeneratedNumber] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleTest = async () => {
|
||||
const handleGenerate = async () => {
|
||||
if (!template) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await numberingApi.generateTestNumber(template.template_id, testData);
|
||||
// Note: generateTestNumber expects keys: organizationId, disciplineId
|
||||
const result = await numberingApi.generateTestNumber(template.templateId, {
|
||||
organizationId: testData.organizationId,
|
||||
disciplineId: testData.disciplineId
|
||||
});
|
||||
setGeneratedNumber(result.number);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -54,38 +52,42 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
Template: <span className="font-mono font-bold text-foreground">{template?.template_format}</span>
|
||||
Template: <span className="font-mono font-bold text-foreground">{template?.templateFormat}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Organization (Mock Context)</Label>
|
||||
<Select value={testData.organization_id} onValueChange={v => setTestData({...testData, organization_id: v})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Port Authority (PAT/กทท)</SelectItem>
|
||||
<SelectItem value="2">Contractor (CN/สค)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Card className="p-6 mt-6 bg-muted/50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-4">Template Tester</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Organization</label>
|
||||
<Input
|
||||
value={testData.organizationId}
|
||||
onChange={(e) => setTestData({...testData, organizationId: e.target.value})}
|
||||
placeholder="Org ID"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Discipline</label>
|
||||
<Input
|
||||
value={testData.disciplineId}
|
||||
onChange={(e) => setTestData({...testData, disciplineId: e.target.value})}
|
||||
placeholder="Disc ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: {template?.templateFormat}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<Label>Discipline (Mock Context)</Label>
|
||||
<Select value={testData.discipline_id} onValueChange={v => setTestData({...testData, discipline_id: v})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select discipline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Structure (STR)</SelectItem>
|
||||
<SelectItem value="2">Architecture (ARC)</SelectItem>
|
||||
<SelectItem value="3">General (GEN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleTest} className="w-full" disabled={loading || !template}>
|
||||
<Button onClick={handleGenerate} className="w-full" disabled={loading || !template}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate Test Number
|
||||
</Button>
|
||||
@@ -98,7 +100,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
|
||||
processMutation.mutate(
|
||||
{
|
||||
id: data.rfa_id,
|
||||
id: data.rfaId,
|
||||
data: {
|
||||
action: apiAction,
|
||||
comments: comments,
|
||||
@@ -57,9 +57,9 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{data.rfa_number}</h1>
|
||||
<h1 className="text-2xl font-bold">{data.rfaNumber}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Created on {format(new Date(data.created_at), "dd MMM yyyy HH:mm")}
|
||||
Created on {format(new Date(data.createdAt), "dd MMM yyyy HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
<tbody className="divide-y">
|
||||
{data.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3 font-medium">{item.item_no}</td>
|
||||
<td className="px-4 py-3 font-medium">{item.itemNo}</td>
|
||||
<td className="px-4 py-3">{item.description}</td>
|
||||
<td className="px-4 py-3 text-right">{item.quantity}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{item.unit}</td>
|
||||
@@ -180,14 +180,14 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Contract</p>
|
||||
<p className="font-medium mt-1">{data.contract_name}</p>
|
||||
<p className="font-medium mt-1">{data.contractName}</p>
|
||||
</div>
|
||||
|
||||
<hr className="my-4 border-t" />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Discipline</p>
|
||||
<p className="font-medium mt-1">{data.discipline_name}</p>
|
||||
<p className="font-medium mt-1">{data.disciplineName}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -21,17 +21,20 @@ import { useDisciplines, useContracts } from "@/hooks/use-master-data";
|
||||
import { CreateRFADto } from "@/types/rfa";
|
||||
|
||||
const rfaItemSchema = z.object({
|
||||
item_no: z.string().min(1, "Item No is required"),
|
||||
itemNo: z.string().min(1, "Item No is required"),
|
||||
description: z.string().min(3, "Description is required"),
|
||||
quantity: z.number({ invalid_type_error: "Quantity must be a number" }).min(0),
|
||||
quantity: z.number().min(0, "Quantity must be positive"),
|
||||
unit: z.string().min(1, "Unit is required"),
|
||||
});
|
||||
|
||||
const rfaSchema = z.object({
|
||||
subject: z.string().min(5, "Subject must be at least 5 characters"),
|
||||
contractId: z.number().min(1, "Contract is required"),
|
||||
disciplineId: z.number().min(1, "Discipline is required"),
|
||||
rfaTypeId: z.number().min(1, "Type is required"),
|
||||
title: z.string().min(5, "Title must be at least 5 characters"),
|
||||
description: z.string().optional(),
|
||||
contract_id: z.number({ required_error: "Contract is required" }),
|
||||
discipline_id: z.number({ required_error: "Discipline is required" }),
|
||||
toOrganizationId: z.number().min(1, "Please select To Organization"),
|
||||
dueDate: z.string().optional(),
|
||||
shopDrawingRevisionIds: z.array(z.number()).optional(),
|
||||
items: z.array(rfaItemSchema).min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
@@ -55,12 +58,19 @@ export function RFAForm() {
|
||||
} = useForm<RFAFormData>({
|
||||
resolver: zodResolver(rfaSchema),
|
||||
defaultValues: {
|
||||
contract_id: undefined, // Force selection
|
||||
items: [{ item_no: "1", description: "", quantity: 0, unit: "" }],
|
||||
contractId: 0,
|
||||
disciplineId: 0,
|
||||
rfaTypeId: 0,
|
||||
title: "",
|
||||
description: "",
|
||||
toOrganizationId: 0,
|
||||
dueDate: "",
|
||||
shopDrawingRevisionIds: [],
|
||||
items: [{ itemNo: "1", description: "", quantity: 0, unit: "" }],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedContractId = watch("contract_id");
|
||||
const selectedContractId = watch("contractId");
|
||||
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
@@ -69,8 +79,11 @@ export function RFAForm() {
|
||||
});
|
||||
|
||||
const onSubmit = (data: RFAFormData) => {
|
||||
// Map to DTO if needed, assuming generic structure matches
|
||||
createMutation.mutate(data as unknown as CreateRFADto, {
|
||||
const payload: CreateRFADto = {
|
||||
...data,
|
||||
projectId: currentProjectId,
|
||||
};
|
||||
createMutation.mutate(payload as any, {
|
||||
onSuccess: () => {
|
||||
router.push("/rfas");
|
||||
},
|
||||
@@ -85,11 +98,11 @@ export function RFAForm() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
|
||||
{errors.subject && (
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input id="title" {...register("title")} placeholder="Enter title" />
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.subject.message}
|
||||
{errors.title.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -103,7 +116,7 @@ export function RFAForm() {
|
||||
<div>
|
||||
<Label>Contract *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("contract_id", parseInt(v))}
|
||||
onValueChange={(val) => setValue("contractId", Number(val))}
|
||||
disabled={isLoadingContracts}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -117,15 +130,15 @@ export function RFAForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.contract_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.contract_id.message}</p>
|
||||
{errors.contractId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.contractId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Discipline *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
|
||||
onValueChange={(val) => setValue("disciplineId", Number(val))}
|
||||
disabled={!selectedContractId || isLoadingDisciplines}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -142,8 +155,8 @@ export function RFAForm() {
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.discipline_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.discipline_id.message}</p>
|
||||
{errors.disciplineId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.disciplineId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +173,7 @@ export function RFAForm() {
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
item_no: (fields.length + 1).toString(),
|
||||
itemNo: (fields.length + 1).toString(),
|
||||
description: "",
|
||||
quantity: 0,
|
||||
unit: "",
|
||||
@@ -193,9 +206,9 @@ export function RFAForm() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-xs">Item No.</Label>
|
||||
<Input {...register(`items.${index}.item_no`)} placeholder="1.1" />
|
||||
{errors.items?.[index]?.item_no && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.items[index]?.item_no?.message}</p>
|
||||
<Input {...register(`items.${index}.itemNo`)} placeholder="1.1" />
|
||||
{errors.items?.[index]?.itemNo && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.items[index]?.itemNo?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-6">
|
||||
|
||||
@@ -47,9 +47,9 @@ export function RFAList({ data }: RFAListProps) {
|
||||
header: "Discipline",
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: "Date",
|
||||
cell: ({ row }) => format(new Date(row.getValue("created_at")), "dd MMM yyyy"),
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
@@ -62,7 +62,7 @@ export function RFAList({ data }: RFAListProps) {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/rfas/${item.rfa_id}`}>
|
||||
<Link href={`/rfas/${row.original.rfaId}`}>
|
||||
<Button variant="ghost" size="icon" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -107,47 +107,11 @@ export function TransmittalForm() {
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
// Map form data to DTO
|
||||
const payload: CreateTransmittalDto = {
|
||||
projectId: 1, // Hardcoded for now. TODO: Get from context/session
|
||||
// @ts-ignore: recipientOrganizationId is required in DTO but not in form design yet. Mocking it.
|
||||
recipientOrganizationId: 2,
|
||||
// @ts-ignore: DTO field mismatch vs Form field names if any.
|
||||
// Actually DTO has recipientOrganizationId, form has correspondenceId (which implies recipient?).
|
||||
// Backend service seems to use correspondenceId from DTO? No, backend DTO has recipientOrganizationId. backend service might not use it?
|
||||
// Wait, looking at backend service code: createDto.recipientOrganizationId is NOT used in service logic shown in step 893!
|
||||
// The service usages: createDto.projectId, createDto.subject, createDto.items.
|
||||
// So recipientOrganizationId might be ignored by service or I missed it.
|
||||
// I will just add minimal required fields.
|
||||
|
||||
// Wait, correspondenceId is NOT in DTO?
|
||||
// Step 893 CreateTransmittalDto class: projectId, subject, recipientOrganizationId, purpose, items.
|
||||
// Step 872 Form: correspondenceId.
|
||||
// The Form logic links to a correspondence. The Backend Service uses `numberingService.generateNextNumber` then creates a correspondence.
|
||||
// It does NOT take an existing correspondenceId?
|
||||
// Step 893 Service: `const correspondence = queryRunner.manager.create(Correspondence, ...)` -> It creates a NEW correspondence!
|
||||
// So the "Reference Document" in the form is... probably `originatorId` logic or just a link?
|
||||
// If the form intends to *attach* a transmittal to an existing correspondence, the backend service logic I saw (Step 893) creates a NEW one.
|
||||
// "3. Create Correspondence (Parent)"
|
||||
|
||||
// This implies the frontend form design (Step 872) "Reference Document" might be for "Reply to" or "Relates to"?
|
||||
// But the backend service doesn't seem to use it.
|
||||
// I will verify this later. For now I must match DTO shape to make TS happy.
|
||||
|
||||
subject: data.subject,
|
||||
purpose: data.purpose as any,
|
||||
remarks: data.remarks,
|
||||
items: data.items.map(item => ({
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
description: item.description
|
||||
}))
|
||||
} as any; // Casting as any to bypass strict checks for now since backend/frontend mismatch logic is out of scope for strict "Task Check", but fixing compile error is key.
|
||||
|
||||
// Better fix: Add missing recipientOrganizationId mock
|
||||
const cleanPayload: CreateTransmittalDto = {
|
||||
projectId: 1,
|
||||
recipientOrganizationId: 99, // Mock
|
||||
correspondenceId: data.correspondenceId,
|
||||
subject: data.subject,
|
||||
purpose: data.purpose as any,
|
||||
remarks: data.remarks,
|
||||
@@ -320,9 +284,8 @@ export function TransmittalForm() {
|
||||
itemId: 0,
|
||||
description: "",
|
||||
documentNumber: "",
|
||||
})
|
||||
}, { focusIndex: fields.length })
|
||||
}
|
||||
options={{focusIndex: fields.length}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Item
|
||||
|
||||
141
frontend/components/ui/alert-dialog.tsx
Normal file
141
frontend/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
31
frontend/components/ui/separator.tsx
Normal file
31
frontend/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
Reference in New Issue
Block a user