260322:1648 Correct Coresspondence / Doing RFA / Correct CI
This commit is contained in:
@@ -1,45 +1,30 @@
|
||||
"use client";
|
||||
'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";
|
||||
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" },
|
||||
{ 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"),
|
||||
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(),
|
||||
});
|
||||
@@ -52,11 +37,7 @@ interface OrganizationDialogProps {
|
||||
organization?: Organization | null;
|
||||
}
|
||||
|
||||
export function OrganizationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
organization,
|
||||
}: OrganizationDialogProps) {
|
||||
export function OrganizationDialog({ open, onOpenChange, organization }: OrganizationDialogProps) {
|
||||
const createOrg = useCreateOrganization();
|
||||
const updateOrg = useUpdateOrganization();
|
||||
|
||||
@@ -71,9 +52,9 @@ export function OrganizationDialog({
|
||||
} = useForm<OrganizationFormData>({
|
||||
resolver: zodResolver(organizationSchema),
|
||||
defaultValues: {
|
||||
organizationCode: "",
|
||||
organizationName: "",
|
||||
roleId: "",
|
||||
organizationCode: '',
|
||||
organizationName: '',
|
||||
roleId: '',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
@@ -83,14 +64,14 @@ export function OrganizationDialog({
|
||||
reset({
|
||||
organizationCode: organization.organizationCode,
|
||||
organizationName: organization.organizationName,
|
||||
roleId: organization.roleId?.toString() || "",
|
||||
roleId: organization.roleId?.toString() || '',
|
||||
isActive: organization.isActive,
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
organizationCode: "",
|
||||
organizationName: "",
|
||||
roleId: "",
|
||||
organizationCode: '',
|
||||
organizationName: '',
|
||||
roleId: '',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
@@ -99,14 +80,11 @@ export function OrganizationDialog({
|
||||
const onSubmit = (data: OrganizationFormData) => {
|
||||
const submitData = {
|
||||
...data,
|
||||
roleId: data.roleId ? parseInt(data.roleId) : undefined,
|
||||
roleId: data.roleId ? Number(data.roleId) : undefined,
|
||||
};
|
||||
|
||||
if (organization) {
|
||||
updateOrg.mutate(
|
||||
{ uuid: organization.uuid, data: submitData },
|
||||
{ onSuccess: () => onOpenChange(false) }
|
||||
);
|
||||
updateOrg.mutate({ uuid: organization.uuid, data: submitData }, { onSuccess: () => onOpenChange(false) });
|
||||
} else {
|
||||
createOrg.mutate(submitData, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
@@ -118,31 +96,19 @@ export function OrganizationDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{organization ? "Edit Organization" : "New Organization"}
|
||||
</DialogTitle>
|
||||
<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>
|
||||
)}
|
||||
<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)}
|
||||
>
|
||||
<Select value={watch('roleId')} onValueChange={(value) => setValue('roleId', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
@@ -159,49 +125,28 @@ export function OrganizationDialog({
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createOrg.isPending || updateOrg.isPending}
|
||||
>
|
||||
{organization ? "Save Changes" : "Create Organization"}
|
||||
<Button type="submit" disabled={createOrg.isPending || updateOrg.isPending}>
|
||||
{organization ? 'Save Changes' : 'Create Organization'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { flexRender, getCoreRowModel, useReactTable, ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -38,21 +20,15 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface Field {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "checkbox" | "select" | "textarea";
|
||||
type: 'text' | 'number' | 'checkbox' | 'select' | 'textarea';
|
||||
required?: boolean;
|
||||
options?: { label: string; value: string | number }[];
|
||||
}
|
||||
@@ -93,7 +69,11 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
const [editingItem, setEditingId] = useState<number | null>(null);
|
||||
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
|
||||
|
||||
const { data: rawData, isLoading, refetch } = useQuery({
|
||||
const {
|
||||
data: rawData,
|
||||
isLoading,
|
||||
_refetch,
|
||||
} = useQuery({
|
||||
queryKey,
|
||||
queryFn: fetchFn,
|
||||
});
|
||||
@@ -154,14 +134,10 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
columns: [
|
||||
...columns,
|
||||
{
|
||||
id: "actions",
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEdit(row.original)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -183,7 +159,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
setEditingId(null);
|
||||
reset();
|
||||
fields.forEach((f) => {
|
||||
if (f.type === "checkbox") setValue(f.name, true);
|
||||
if (f.type === 'checkbox') setValue(f.name, true);
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
@@ -192,11 +168,11 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
setEditingId(item.id as number);
|
||||
reset(item as Record<string, unknown>);
|
||||
// Ensure select values are strings for Shadcn Select
|
||||
fields.forEach(f => {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (f.type === 'select' && record[f.name]) {
|
||||
setValue(f.name, String(record[f.name]));
|
||||
}
|
||||
fields.forEach((f) => {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (f.type === 'select' && record[f.name]) {
|
||||
setValue(f.name, String(record[f.name]));
|
||||
}
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
@@ -214,9 +190,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Add {entityName}
|
||||
@@ -232,12 +206,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -246,10 +215,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<TableCell colSpan={columns.length + 1} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
@@ -258,10 +224,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
</TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
<TableCell colSpan={columns.length + 1} className="h-24 text-center text-muted-foreground">
|
||||
No data found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -269,12 +232,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
@@ -286,17 +244,15 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingItem ? `Edit ${entityName}` : `Add New ${entityName}`}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{editingItem ? `Edit ${entityName}` : `Add New ${entityName}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label} {field.required && "*"}
|
||||
{field.label} {field.required && '*'}
|
||||
</Label>
|
||||
{field.type === "checkbox" ? (
|
||||
{field.type === 'checkbox' ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
@@ -310,11 +266,8 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
value={String(watch(field.name) || "")}
|
||||
onValueChange={(val) => setValue(field.name, val)}
|
||||
>
|
||||
) : field.type === 'select' ? (
|
||||
<Select value={String(watch(field.name) || '')} onValueChange={(val) => setValue(field.name, val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${field.label}...`} />
|
||||
</SelectTrigger>
|
||||
@@ -326,57 +279,43 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
{...register(field.name, { required: field.required })}
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Textarea id={field.name} {...register(field.name, { required: field.required })} />
|
||||
) : (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.type}
|
||||
{...register(field.name, {
|
||||
required: field.required,
|
||||
valueAsNumber: field.type === "number",
|
||||
valueAsNumber: field.type === 'number',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{errors[field.name] && (
|
||||
<p className="text-xs text-red-500 font-medium">
|
||||
{field.label} is required
|
||||
</p>
|
||||
)}
|
||||
{errors[field.name] && <p className="text-xs text-red-500 font-medium">{field.label} is required</p>}
|
||||
</div>
|
||||
))}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{(createMutation.isPending || updateMutation.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{editingItem ? "Save Changes" : `Add ${entityName}`}
|
||||
{editingItem ? 'Save Changes' : `Add ${entityName}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={itemToDelete !== null}
|
||||
onOpenChange={(open) => !open && setItemToDelete(null)}
|
||||
>
|
||||
<AlertDialog open={itemToDelete !== null} onOpenChange={(open) => !open && setItemToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete this{" "}
|
||||
{entityName.toLowerCase()} and remove its data from our servers.
|
||||
This action cannot be undone. This will permanently delete this {entityName.toLowerCase()} and remove its
|
||||
data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -385,7 +324,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
|
||||
onClick={() => itemToDelete && deleteMutation.mutate(itemToDelete)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import apiClient from "@/lib/api/client";
|
||||
import { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
interface Role {
|
||||
roleId: number;
|
||||
@@ -28,7 +21,7 @@ interface Permission {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RbacMatrixProps {
|
||||
interface _RbacMatrixProps {
|
||||
roles: Role[];
|
||||
permissions: Permission[];
|
||||
rolePermissions: Record<number, number[]>; // roleId -> permissionIds[]
|
||||
@@ -42,7 +35,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
|
||||
return current as T[];
|
||||
}
|
||||
|
||||
if (!current || typeof current !== "object" || !("data" in current)) {
|
||||
if (!current || typeof current !== 'object' || !('data' in current)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -54,11 +47,11 @@ const extractArrayData = <T,>(value: unknown): T[] => {
|
||||
|
||||
const securityService = {
|
||||
getRoles: async (): Promise<Role[]> => {
|
||||
const response = await apiClient.get("/users/roles");
|
||||
const response = await apiClient.get('/users/roles');
|
||||
return extractArrayData<Role>(response.data);
|
||||
},
|
||||
getPermissions: async (): Promise<Permission[]> => {
|
||||
const response = await apiClient.get("/users/permissions");
|
||||
const response = await apiClient.get('/users/permissions');
|
||||
return extractArrayData<Permission>(response.data);
|
||||
},
|
||||
updateRolePermissions: async (roleId: number, permissionIds: number[]) => {
|
||||
@@ -73,12 +66,12 @@ export function RbacMatrix() {
|
||||
const [pendingChanges, setPendingChanges] = useState<Record<number, number[]>>({});
|
||||
|
||||
const { data: roles = [], isLoading: rolesLoading } = useQuery<Role[]>({
|
||||
queryKey: ["roles"],
|
||||
queryKey: ['roles'],
|
||||
queryFn: securityService.getRoles,
|
||||
});
|
||||
|
||||
const { data: permissions = [], isLoading: permsLoading } = useQuery<Permission[]>({
|
||||
queryKey: ["permissions"],
|
||||
queryKey: ['permissions'],
|
||||
queryFn: securityService.getPermissions,
|
||||
});
|
||||
|
||||
@@ -92,16 +85,16 @@ export function RbacMatrix() {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (changes: Record<number, number[]>) => {
|
||||
const promises = Object.entries(changes).map(([roleId, perms]) =>
|
||||
securityService.updateRolePermissions(parseInt(roleId), perms)
|
||||
securityService.updateRolePermissions(Number(roleId), perms)
|
||||
);
|
||||
return Promise.all(promises);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Permissions updated successfully");
|
||||
toast.success('Permissions updated successfully');
|
||||
setPendingChanges({});
|
||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||
},
|
||||
onError: () => toast.error("Failed to update permissions"),
|
||||
onError: () => toast.error('Failed to update permissions'),
|
||||
});
|
||||
|
||||
const handleToggle = (roleId: number, permId: number, currentPerms: number[]) => {
|
||||
|
||||
@@ -1,65 +1,63 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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, useState } from "react";
|
||||
import { User } from "@/types/user";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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, useState } from 'react';
|
||||
import { User } from '@/types/user';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const ALL_ORGANIZATIONS_VALUE = "all";
|
||||
const ALL_ORGANIZATIONS_VALUE = 'all';
|
||||
|
||||
// Update schema to include confirmPassword
|
||||
const userSchema = z.object({
|
||||
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.string().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"]
|
||||
});
|
||||
const userSchema = z
|
||||
.object({
|
||||
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.string().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>;
|
||||
|
||||
@@ -87,16 +85,16 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
} = useForm<UserFormData>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
username: '',
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
isActive: true,
|
||||
roleIds: [],
|
||||
lineId: "",
|
||||
lineId: '',
|
||||
primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,24 +106,24 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isActive: user.isActive,
|
||||
lineId: user.lineId || "",
|
||||
lineId: user.lineId || '',
|
||||
primaryOrganizationId: user.primaryOrganizationId?.toString() || ALL_ORGANIZATIONS_VALUE,
|
||||
roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [],
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
username: "",
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
username: '',
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
isActive: true,
|
||||
lineId: "",
|
||||
lineId: '',
|
||||
primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
|
||||
roleIds: [],
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
}
|
||||
// Also reset visibility
|
||||
@@ -133,17 +131,17 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
setShowConfirmPassword(false);
|
||||
}, [user, reset, open]);
|
||||
|
||||
const selectedRoleIds = watch("roleIds") || [];
|
||||
const selectedRoleIds = watch('roleIds') || [];
|
||||
|
||||
const onSubmit = (data: UserFormData) => {
|
||||
// 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.
|
||||
// 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
|
||||
@@ -155,27 +153,27 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
}
|
||||
|
||||
if (user) {
|
||||
updateUser.mutate(
|
||||
{ uuid: user.uuid, data: payload },
|
||||
{ onSuccess: () => onOpenChange(false) }
|
||||
);
|
||||
updateUser.mutate({ uuid: user.uuid, data: payload }, { onSuccess: () => onOpenChange(false) });
|
||||
} else {
|
||||
// Create req: Password mandatory
|
||||
if (!payload.password) return; // Should allow Zod to catch or show error
|
||||
|
||||
createUser.mutate({
|
||||
username: payload.username,
|
||||
email: payload.email,
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
password: payload.password,
|
||||
isActive: payload.isActive ?? true,
|
||||
lineId: payload.lineId,
|
||||
primaryOrganizationId: payload.primaryOrganizationId,
|
||||
roleIds: payload.roleIds ?? [],
|
||||
}, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
});
|
||||
createUser.mutate(
|
||||
{
|
||||
username: payload.username,
|
||||
email: payload.email,
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
password: payload.password,
|
||||
isActive: payload.isActive ?? true,
|
||||
lineId: payload.lineId,
|
||||
primaryOrganizationId: payload.primaryOrganizationId,
|
||||
roleIds: payload.roleIds ?? [],
|
||||
},
|
||||
{
|
||||
onSuccess: () => onOpenChange(false),
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,77 +181,61 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{user ? "Edit User" : "Create New User"}</DialogTitle>
|
||||
<DialogTitle>{user ? 'Edit User' : 'Create New User'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Username *</Label>
|
||||
<Input
|
||||
{...register("username")}
|
||||
disabled={!!user}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-red-500">{errors.username.message}</p>
|
||||
)}
|
||||
<Input {...register('username')} disabled={!!user} autoComplete="off" />
|
||||
{errors.username && <p className="text-sm text-red-500">{errors.username.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Email *</Label>
|
||||
<Input type="email" {...register("email")} autoComplete="off" />
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
<Input type="email" {...register('email')} autoComplete="off" />
|
||||
{errors.email && <p className="text-sm text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>First Name *</Label>
|
||||
<Input {...register("firstName")} autoComplete="off" />
|
||||
{errors.firstName && (
|
||||
<p className="text-sm text-red-500">{errors.firstName.message}</p>
|
||||
)}
|
||||
<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("lastName")} autoComplete="off" />
|
||||
{errors.lastName && (
|
||||
<p className="text-sm text-red-500">{errors.lastName.message}</p>
|
||||
)}
|
||||
<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("lineId")} autoComplete="off" />
|
||||
<Input {...register('lineId')} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Primary Organization</Label>
|
||||
<Select
|
||||
value={watch("primaryOrganizationId") || ALL_ORGANIZATIONS_VALUE}
|
||||
onValueChange={(val) =>
|
||||
setValue("primaryOrganizationId", val)
|
||||
}
|
||||
value={watch('primaryOrganizationId') || ALL_ORGANIZATIONS_VALUE}
|
||||
onValueChange={(val) => setValue('primaryOrganizationId', val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Organization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ORGANIZATIONS_VALUE}>All Organizations</SelectItem>
|
||||
{Array.isArray(organizations) && organizations.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
|
||||
<SelectItem
|
||||
key={org.uuid}
|
||||
value={org.uuid}
|
||||
>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
{Array.isArray(organizations) &&
|
||||
organizations.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
|
||||
<SelectItem key={org.uuid} value={org.uuid}>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -261,91 +243,88 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
|
||||
{/* 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>
|
||||
<h3 className="text-sm font-medium">{user ? 'Change Password (Optional)' : 'Password Setup'}</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Label>Password {user ? '' : '*'}</Label>
|
||||
<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>
|
||||
)}
|
||||
<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 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>
|
||||
</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>
|
||||
<div className="space-y-2 border p-3 rounded-md max-h-[200px] overflow-y-auto">
|
||||
{Array.isArray(roles) && roles.length === 0 && <p className="text-sm text-muted-foreground">Loading roles...</p>}
|
||||
{Array.isArray(roles) && roles.map((role: { roleId: number; roleName: string; description?: string }) => (
|
||||
<div key={role.roleId} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`role-${role.roleId}`}
|
||||
checked={selectedRoleIds.includes(role.roleId)}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = selectedRoleIds;
|
||||
if (checked) {
|
||||
setValue("roleIds", [...current, role.roleId]);
|
||||
} else {
|
||||
setValue(
|
||||
"roleIds",
|
||||
current.filter((id) => id !== role.roleId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={`role-${role.roleId}`}
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
{role.roleName}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</p>
|
||||
{Array.isArray(roles) && roles.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Loading roles...</p>
|
||||
)}
|
||||
{Array.isArray(roles) &&
|
||||
roles.map((role: { roleId: number; roleName: string; description?: string }) => (
|
||||
<div key={role.roleId} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`role-${role.roleId}`}
|
||||
checked={selectedRoleIds.includes(role.roleId)}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = selectedRoleIds;
|
||||
if (checked) {
|
||||
setValue('roleIds', [...current, role.roleId]);
|
||||
} else {
|
||||
setValue(
|
||||
'roleIds',
|
||||
current.filter((id) => id !== role.roleId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={`role-${role.roleId}`}
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
{role.roleName}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{role.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,31 +332,21 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
checked={watch("isActive")}
|
||||
onCheckedChange={(chk) => setValue("isActive", chk === true)}
|
||||
checked={watch('isActive')}
|
||||
onCheckedChange={(chk) => setValue('isActive', chk === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="is_active"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
<label htmlFor="is_active" className="text-sm font-medium leading-none cursor-pointer">
|
||||
Active User
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createUser.isPending || updateUser.isPending}
|
||||
>
|
||||
{user ? "Update User" : "Create User"}
|
||||
<Button type="submit" disabled={createUser.isPending || updateUser.isPending}>
|
||||
{user ? 'Update User' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function AuthSync() {
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
role: user.role || 'User',
|
||||
permissions: user.permissions
|
||||
permissions: user.permissions,
|
||||
},
|
||||
(session as { accessToken?: string }).accessToken || ''
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Circulation, CirculationListResponse } from "@/types/circulation";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, CheckCircle2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Circulation, CirculationListResponse } from '@/types/circulation';
|
||||
import { DataTable } from '@/components/common/data-table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { _StatusBadge } from '@/components/common/status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Eye, _CheckCircle2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface CirculationListProps {
|
||||
data: CirculationListResponse;
|
||||
@@ -17,29 +17,27 @@ interface CirculationListProps {
|
||||
/**
|
||||
* Calculate progress of circulation routings
|
||||
*/
|
||||
function getProgress(routings?: Circulation["routings"]) {
|
||||
function getProgress(routings?: Circulation['routings']) {
|
||||
if (!routings || routings.length === 0) return { completed: 0, total: 0 };
|
||||
const completed = routings.filter((r) => r.status === "COMPLETED").length;
|
||||
const completed = routings.filter((r) => r.status === 'COMPLETED').length;
|
||||
return { completed, total: routings.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color variant for circulation status
|
||||
*/
|
||||
function getStatusVariant(
|
||||
statusCode: string
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
function getStatusVariant(statusCode: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (statusCode?.toUpperCase()) {
|
||||
case "DRAFT":
|
||||
return "outline";
|
||||
case "ACTIVE":
|
||||
case "IN_PROGRESS":
|
||||
return "default";
|
||||
case "COMPLETED":
|
||||
case "CLOSED":
|
||||
return "secondary";
|
||||
case 'DRAFT':
|
||||
return 'outline';
|
||||
case 'ACTIVE':
|
||||
case 'IN_PROGRESS':
|
||||
return 'default';
|
||||
case 'COMPLETED':
|
||||
case 'CLOSED':
|
||||
return 'secondary';
|
||||
default:
|
||||
return "outline";
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,51 +46,46 @@ export function CirculationList({ data }: CirculationListProps) {
|
||||
|
||||
const columns: ColumnDef<Circulation>[] = [
|
||||
{
|
||||
accessorKey: "circulationNo",
|
||||
header: "Circulation No.",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("circulationNo")}</span>
|
||||
),
|
||||
accessorKey: 'circulationNo',
|
||||
header: 'Circulation No.',
|
||||
cell: ({ row }) => <span className="font-medium">{row.getValue('circulationNo')}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
accessorKey: 'subject',
|
||||
header: 'Subject',
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[250px] truncate" title={row.getValue("subject")}>
|
||||
{row.getValue("subject")}
|
||||
<div className="max-w-[250px] truncate" title={row.getValue('subject')}>
|
||||
{row.getValue('subject')}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "organization",
|
||||
header: "Organization",
|
||||
accessorKey: 'organization',
|
||||
header: 'Organization',
|
||||
cell: ({ row }) => {
|
||||
const org = row.original.organization;
|
||||
return org?.organization_name || "-";
|
||||
return org?.organization_name || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "statusCode",
|
||||
header: "Status",
|
||||
accessorKey: 'statusCode',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("statusCode") as string;
|
||||
const status = row.getValue('statusCode') as string;
|
||||
return <Badge variant={getStatusVariant(status)}>{status}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
header: "Progress",
|
||||
id: 'progress',
|
||||
header: 'Progress',
|
||||
cell: ({ row }) => {
|
||||
const { completed, total } = getProgress(row.original.routings);
|
||||
if (total === 0) return "-";
|
||||
if (total === 0) return '-';
|
||||
const percent = Math.round((completed / total) * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<div className="h-full bg-primary transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completed}/{total}
|
||||
@@ -102,13 +95,12 @@ export function CirculationList({ data }: CirculationListProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) =>
|
||||
format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => format(new Date(row.getValue('createdAt')), 'dd MMM yyyy'),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
|
||||
@@ -16,12 +16,7 @@ interface CanProps {
|
||||
// Common use case: <Can permission="x">
|
||||
}
|
||||
|
||||
export function Can({
|
||||
permission,
|
||||
role,
|
||||
children,
|
||||
fallback = null,
|
||||
}: CanProps) {
|
||||
export function Can({ permission, role, children, fallback = null }: CanProps) {
|
||||
const { hasPermission, hasRole } = useAuthStore();
|
||||
|
||||
let allowed = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
@@ -27,8 +27,8 @@ export function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -39,9 +39,7 @@ export function ConfirmDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction onClick={onConfirm}>{confirmText}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
@@ -10,18 +10,14 @@ interface PaginationProps {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
}: PaginationProps) {
|
||||
export function Pagination({ currentPage, totalPages, total }: PaginationProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const createPageURL = (pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
params.set('page', pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -50,7 +46,7 @@ export function Pagination({
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
variant={pageNum === currentPage ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => router.push(createPageURL(pageNum))}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
@@ -7,56 +7,53 @@ interface StatusBadgeProps {
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: string }> = {
|
||||
DRAFT: { label: "Draft", variant: "secondary" },
|
||||
PENDING: { label: "Pending", variant: "warning" }, // Note: Shadcn/UI might not have 'warning' variant by default, may need custom CSS or use 'secondary'
|
||||
IN_REVIEW: { label: "In Review", variant: "default" }, // Using 'default' (primary) for In Review
|
||||
APPROVED: { label: "Approved", variant: "success" }, // Note: 'success' might need custom CSS
|
||||
REJECTED: { label: "Rejected", variant: "destructive" },
|
||||
CLOSED: { label: "Closed", variant: "outline" },
|
||||
DRAFT: { label: 'Draft', variant: 'secondary' },
|
||||
PENDING: { label: 'Pending', variant: 'warning' }, // Note: Shadcn/UI might not have 'warning' variant by default, may need custom CSS or use 'secondary'
|
||||
IN_REVIEW: { label: 'In Review', variant: 'default' }, // Using 'default' (primary) for In Review
|
||||
APPROVED: { label: 'Approved', variant: 'success' }, // Note: 'success' might need custom CSS
|
||||
REJECTED: { label: 'Rejected', variant: 'destructive' },
|
||||
CLOSED: { label: 'Closed', variant: 'outline' },
|
||||
};
|
||||
|
||||
// Fallback for unknown statuses
|
||||
const defaultStatus = { label: "Unknown", variant: "outline" };
|
||||
const _defaultStatus = { label: 'Unknown', variant: 'outline' };
|
||||
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
const config = statusConfig[status] || { label: status, variant: "default" };
|
||||
const config = statusConfig[status] || { label: status, variant: 'default' };
|
||||
|
||||
// Mapping custom variants to Shadcn Badge variants if needed
|
||||
// For now, we'll assume standard variants or rely on className overrides for colors
|
||||
let badgeVariant: "default" | "secondary" | "destructive" | "outline" = "default";
|
||||
let customClass = "";
|
||||
let badgeVariant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
|
||||
let customClass = '';
|
||||
|
||||
switch (config.variant) {
|
||||
case "secondary":
|
||||
badgeVariant = "secondary";
|
||||
case 'secondary':
|
||||
badgeVariant = 'secondary';
|
||||
break;
|
||||
case "destructive":
|
||||
badgeVariant = "destructive";
|
||||
case 'destructive':
|
||||
badgeVariant = 'destructive';
|
||||
break;
|
||||
case "outline":
|
||||
badgeVariant = "outline";
|
||||
case 'outline':
|
||||
badgeVariant = 'outline';
|
||||
break;
|
||||
case "warning":
|
||||
badgeVariant = "secondary"; // Fallback
|
||||
customClass = "bg-yellow-500 hover:bg-yellow-600 text-white";
|
||||
case 'warning':
|
||||
badgeVariant = 'secondary'; // Fallback
|
||||
customClass = 'bg-yellow-500 hover:bg-yellow-600 text-white';
|
||||
break;
|
||||
case "success":
|
||||
badgeVariant = "default"; // Fallback
|
||||
customClass = "bg-green-500 hover:bg-green-600 text-white";
|
||||
case 'success':
|
||||
badgeVariant = 'default'; // Fallback
|
||||
customClass = 'bg-green-500 hover:bg-green-600 text-white';
|
||||
break;
|
||||
case "info":
|
||||
badgeVariant = "default";
|
||||
customClass = "bg-blue-500 hover:bg-blue-600 text-white";
|
||||
case 'info':
|
||||
badgeVariant = 'default';
|
||||
customClass = 'bg-blue-500 hover:bg-blue-600 text-white';
|
||||
break;
|
||||
default:
|
||||
badgeVariant = "default";
|
||||
badgeVariant = 'default';
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={badgeVariant}
|
||||
className={cn("uppercase", customClass, className)}
|
||||
>
|
||||
<Badge variant={badgeVariant} className={cn('uppercase', customClass, className)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
'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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
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';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
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 page = Number(searchParams.get('page') || '1');
|
||||
const _status = searchParams.get('status') || undefined;
|
||||
const search = searchParams.get('search') || undefined;
|
||||
|
||||
const revisionStatus = (searchParams.get('revisionStatus') as 'CURRENT' | 'ALL' | 'OLD') || 'CURRENT';
|
||||
|
||||
@@ -31,28 +31,23 @@ export function CorrespondencesContent() {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-red-500 text-center py-8">
|
||||
Failed to load correspondences.
|
||||
</div>
|
||||
);
|
||||
return <div className="text-red-500 text-center py-8">Failed to load correspondences.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex gap-2">
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-md">
|
||||
{['ALL', 'CURRENT', 'OLD'].map((status) => (
|
||||
<Link key={status} href={`?${new URLSearchParams({...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1'}).toString()}`}>
|
||||
<Button
|
||||
variant={revisionStatus === status ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="text-xs px-3"
|
||||
>
|
||||
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-md">
|
||||
{['ALL', 'CURRENT', 'OLD'].map((status) => (
|
||||
<Link
|
||||
key={status}
|
||||
href={`?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1' }).toString()}`}
|
||||
>
|
||||
<Button variant={revisionStatus === status ? 'default' : 'ghost'} size="sm" className="text-xs px-3">
|
||||
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CorrespondenceList data={data?.data || []} />
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Correspondence } from "@/types/correspondence";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle, Edit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSubmitCorrespondence, useProcessWorkflow } from "@/hooks/use-correspondence";
|
||||
import { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Correspondence } from '@/types/correspondence';
|
||||
import { StatusBadge } from '@/components/common/status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle, Edit } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSubmitCorrespondence, useProcessWorkflow } from '@/hooks/use-correspondence';
|
||||
import { useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface CorrespondenceDetailProps {
|
||||
data: Correspondence;
|
||||
@@ -19,26 +19,26 @@ interface CorrespondenceDetailProps {
|
||||
export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
const submitMutation = useSubmitCorrespondence();
|
||||
const processMutation = useProcessWorkflow();
|
||||
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
|
||||
const [comments, setComments] = useState("");
|
||||
const [actionState, setActionState] = useState<'approve' | 'reject' | null>(null);
|
||||
const [comments, setComments] = useState('');
|
||||
|
||||
if (!data) return <div>No data found</div>;
|
||||
|
||||
// Derive Current Revision Data
|
||||
const currentRevision = data.revisions?.find(r => r.isCurrent) || data.revisions?.[0];
|
||||
const subject = currentRevision?.subject || "-";
|
||||
const description = currentRevision?.description || "-";
|
||||
const status = currentRevision?.status?.statusCode || "UNKNOWN"; // e.g. DRAFT
|
||||
const currentRevision = data.revisions?.find((r) => r.isCurrent) || data.revisions?.[0];
|
||||
const subject = currentRevision?.subject || '-';
|
||||
const description = currentRevision?.description || '-';
|
||||
const status = currentRevision?.status?.statusCode || 'UNKNOWN'; // e.g. DRAFT
|
||||
const attachments = currentRevision?.attachments || [];
|
||||
|
||||
// Note: Importance might be in details
|
||||
const importance = currentRevision?.details?.importance || "NORMAL";
|
||||
const importance = currentRevision?.details?.importance || 'NORMAL';
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (confirm("Are you sure you want to submit this correspondence?")) {
|
||||
if (confirm('Are you sure you want to submit this correspondence?')) {
|
||||
submitMutation.mutate({
|
||||
uuid: data.uuid,
|
||||
data: {}
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -46,19 +46,22 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
const handleProcess = () => {
|
||||
if (!actionState) return;
|
||||
|
||||
const action = actionState === "approve" ? "APPROVE" : "REJECT";
|
||||
processMutation.mutate({
|
||||
uuid: data.uuid,
|
||||
data: {
|
||||
action,
|
||||
comments
|
||||
const action = actionState === 'approve' ? 'APPROVE' : 'REJECT';
|
||||
processMutation.mutate(
|
||||
{
|
||||
uuid: data.uuid,
|
||||
data: {
|
||||
action,
|
||||
comments,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setActionState(null);
|
||||
setComments('');
|
||||
},
|
||||
}
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setActionState(null);
|
||||
setComments("");
|
||||
}
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -74,40 +77,38 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{data.correspondenceNumber}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Created on {data.createdAt ? format(new Date(data.createdAt), "dd MMM yyyy HH:mm") : '-'}
|
||||
Created on {data.createdAt ? format(new Date(data.createdAt), 'dd MMM yyyy HH:mm') : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* EDIT BUTTON LOGIC: Show if DRAFT */}
|
||||
{status === "DRAFT" && (
|
||||
<Link href={`/correspondences/${data.uuid}/edit`}>
|
||||
<Button variant="outline">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
{/* EDIT BUTTON LOGIC: Show if DRAFT */}
|
||||
{status === 'DRAFT' && (
|
||||
<Link href={`/correspondences/${data.uuid}/edit`}>
|
||||
<Button variant="outline">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{status === "DRAFT" && (
|
||||
{status === 'DRAFT' && (
|
||||
<Button onClick={handleSubmit} disabled={submitMutation.isPending}>
|
||||
{submitMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
{submitMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Submit for Review
|
||||
</Button>
|
||||
)}
|
||||
{status === "IN_REVIEW" && (
|
||||
{status === 'IN_REVIEW' && (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setActionState("reject")}
|
||||
>
|
||||
<Button variant="destructive" onClick={() => setActionState('reject')}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => setActionState("approve")}
|
||||
>
|
||||
<Button className="bg-green-600 hover:bg-green-700" onClick={() => setActionState('approve')}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
@@ -120,31 +121,33 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
{actionState && (
|
||||
<Card className="border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{actionState === "approve" ? "Confirm Approval" : "Confirm Rejection"}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-lg">
|
||||
{actionState === 'approve' ? 'Confirm Approval' : 'Confirm Rejection'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Comments</Label>
|
||||
<Textarea
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Enter comments..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setActionState(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant={actionState === "approve" ? "default" : "destructive"}
|
||||
onClick={handleProcess}
|
||||
disabled={processMutation.isPending}
|
||||
className={actionState === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm {actionState === "approve" ? "Approve" : "Reject"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Comments</Label>
|
||||
<Textarea
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Enter comments..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setActionState(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={actionState === 'approve' ? 'default' : 'destructive'}
|
||||
onClick={handleProcess}
|
||||
disabled={processMutation.isPending}
|
||||
className={actionState === 'approve' ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
>
|
||||
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm {actionState === 'approve' ? 'Approve' : 'Reject'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -162,9 +165,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Description</h3>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{description}</p>
|
||||
</div>
|
||||
|
||||
{currentRevision?.body && (
|
||||
@@ -179,9 +180,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
{currentRevision?.remarks && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Remarks</h3>
|
||||
<p className="text-gray-600 italic">
|
||||
{currentRevision.remarks}
|
||||
</p>
|
||||
<p className="text-gray-600 italic">{currentRevision.remarks}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -226,10 +225,16 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Importance</p>
|
||||
<div className="mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${importance === 'URGENT' ? 'bg-red-100 text-red-800' :
|
||||
importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-blue-100 text-blue-800'}`}>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${
|
||||
importance === 'URGENT'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: importance === 'HIGH'
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}
|
||||
>
|
||||
{String(importance)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -243,7 +248,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
<p className="text-xs text-muted-foreground">{data.originator?.organizationCode || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Project</p>
|
||||
<p className="font-medium mt-1">{data.project?.projectName || '-'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.project?.projectCode || '-'}</p>
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FileUploadZone } from "@/components/custom/file-upload-zone";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence";
|
||||
import { Organization } from "@/types/organization";
|
||||
import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from "@/hooks/use-master-data";
|
||||
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
|
||||
import { useState, useEffect } from "react";
|
||||
import { correspondenceService } from "@/lib/services/correspondence.service";
|
||||
import { numberingApi } from "@/lib/api/numbering";
|
||||
import { useForm, Resolver } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { FileUploadZone } from '@/components/custom/file-upload-zone';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useCreateCorrespondence, useUpdateCorrespondence } from '@/hooks/use-correspondence';
|
||||
import { Organization } from '@/types/organization';
|
||||
import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from '@/hooks/use-master-data';
|
||||
import { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-correspondence.dto';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { correspondenceService as _correspondenceService } from '@/lib/services/correspondence.service';
|
||||
import { numberingApi } from '@/lib/api/numbering';
|
||||
|
||||
// Updated Zod Schema with all required fields
|
||||
const correspondenceSchema = z.object({
|
||||
projectId: z.string().min(1, "Please select a Project"),
|
||||
documentTypeId: z.number().min(1, "Please select a Document Type"),
|
||||
projectId: z.string().min(1, 'Please select a Project'),
|
||||
documentTypeId: z.number().min(1, 'Please select a Document Type'),
|
||||
disciplineId: z.number().optional(),
|
||||
subject: z.string().min(5, "Subject must be at least 5 characters"),
|
||||
subject: z.string().min(5, 'Subject must be at least 5 characters'),
|
||||
description: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
remarks: z.string().optional(),
|
||||
@@ -38,9 +32,9 @@ const correspondenceSchema = z.object({
|
||||
documentDate: z.string().optional(),
|
||||
issuedDate: z.string().optional(),
|
||||
receivedDate: z.string().optional(),
|
||||
fromOrganizationId: z.string().min(1, "Please select From Organization"),
|
||||
toOrganizationId: z.string().min(1, "Please select To Organization"),
|
||||
importance: z.enum(["NORMAL", "HIGH", "URGENT"]),
|
||||
fromOrganizationId: z.string().min(1, 'Please select From Organization'),
|
||||
toOrganizationId: z.string().min(1, 'Please select To Organization'),
|
||||
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']),
|
||||
attachments: z.array(z.instanceof(File)).optional(),
|
||||
});
|
||||
|
||||
@@ -59,11 +53,37 @@ type CorrespondenceTypeOption = {
|
||||
typeCode: string;
|
||||
};
|
||||
|
||||
type DisciplineOption = {
|
||||
interface DisciplineOption {
|
||||
id: number;
|
||||
disciplineCode: string;
|
||||
codeNameEn?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface InitialCorrespondenceData {
|
||||
projectId?: number | string;
|
||||
project?: { uuid?: string };
|
||||
correspondenceTypeId?: number;
|
||||
disciplineId?: number;
|
||||
revisions?: Array<{
|
||||
isCurrent?: boolean;
|
||||
subject?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
remarks?: string;
|
||||
dueDate?: string;
|
||||
documentDate?: string;
|
||||
issuedDate?: string;
|
||||
receivedDate?: string;
|
||||
details?: { importance: 'NORMAL' | 'HIGH' | 'URGENT' };
|
||||
}>;
|
||||
originatorId?: number;
|
||||
recipients?: Array<{
|
||||
recipientType: string;
|
||||
recipientOrganizationId: number;
|
||||
}>;
|
||||
correspondenceNumber?: string;
|
||||
}
|
||||
|
||||
const extractArrayData = <T,>(value: unknown): T[] => {
|
||||
let current: unknown = value;
|
||||
@@ -73,7 +93,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
|
||||
return current as T[];
|
||||
}
|
||||
|
||||
if (!current || typeof current !== "object" || !("data" in current)) {
|
||||
if (!current || typeof current !== 'object' || !('data' in current)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -83,7 +103,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
|
||||
return Array.isArray(current) ? (current as T[]) : [];
|
||||
};
|
||||
|
||||
export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, uuid?: string }) {
|
||||
export function CorrespondenceForm({ initialData, uuid }: { initialData?: InitialCorrespondenceData; uuid?: string }) {
|
||||
const router = useRouter();
|
||||
const createMutation = useCreateCorrespondence();
|
||||
const updateMutation = useUpdateCorrespondence();
|
||||
@@ -99,26 +119,26 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
const disciplines = extractArrayData<DisciplineOption>(disciplinesData);
|
||||
|
||||
// Extract initial values if editing
|
||||
const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0];
|
||||
const currentRev = initialData?.revisions?.find((r) => r.isCurrent) || initialData?.revisions?.[0];
|
||||
const defaultValues: Partial<FormData> = {
|
||||
projectId: initialData?.project?.uuid || (initialData?.projectId ? String(initialData.projectId) : undefined),
|
||||
documentTypeId: initialData?.correspondenceTypeId || undefined,
|
||||
disciplineId: initialData?.disciplineId || undefined,
|
||||
subject: currentRev?.subject || currentRev?.title || "",
|
||||
description: currentRev?.description || "",
|
||||
body: currentRev?.body || "",
|
||||
remarks: currentRev?.remarks || "",
|
||||
subject: currentRev?.subject || currentRev?.title || '',
|
||||
description: currentRev?.description || '',
|
||||
body: currentRev?.body || '',
|
||||
remarks: currentRev?.remarks || '',
|
||||
dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined,
|
||||
documentDate: currentRev?.documentDate ? new Date(currentRev.documentDate).toISOString().split('T')[0] : undefined,
|
||||
issuedDate: currentRev?.issuedDate ? new Date(currentRev.issuedDate).toISOString().split('T')[0] : undefined,
|
||||
receivedDate: currentRev?.receivedDate ? new Date(currentRev.receivedDate).toISOString().split('T')[0] : undefined,
|
||||
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined,
|
||||
// Map initial recipient (TO) - Simplified for now
|
||||
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId
|
||||
? String(initialData.recipients.find((r: any) => r.recipientType === 'TO').recipientOrganizationId)
|
||||
toOrganizationId: initialData?.recipients?.find((r) => r.recipientType === 'TO')?.recipientOrganizationId
|
||||
? String(initialData.recipients.find((r) => r.recipientType === 'TO')?.recipientOrganizationId)
|
||||
: undefined,
|
||||
importance: currentRev?.details?.importance || "NORMAL",
|
||||
};
|
||||
importance: currentRev?.details?.importance || 'NORMAL',
|
||||
} as Partial<FormData>;
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -127,17 +147,17 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
// @ts-ignore: Zod version mismatch in monorepo
|
||||
resolver: zodResolver(correspondenceSchema) as any,
|
||||
// @ts-ignore: Zod version mismatch
|
||||
resolver: zodResolver(correspondenceSchema) as unknown as Resolver<FormData>,
|
||||
defaultValues: defaultValues as FormData,
|
||||
});
|
||||
|
||||
// Watch for controlled inputs
|
||||
const projectId = watch("projectId");
|
||||
const documentTypeId = watch("documentTypeId");
|
||||
const disciplineId = watch("disciplineId");
|
||||
const fromOrgId = watch("fromOrganizationId");
|
||||
const toOrgId = watch("toOrganizationId");
|
||||
const projectId = watch('projectId');
|
||||
const documentTypeId = watch('documentTypeId');
|
||||
const disciplineId = watch('disciplineId');
|
||||
const fromOrgId = watch('fromOrganizationId');
|
||||
const toOrgId = watch('toOrganizationId');
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
const payload: CreateCorrespondenceDto = {
|
||||
@@ -153,24 +173,25 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
issuedDate: data.issuedDate ? new Date(data.issuedDate).toISOString() : undefined,
|
||||
receivedDate: data.receivedDate ? new Date(data.receivedDate).toISOString() : undefined,
|
||||
originatorId: data.fromOrganizationId,
|
||||
recipients: [
|
||||
{ organizationId: data.toOrganizationId, type: 'TO' }
|
||||
],
|
||||
recipients: [{ organizationId: data.toOrganizationId, type: 'TO' }],
|
||||
details: {
|
||||
importance: data.importance
|
||||
importance: data.importance,
|
||||
},
|
||||
};
|
||||
|
||||
if (uuid && initialData) {
|
||||
// UPDATE Mode
|
||||
updateMutation.mutate({ uuid, data: payload }, {
|
||||
onSuccess: () => router.push(`/correspondences/${uuid}`)
|
||||
});
|
||||
// UPDATE Mode
|
||||
updateMutation.mutate(
|
||||
{ uuid, data: payload },
|
||||
{
|
||||
onSuccess: () => router.push(`/correspondences/${uuid}`),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// CREATE Mode
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => router.push("/correspondences"),
|
||||
});
|
||||
// CREATE Mode
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => router.push('/correspondences'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,31 +202,29 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !documentTypeId || !fromOrgId || !toOrgId) {
|
||||
setPreview(null);
|
||||
return;
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const res = await numberingApi.previewNumber({
|
||||
projectId,
|
||||
correspondenceTypeId: documentTypeId,
|
||||
disciplineId,
|
||||
originatorOrganizationId: fromOrgId,
|
||||
recipientOrganizationId: toOrgId
|
||||
});
|
||||
setPreview({ number: res.previewNumber, isDefaultTemplate: res.isDefault });
|
||||
} catch (err) {
|
||||
setPreview(null);
|
||||
}
|
||||
try {
|
||||
const res = await numberingApi.previewNumber({
|
||||
projectId,
|
||||
correspondenceTypeId: documentTypeId,
|
||||
disciplineId,
|
||||
originatorOrganizationId: fromOrgId,
|
||||
recipientOrganizationId: toOrgId,
|
||||
});
|
||||
setPreview({ number: res.previewNumber, isDefaultTemplate: res.isDefault });
|
||||
} catch (_err) {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(fetchPreview, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [projectId, documentTypeId, disciplineId, fromOrgId, toOrgId]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
||||
{/* Existing Document Number (Read Only) */}
|
||||
@@ -213,42 +232,49 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
<div className="space-y-2">
|
||||
<Label>Current Document Number</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={initialData.correspondenceNumber} disabled readOnly className="bg-muted font-mono font-bold text-lg w-full" />
|
||||
{preview && preview.number !== initialData.correspondenceNumber && (
|
||||
<span className="text-xs text-amber-600 font-semibold whitespace-nowrap px-2">
|
||||
Start Change Detected
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
value={initialData.correspondenceNumber}
|
||||
disabled
|
||||
readOnly
|
||||
className="bg-muted font-mono font-bold text-lg w-full"
|
||||
/>
|
||||
{preview && preview.number !== initialData.correspondenceNumber && (
|
||||
<span className="text-xs text-amber-600 font-semibold whitespace-nowrap px-2">Start Change Detected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Section */}
|
||||
{preview && (
|
||||
<div className={`p-4 rounded-md border ${preview.number !== initialData?.correspondenceNumber ? 'bg-amber-50 border-amber-200' : 'bg-muted border-border'}`}>
|
||||
<p className="text-sm font-semibold mb-1 flex items-center gap-2">
|
||||
{initialData?.correspondenceNumber ? "New Document Number (Preview)" : "Document Number Preview"}
|
||||
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
|
||||
<span className="text-[10px] bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full border border-amber-200">
|
||||
Will Update
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xl font-bold font-mono tracking-wide ${preview.number !== initialData?.correspondenceNumber ? 'text-amber-700' : 'text-primary'}`}>
|
||||
{preview.number}
|
||||
</span>
|
||||
{preview.isDefaultTemplate && (
|
||||
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
Default Template
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* The document number will be regenerated because critical fields were changed.
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className={`p-4 rounded-md border ${preview.number !== initialData?.correspondenceNumber ? 'bg-amber-50 border-amber-200' : 'bg-muted border-border'}`}
|
||||
>
|
||||
<p className="text-sm font-semibold mb-1 flex items-center gap-2">
|
||||
{initialData?.correspondenceNumber ? 'New Document Number (Preview)' : 'Document Number Preview'}
|
||||
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
|
||||
<span className="text-[10px] bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full border border-amber-200">
|
||||
Will Update
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-xl font-bold font-mono tracking-wide ${preview.number !== initialData?.correspondenceNumber ? 'text-amber-700' : 'text-primary'}`}
|
||||
>
|
||||
{preview.number}
|
||||
</span>
|
||||
{preview.isDefaultTemplate && (
|
||||
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
Default Template
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* The document number will be regenerated because critical fields were changed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -258,12 +284,12 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
<div className="space-y-2">
|
||||
<Label>Project *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("projectId", v)}
|
||||
onValueChange={(v) => setValue('projectId', v)}
|
||||
value={projectId || undefined}
|
||||
disabled={isLoadingProjects}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
|
||||
<SelectValue placeholder={isLoadingProjects ? 'Loading...' : 'Select Project'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((p) => (
|
||||
@@ -273,21 +299,19 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.projectId && (
|
||||
<p className="text-sm text-destructive">{errors.projectId.message}</p>
|
||||
)}
|
||||
{errors.projectId && <p className="text-sm text-destructive">{errors.projectId.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Document Type Dropdown */}
|
||||
<div className="space-y-2">
|
||||
<Label>Document Type *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("documentTypeId", parseInt(v))}
|
||||
onValueChange={(v) => setValue('documentTypeId', Number(v))}
|
||||
value={documentTypeId ? String(documentTypeId) : undefined}
|
||||
disabled={isLoadingTypes}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} />
|
||||
<SelectValue placeholder={isLoadingTypes ? 'Loading...' : 'Select Type'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{correspondenceTypes.map((t) => (
|
||||
@@ -297,21 +321,19 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.documentTypeId && (
|
||||
<p className="text-sm text-destructive">{errors.documentTypeId.message}</p>
|
||||
)}
|
||||
{errors.documentTypeId && <p className="text-sm text-destructive">{errors.documentTypeId.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Discipline Dropdown (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label>Discipline</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("disciplineId", v ? parseInt(v) : undefined)}
|
||||
onValueChange={(v) => setValue('disciplineId', v ? Number(v) : undefined)}
|
||||
value={disciplineId ? String(disciplineId) : undefined}
|
||||
disabled={isLoadingDisciplines}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} />
|
||||
<SelectValue placeholder={isLoadingDisciplines ? 'Loading...' : 'Select Discipline (Optional)'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{disciplines.map((d) => (
|
||||
@@ -327,88 +349,76 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
|
||||
{errors.subject && (
|
||||
<p className="text-sm text-destructive">{errors.subject.message}</p>
|
||||
)}
|
||||
<Input id="subject" {...register('subject')} placeholder="Enter subject" />
|
||||
{errors.subject && <p className="text-sm text-destructive">{errors.subject.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-2">
|
||||
{/* Body */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="body">Body (Content)</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
{...register("body")}
|
||||
rows={6}
|
||||
placeholder="Enter letter content..."
|
||||
/>
|
||||
<Textarea id="body" {...register('body')} rows={6} placeholder="Enter letter content..." />
|
||||
</div>
|
||||
|
||||
{/* Date Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentDate">Document Date</Label>
|
||||
<Input
|
||||
id="documentDate"
|
||||
type="date"
|
||||
{...register("documentDate")}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setValue("documentDate", val, { shouldValidate: true, shouldDirty: true });
|
||||
if (val) {
|
||||
setValue("issuedDate", val, { shouldValidate: true, shouldDirty: true });
|
||||
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
|
||||
const d = new Date(val);
|
||||
d.setDate(d.getDate() + 7);
|
||||
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuedDate">Issued Date</Label>
|
||||
<Input id="issuedDate" type="date" {...register("issuedDate")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="receivedDate">Received Date</Label>
|
||||
<Input
|
||||
id="receivedDate"
|
||||
type="date"
|
||||
{...register("receivedDate")}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
|
||||
if (val) {
|
||||
const d = new Date(val);
|
||||
d.setDate(d.getDate() + 7);
|
||||
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" {...register("dueDate")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentDate">Document Date</Label>
|
||||
<Input
|
||||
id="documentDate"
|
||||
type="date"
|
||||
{...register('documentDate')}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setValue('documentDate', val, { shouldValidate: true, shouldDirty: true });
|
||||
if (val) {
|
||||
setValue('issuedDate', val, { shouldValidate: true, shouldDirty: true });
|
||||
setValue('receivedDate', val, { shouldValidate: true, shouldDirty: true });
|
||||
const d = new Date(val);
|
||||
d.setDate(d.getDate() + 7);
|
||||
setValue('dueDate', d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuedDate">Issued Date</Label>
|
||||
<Input id="issuedDate" type="date" {...register('issuedDate')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="receivedDate">Received Date</Label>
|
||||
<Input
|
||||
id="receivedDate"
|
||||
type="date"
|
||||
{...register('receivedDate')}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setValue('receivedDate', val, { shouldValidate: true, shouldDirty: true });
|
||||
if (val) {
|
||||
const d = new Date(val);
|
||||
d.setDate(d.getDate() + 7);
|
||||
setValue('dueDate', d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" {...register('dueDate')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remarks">Remarks</Label>
|
||||
<Input id="remarks" {...register("remarks")} placeholder="Optional remarks" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remarks">Remarks</Label>
|
||||
<Input id="remarks" {...register('remarks')} placeholder="Optional remarks" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (Internal Note)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register("description")}
|
||||
rows={2}
|
||||
placeholder="Enter description..."
|
||||
/>
|
||||
<Textarea id="description" {...register('description')} rows={2} placeholder="Enter description..." />
|
||||
</div>
|
||||
|
||||
{/* Organizations */}
|
||||
@@ -416,12 +426,12 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
<div className="space-y-2">
|
||||
<Label>From Organization *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("fromOrganizationId", v)}
|
||||
onValueChange={(v) => setValue('fromOrganizationId', v)}
|
||||
value={fromOrgId || undefined}
|
||||
disabled={isLoadingOrgs}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
|
||||
<SelectValue placeholder={isLoadingOrgs ? 'Loading...' : 'Select Organization'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizationOptions.map((org) => (
|
||||
@@ -431,20 +441,18 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.fromOrganizationId && (
|
||||
<p className="text-sm text-destructive">{errors.fromOrganizationId.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("toOrganizationId", v)}
|
||||
onValueChange={(v) => setValue('toOrganizationId', v)}
|
||||
value={toOrgId || undefined}
|
||||
disabled={isLoadingOrgs}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
|
||||
<SelectValue placeholder={isLoadingOrgs ? 'Loading...' : 'Select Organization'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizationOptions.map((org) => (
|
||||
@@ -454,9 +462,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.toOrganizationId && (
|
||||
<p className="text-sm text-destructive">{errors.toOrganizationId.message}</p>
|
||||
)}
|
||||
{errors.toOrganizationId && <p className="text-sm text-destructive">{errors.toOrganizationId.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -465,30 +471,15 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
<Label>Importance</Label>
|
||||
<div className="flex gap-6 mt-2">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="NORMAL"
|
||||
{...register("importance")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<input type="radio" value="NORMAL" {...register('importance')} className="accent-primary" />
|
||||
<span>Normal</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="HIGH"
|
||||
{...register("importance")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<input type="radio" value="HIGH" {...register('importance')} className="accent-primary" />
|
||||
<span>High</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="URGENT"
|
||||
{...register("importance")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<input type="radio" value="URGENT" {...register('importance')} className="accent-primary" />
|
||||
<span>Urgent</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -499,9 +490,9 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
<div className="space-y-2">
|
||||
<Label>Attachments</Label>
|
||||
<FileUploadZone
|
||||
onFilesChanged={(files) => setValue("attachments", files)}
|
||||
onFilesChanged={(files) => setValue('attachments', files)}
|
||||
multiple
|
||||
accept={[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".jpg", ".png"]}
|
||||
accept={['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.jpg', '.png']}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -513,7 +504,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{uuid ? "Update Correspondence" : "Create Correspondence"}
|
||||
{uuid ? 'Update Correspondence' : 'Create Correspondence'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { CorrespondenceRevision } from "@/types/correspondence";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Edit, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { CorrespondenceRevision } from '@/types/correspondence';
|
||||
import { DataTable } from '@/components/common/data-table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { StatusBadge } from '@/components/common/status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Eye, Edit, FileText } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface CorrespondenceListProps {
|
||||
data: CorrespondenceRevision[];
|
||||
@@ -16,22 +16,20 @@ interface CorrespondenceListProps {
|
||||
export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
const columns: ColumnDef<CorrespondenceRevision>[] = [
|
||||
{
|
||||
accessorKey: "correspondence.correspondenceNumber",
|
||||
header: "Document No.",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.correspondence?.correspondenceNumber}</span>
|
||||
),
|
||||
accessorKey: 'correspondence.correspondenceNumber',
|
||||
header: 'Document No.',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.correspondence?.correspondenceNumber}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "revisionLabel",
|
||||
header: "Rev",
|
||||
accessorKey: 'revisionLabel',
|
||||
header: 'Rev',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.revisionLabel || row.original.revisionNumber}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
accessorKey: 'subject',
|
||||
header: 'Subject',
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[300px] truncate" title={row.original.subject}>
|
||||
{row.original.subject}
|
||||
@@ -39,24 +37,24 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "correspondence.originator.organizationCode",
|
||||
header: "From",
|
||||
accessorKey: 'correspondence.originator.organizationCode',
|
||||
header: 'From',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.correspondence?.originator?.organizationCode || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => format(new Date(row.getValue('createdAt')), 'dd MMM yyyy'),
|
||||
},
|
||||
{
|
||||
accessorKey: "status.statusName",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status?.statusCode || "UNKNOWN"} />,
|
||||
accessorKey: 'status.statusName',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status?.statusCode || 'UNKNOWN'} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
// Edit/View link goes to the DOCUMENT detail (correspondence.uuid)
|
||||
@@ -72,23 +70,28 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" title="View File" onClick={() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="View File"
|
||||
onClick={() => {
|
||||
const attachments = item.attachments; // Now we are on Revision, so attachments might be here if joined
|
||||
if (attachments && attachments.length > 0 && attachments[0].url) {
|
||||
window.open(attachments[0].url, '_blank');
|
||||
window.open(attachments[0].url, '_blank');
|
||||
} else {
|
||||
// Fallback check if attachments are on details json inside revision
|
||||
// or if we simply didn't join them yet.
|
||||
// Current Backend join: leftJoinAndSelect('rev.status', 'status') doesn't join attachments explicitly but maybe relation exists?
|
||||
// Wait, checking Entity... CorrespondenceRevision does NOT have attachments relation in code snippet provided earlier.
|
||||
// It might be in 'details' JSON or implied.
|
||||
// Just Alert for now as per previous logic.
|
||||
alert("ไม่พบไฟล์แนบ (No file attached)");
|
||||
// Fallback check if attachments are on details json inside revision
|
||||
// or if we simply didn't join them yet.
|
||||
// Current Backend join: leftJoinAndSelect('rev.status', 'status') doesn't join attachments explicitly but maybe relation exists?
|
||||
// Wait, checking Entity... CorrespondenceRevision does NOT have attachments relation in code snippet provided earlier.
|
||||
// It might be in 'details' JSON or implied.
|
||||
// Just Alert for now as per previous logic.
|
||||
alert('ไม่พบไฟล์แนบ (No file attached)');
|
||||
}
|
||||
}}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
{statusCode === "DRAFT" && (
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
{statusCode === 'DRAFT' && (
|
||||
<Link href={`/correspondences/${docUuid}/edit`}>
|
||||
<Button variant="ghost" size="icon" title="Edit">
|
||||
<Edit className="h-4 w-4" />
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// File: components/custom/file-upload-zone.tsx
|
||||
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useState } from "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";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import React, { useCallback, useState } from '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';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export interface FileWithMeta extends File {
|
||||
preview?: string;
|
||||
@@ -32,10 +32,10 @@ interface FileUploadZoneProps {
|
||||
* Helper: แปลง Bytes เป็นหน่วยที่อ่านง่าย
|
||||
*/
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (!+bytes) return "0 Bytes";
|
||||
if (!Number(bytes)) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
@@ -46,7 +46,7 @@ const formatBytes = (bytes: number, decimals = 2) => {
|
||||
*/
|
||||
export function FileUploadZone({
|
||||
onFilesChanged,
|
||||
accept = [".pdf", ".dwg", ".docx", ".xlsx", ".zip"],
|
||||
accept = ['.pdf', '.dwg', '.docx', '.xlsx', '.zip'],
|
||||
maxSize = 50 * 1024 * 1024, // 50MB Default
|
||||
multiple = true,
|
||||
initialFiles = [],
|
||||
@@ -56,18 +56,18 @@ export function FileUploadZone({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// ตรวจสอบไฟล์
|
||||
const validateFile = (file: File): string | undefined => {
|
||||
const validateFile = useCallback((file: File): string | undefined => {
|
||||
// 1. Check Size
|
||||
if (file.size > maxSize) {
|
||||
return `ขนาดไฟล์เกินกำหนด (${formatBytes(maxSize)})`;
|
||||
}
|
||||
// 2. Check Type (Extension based validation for simplicity on client)
|
||||
const fileExtension = "." + file.name.split(".").pop()?.toLowerCase();
|
||||
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (accept.length > 0 && !accept.includes(fileExtension)) {
|
||||
return `ประเภทไฟล์ไม่รองรับ (อนุญาต: ${accept.join(", ")})`;
|
||||
return `ประเภทไฟล์ไม่รองรับ (อนุญาต: ${accept.join(', ')})`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}, [maxSize, accept]);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
@@ -85,7 +85,7 @@ export function FileUploadZone({
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[maxSize, accept, multiple, onFilesChanged]
|
||||
[multiple, onFilesChanged, validateFile]
|
||||
);
|
||||
|
||||
// Drag Events
|
||||
@@ -114,27 +114,25 @@ export function FileUploadZone({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-4", className)}>
|
||||
<div className={cn('w-full space-y-4', className)}>
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer flex flex-col items-center justify-center gap-2",
|
||||
isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/25 hover:border-primary/50",
|
||||
"h-48"
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer flex flex-col items-center justify-center gap-2',
|
||||
isDragging ? 'border-primary bg-primary/10' : 'border-muted-foreground/25 hover:border-primary/50',
|
||||
'h-48'
|
||||
)}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={() => document.getElementById("file-input")?.click()}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple={multiple}
|
||||
accept={accept.join(",")}
|
||||
accept={accept.join(',')}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) handleFileSelect(Array.from(e.target.files));
|
||||
}}
|
||||
@@ -143,11 +141,9 @@ export function FileUploadZone({
|
||||
<UploadCloud className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
คลิกเพื่อเลือกไฟล์ หรือ ลากไฟล์มาวางที่นี่
|
||||
</p>
|
||||
<p className="text-sm font-medium">คลิกเพื่อเลือกไฟล์ หรือ ลากไฟล์มาวางที่นี่</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
รองรับ: {accept.join(", ")} (สูงสุด {formatBytes(maxSize)})
|
||||
รองรับ: {accept.join(', ')} (สูงสุด {formatBytes(maxSize)})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,22 +162,21 @@ export function FileUploadZone({
|
||||
<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">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate max-w-[200px] sm:max-w-md">{file.name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
{file.validationError ? (
|
||||
<Badge variant="destructive" className="text-[10px] px-1 h-5 flex gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> {file.validationError}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] px-1 h-5 text-green-600 bg-green-50 border-green-200 flex gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> Ready
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{formatBytes(file.size)}</span>
|
||||
{file.validationError ? (
|
||||
<Badge variant="destructive" className="text-[10px] px-1 h-5 flex gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> {file.validationError}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 h-5 text-green-600 bg-green-50 border-green-200 flex gap-1"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" /> Ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// File: components/custom/workflow-visualizer.tsx
|
||||
|
||||
import React from "react";
|
||||
import { Check, Clock, XCircle, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from 'react';
|
||||
import { Check, Clock, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* สถานะของขั้นตอนใน Workflow
|
||||
*/
|
||||
export type StepStatus = "completed" | "current" | "pending" | "rejected" | "skipped";
|
||||
export type StepStatus = 'completed' | 'current' | 'pending' | 'rejected' | 'skipped';
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: string | number;
|
||||
@@ -28,38 +28,38 @@ interface WorkflowVisualizerProps {
|
||||
*/
|
||||
export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps) {
|
||||
return (
|
||||
<div className={cn("w-full overflow-x-auto py-4 px-2", className)}>
|
||||
<div className={cn('w-full overflow-x-auto py-4 px-2', className)}>
|
||||
<div className="flex items-start min-w-max">
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
|
||||
// กำหนดสีตามสถานะ
|
||||
let statusColor = "bg-muted text-muted-foreground border-muted"; // pending
|
||||
let statusColor = 'bg-muted text-muted-foreground border-muted'; // pending
|
||||
let icon = <span className="text-xs">{index + 1}</span>;
|
||||
let lineColor = "bg-muted";
|
||||
let lineColor = 'bg-muted';
|
||||
|
||||
switch (step.status) {
|
||||
case "completed":
|
||||
statusColor = "bg-green-600 text-white border-green-600";
|
||||
case 'completed':
|
||||
statusColor = 'bg-green-600 text-white border-green-600';
|
||||
icon = <Check className="w-4 h-4" />;
|
||||
lineColor = "bg-green-600";
|
||||
lineColor = 'bg-green-600';
|
||||
break;
|
||||
case "current":
|
||||
statusColor = "bg-blue-600 text-white border-blue-600 ring-4 ring-blue-100";
|
||||
case 'current':
|
||||
statusColor = 'bg-blue-600 text-white border-blue-600 ring-4 ring-blue-100';
|
||||
icon = <Clock className="w-4 h-4 animate-pulse" />;
|
||||
lineColor = "bg-muted"; // เส้นต่อไปยังเป็นสีเทา
|
||||
lineColor = 'bg-muted'; // เส้นต่อไปยังเป็นสีเทา
|
||||
break;
|
||||
case "rejected":
|
||||
statusColor = "bg-destructive text-destructive-foreground border-destructive";
|
||||
case 'rejected':
|
||||
statusColor = 'bg-destructive text-destructive-foreground border-destructive';
|
||||
icon = <XCircle className="w-4 h-4" />;
|
||||
lineColor = "bg-destructive";
|
||||
lineColor = 'bg-destructive';
|
||||
break;
|
||||
case "skipped":
|
||||
statusColor = "bg-orange-400 text-white border-orange-400";
|
||||
icon = <AlertCircle className="w-4 h-4" />;
|
||||
lineColor = "bg-orange-400";
|
||||
break;
|
||||
case "pending":
|
||||
case 'skipped':
|
||||
statusColor = 'bg-orange-400 text-white border-orange-400';
|
||||
icon = <AlertCircle className="w-4 h-4" />;
|
||||
lineColor = 'bg-orange-400';
|
||||
break;
|
||||
case 'pending':
|
||||
default:
|
||||
// ใช้ default
|
||||
break;
|
||||
@@ -69,17 +69,37 @@ export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps
|
||||
<div key={step.id} className="relative flex flex-col items-center flex-1 group">
|
||||
{/* Connector Line (Left & Right) */}
|
||||
<div className="flex items-center w-full absolute top-4 left-0 -z-10">
|
||||
{/* Left Half Line (Previous step connection) */}
|
||||
<div className={cn("h-1 w-1/2", index === 0 ? "bg-transparent" : (steps[index-1].status === 'completed' || steps[index-1].status === 'skipped' ? lineColor : (steps[index].status === 'completed' ? lineColor : 'bg-muted')))} />
|
||||
|
||||
{/* Right Half Line (Next step connection) */}
|
||||
<div className={cn("h-1 w-1/2", isLast ? "bg-transparent" : (step.status === 'completed' || step.status === 'skipped' ? lineColor : 'bg-muted'))} />
|
||||
{/* Left Half Line (Previous step connection) */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 w-1/2',
|
||||
index === 0
|
||||
? 'bg-transparent'
|
||||
: steps[index - 1].status === 'completed' || steps[index - 1].status === 'skipped'
|
||||
? lineColor
|
||||
: steps[index].status === 'completed'
|
||||
? lineColor
|
||||
: 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Right Half Line (Next step connection) */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 w-1/2',
|
||||
isLast
|
||||
? 'bg-transparent'
|
||||
: step.status === 'completed' || step.status === 'skipped'
|
||||
? lineColor
|
||||
: 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Circle */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300",
|
||||
'w-8 h-8 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300',
|
||||
statusColor
|
||||
)}
|
||||
>
|
||||
@@ -88,8 +108,13 @@ export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps
|
||||
|
||||
{/* Step Label */}
|
||||
<div className="mt-3 text-center space-y-1 max-w-[120px]">
|
||||
<p className={cn("text-sm font-semibold", step.status === 'current' ? 'text-blue-700' : 'text-foreground')}>
|
||||
{step.label}
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
step.status === 'current' ? 'text-blue-700' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</p>
|
||||
{step.subLabel && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={step.subLabel}>
|
||||
@@ -108,4 +133,4 @@ export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { PendingTask } from "@/types/dashboard";
|
||||
import { AlertCircle, ArrowRight } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Link from 'next/link';
|
||||
import { PendingTask } from '@/types/dashboard';
|
||||
import { _AlertCircle, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface PendingTasksProps {
|
||||
tasks: PendingTask[] | undefined;
|
||||
@@ -13,18 +13,20 @@ interface PendingTasksProps {
|
||||
|
||||
export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader><CardTitle className="text-lg">Pending Tasks</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pending Tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tasks) tasks = [];
|
||||
@@ -35,7 +37,10 @@ export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
Pending Tasks
|
||||
{tasks.length > 0 && (
|
||||
<Badge variant="destructive" className="rounded-full h-5 w-5 p-0 flex items-center justify-center text-[10px]">
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="rounded-full h-5 w-5 p-0 flex items-center justify-center text-[10px]"
|
||||
>
|
||||
{tasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -44,9 +49,7 @@ export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No pending tasks. Good job!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No pending tasks. Good job!</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<Link
|
||||
@@ -55,22 +58,21 @@ export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
|
||||
className="block p-3 bg-muted/40 rounded-lg border hover:bg-muted/60 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<span className="text-sm font-medium group-hover:text-primary transition-colors">
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="text-sm font-medium group-hover:text-primary transition-colors">{task.title}</span>
|
||||
{task.daysOverdue > 0 ? (
|
||||
<Badge variant="destructive" className="text-[10px] h-5 px-1.5">
|
||||
{task.daysOverdue}d overdue
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1.5 bg-yellow-50 text-yellow-700 border-yellow-200">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-1.5 bg-yellow-50 text-yellow-700 border-yellow-200"
|
||||
>
|
||||
Due Soon
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">
|
||||
{task.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">{task.description}</p>
|
||||
<div className="flex items-center text-xs text-primary font-medium">
|
||||
View Details <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusCircle, Upload, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlusCircle, Upload, FileText } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ActivityLog } from "@/types/dashboard";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ActivityLog } from '@/types/dashboard';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: ActivityLog[] | undefined;
|
||||
@@ -14,29 +14,31 @@ interface RecentActivityProps {
|
||||
|
||||
export function RecentActivity({ activities, isLoading }: RecentActivityProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activities || activities.length === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm text-center py-8">
|
||||
No recent activity.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm text-center py-8">No recent activity.</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card className="h-full">
|
||||
@@ -46,10 +48,7 @@ export function RecentActivity({ activities, isLoading }: RecentActivityProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{activities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex gap-4 pb-4 border-b last:border-0 last:pb-0"
|
||||
>
|
||||
<div key={activity.id} className="flex gap-4 pb-4 border-b last:border-0 last:pb-0">
|
||||
<Avatar className="h-10 w-10 border">
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{activity.user.initials}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { FileText, Clipboard, CheckCircle, Clock } from "lucide-react";
|
||||
import { DashboardStats } from "@/types/dashboard";
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { FileText, Clipboard, CheckCircle, Clock } from 'lucide-react';
|
||||
import { DashboardStats } from '@/types/dashboard';
|
||||
|
||||
export interface StatsCardsProps {
|
||||
stats: DashboardStats | undefined;
|
||||
@@ -21,32 +21,32 @@ export function StatsCards({ stats, isLoading }: StatsCardsProps) {
|
||||
}
|
||||
const cards = [
|
||||
{
|
||||
title: "Total Correspondences",
|
||||
title: 'Total Correspondences',
|
||||
value: stats.totalDocuments,
|
||||
icon: FileText,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
title: "Active RFAs",
|
||||
title: 'Active RFAs',
|
||||
value: stats.totalRfas,
|
||||
icon: Clipboard,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-50",
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
},
|
||||
{
|
||||
title: "Approved Documents",
|
||||
title: 'Approved Documents',
|
||||
value: stats.approved,
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50",
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
{
|
||||
title: "Pending Approvals",
|
||||
title: 'Pending Approvals',
|
||||
value: stats.pendingApprovals,
|
||||
icon: Clock,
|
||||
color: "text-orange-600",
|
||||
bgColor: "bg-orange-50",
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useReactTable,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
getPaginationRowModel,
|
||||
_getPaginationRowModel,
|
||||
OnChangeFn,
|
||||
} from '@tanstack/react-table';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Drawing } from "@/types/drawing";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileText, Download, Eye, GitCompare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { Drawing } from '@/types/drawing';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileText, Download, Eye, GitCompare } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
||||
return (
|
||||
@@ -21,39 +21,41 @@ 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.drawingNumber || "No Number"}>
|
||||
{drawing.drawingNumber || "No Number"}
|
||||
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber || 'No Number'}>
|
||||
{drawing.drawingNumber || 'No Number'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground truncate" title={drawing.title || "No Title"}>
|
||||
{drawing.title || "No Title"}
|
||||
<p className="text-sm text-muted-foreground truncate" title={drawing.title || 'No Title'}>
|
||||
{drawing.title || 'No Title'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{typeof drawing.discipline === 'object' ? drawing.discipline?.disciplineCode : drawing.discipline}</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.sheetNumber || "-"}
|
||||
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Rev:</span> {drawing.revision || "0"}
|
||||
<span className="font-medium text-foreground">Rev:</span> {drawing.revision || '0'}
|
||||
</div>
|
||||
{drawing.legacyDrawingNumber && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium text-foreground">Legacy:</span> {drawing.legacyDrawingNumber}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium text-foreground">Legacy:</span> {drawing.legacyDrawingNumber}
|
||||
</div>
|
||||
)}
|
||||
{drawing.volumePage !== undefined && (
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Page:</span> {drawing.volumePage}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Page:</span> {drawing.volumePage}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
|
||||
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Date:</span>{" "}
|
||||
{drawing.issueDate && format(new Date(drawing.issueDate), "dd/MM/yyyy")}
|
||||
<span className="font-medium text-foreground">Date:</span>{' '}
|
||||
{drawing.issueDate && format(new Date(drawing.issueDate), 'dd/MM/yyyy')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -68,9 +68,7 @@ export const columns: ColumnDef<Drawing>[] = [
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/drawings/${drawing.uuid}`}>
|
||||
View Details
|
||||
</Link>
|
||||
<Link href={`/drawings/${drawing.uuid}`}>View Details</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/drawings/${drawing.uuid}?edit=true`}>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { DrawingRevision } from "@/types/drawing";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { DrawingRevision } from '@/types/drawing';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, _FileText } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] }) {
|
||||
return (
|
||||
@@ -14,15 +14,10 @@ export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] })
|
||||
|
||||
<div className="space-y-3">
|
||||
{revisions.map((rev) => (
|
||||
<div
|
||||
key={rev.revisionId}
|
||||
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border"
|
||||
>
|
||||
<div 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.isCurrent ? "default" : "outline"}>
|
||||
Rev. {rev.revisionNumber}
|
||||
</Badge>
|
||||
<Badge variant={rev.isCurrent ? 'default' : 'outline'}>Rev. {rev.revisionNumber}</Badge>
|
||||
{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" />
|
||||
@@ -30,12 +25,9 @@ export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] })
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
{rev.revisionDescription || "No description"}
|
||||
</p>
|
||||
<p className="text-sm text-foreground font-medium">{rev.revisionDescription || 'No description'}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{format(new Date(rev.revisionDate), "dd MMM yyyy")} by{" "}
|
||||
{rev.revisedByName}
|
||||
{format(new Date(rev.revisionDate), 'dd MMM yyyy')} by {rev.revisedByName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,74 +1,72 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useForm, FieldError } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useForm, FieldError } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCreateDrawing } from '@/hooks/use-drawing';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCreateDrawing } from "@/hooks/use-drawing";
|
||||
import { useContractDrawingCategories, useShopMainCategories, useShopSubCategories, useProjects } from "@/hooks/use-master-data";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
useContractDrawingCategories,
|
||||
useShopMainCategories,
|
||||
useShopSubCategories,
|
||||
useProjects,
|
||||
} from '@/hooks/use-master-data';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
// Base Schema
|
||||
const baseSchema = z.object({
|
||||
drawingType: z.enum(["CONTRACT", "SHOP", "AS_BUILT"]),
|
||||
projectId: z.string().min(1, "Project is required"),
|
||||
file: z.instanceof(File, { message: "File is required" }),
|
||||
drawingType: z.enum(['CONTRACT', 'SHOP', 'AS_BUILT']),
|
||||
projectId: z.string().min(1, 'Project is required'),
|
||||
file: z.instanceof(File, { message: 'File is required' }),
|
||||
});
|
||||
|
||||
// Contract Schema
|
||||
const contractSchema = baseSchema.extend({
|
||||
drawingType: z.literal("CONTRACT"),
|
||||
contractDrawingNo: z.string().min(1, "Drawing Number is required"),
|
||||
title: z.string().min(3, "Title is required"),
|
||||
drawingType: z.literal('CONTRACT'),
|
||||
contractDrawingNo: z.string().min(1, 'Drawing Number is required'),
|
||||
title: z.string().min(3, 'Title is required'),
|
||||
volumeId: z.string().optional(), // Select input returns string usually (changed to string for input compatibility)
|
||||
volumePage: z.string().transform(val => parseInt(val, 10)).optional(), // Input type number returns string
|
||||
mapCatId: z.string().min(1, "Category is required"),
|
||||
volumePage: z
|
||||
.string()
|
||||
.transform((val) => Number(val))
|
||||
.optional(), // Input type number returns string
|
||||
mapCatId: z.string().min(1, 'Category is required'),
|
||||
});
|
||||
|
||||
// Shop Schema
|
||||
const shopSchema = baseSchema.extend({
|
||||
drawingType: z.literal("SHOP"),
|
||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||
mainCategoryId: z.string().min(1, "Main Category is required"),
|
||||
subCategoryId: z.string().min(1, "Sub Category is required"),
|
||||
drawingType: z.literal('SHOP'),
|
||||
drawingNumber: z.string().min(1, 'Drawing Number is required'),
|
||||
mainCategoryId: z.string().min(1, 'Main Category is required'),
|
||||
subCategoryId: z.string().min(1, 'Sub Category is required'),
|
||||
// Revision Fields
|
||||
revisionLabel: z.string().default("0"),
|
||||
title: z.string().min(3, "Revision Title is required"),
|
||||
revisionLabel: z.string().default('0'),
|
||||
title: z.string().min(3, 'Revision Title is required'),
|
||||
legacyDrawingNumber: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
// As Built Schema
|
||||
const asBuiltSchema = baseSchema.extend({
|
||||
drawingType: z.literal("AS_BUILT"),
|
||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||
mainCategoryId: z.string().min(1, "Main Category is required"),
|
||||
subCategoryId: z.string().min(1, "Sub Category is required"),
|
||||
drawingType: z.literal('AS_BUILT'),
|
||||
drawingNumber: z.string().min(1, 'Drawing Number is required'),
|
||||
mainCategoryId: z.string().min(1, 'Main Category is required'),
|
||||
subCategoryId: z.string().min(1, 'Sub Category is required'),
|
||||
// Revision Fields
|
||||
revisionLabel: z.string().default("0"),
|
||||
title: z.string().min(1, "Title is required"),
|
||||
revisionLabel: z.string().default('0'),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
legacyDrawingNumber: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("drawingType", [
|
||||
contractSchema,
|
||||
shopSchema,
|
||||
asBuiltSchema,
|
||||
]);
|
||||
const formSchema = z.discriminatedUnion('drawingType', [contractSchema, shopSchema, asBuiltSchema]);
|
||||
|
||||
type DrawingFormData = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -97,15 +95,15 @@ export function DrawingUploadForm() {
|
||||
} = useForm<DrawingFormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
drawingType: "CONTRACT",
|
||||
} as DrawingFormData
|
||||
drawingType: 'CONTRACT',
|
||||
} as DrawingFormData,
|
||||
});
|
||||
|
||||
// Type-safe error access for discriminated union fields
|
||||
const formErrors = errors as Record<string, FieldError | undefined>;
|
||||
|
||||
const drawingType = watch("drawingType");
|
||||
const watchedProjectId = watch("projectId");
|
||||
const drawingType = watch('drawingType');
|
||||
const watchedProjectId = watch('projectId');
|
||||
const createMutation = useCreateDrawing(drawingType);
|
||||
|
||||
// When project changes, update selectedProjectId for category hooks
|
||||
@@ -115,7 +113,9 @@ export function DrawingUploadForm() {
|
||||
return;
|
||||
}
|
||||
// Try to resolve UUID→INT from projects list, or pass UUID directly
|
||||
const project = projects.find((p: { id: string; uuid?: string }) => p.id === watchedProjectId || p.uuid === watchedProjectId) as { id: string; uuid?: string } | undefined;
|
||||
const project = projects.find(
|
||||
(p: { id: string; uuid?: string }) => p.id === watchedProjectId || p.uuid === watchedProjectId
|
||||
) as { id: string; uuid?: string } | undefined;
|
||||
setSelectedProjectId(project?.id ?? watchedProjectId);
|
||||
}, [watchedProjectId, projects]);
|
||||
|
||||
@@ -153,8 +153,8 @@ export function DrawingUploadForm() {
|
||||
|
||||
createMutation.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
router.push("/drawings");
|
||||
}
|
||||
router.push('/drawings');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -167,9 +167,7 @@ export function DrawingUploadForm() {
|
||||
{/* Project Selector */}
|
||||
<div>
|
||||
<Label>Project *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("projectId", v)}
|
||||
>
|
||||
<Select onValueChange={(v) => setValue('projectId', v)}>
|
||||
<SelectTrigger>
|
||||
{isLoadingProjects ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -185,16 +183,14 @@ export function DrawingUploadForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.projectId && (
|
||||
<p className="text-sm text-destructive">{errors.projectId.message}</p>
|
||||
)}
|
||||
{errors.projectId && <p className="text-sm text-destructive">{errors.projectId.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Drawing Type *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setValue("drawingType", v as DrawingFormData["drawingType"]);
|
||||
setValue('drawingType', v as DrawingFormData['drawingType']);
|
||||
// Reset errors or fields if needed
|
||||
}}
|
||||
defaultValue="CONTRACT"
|
||||
@@ -216,188 +212,204 @@ export function DrawingUploadForm() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Contract Drawing No *</Label>
|
||||
<Input {...register("contractDrawingNo")} placeholder="e.g. CD-001" />
|
||||
<Input {...register('contractDrawingNo')} placeholder="e.g. CD-001" />
|
||||
{formErrors.contractDrawingNo && (
|
||||
<p className="text-sm text-destructive">{formErrors.contractDrawingNo.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Title *</Label>
|
||||
<Input {...register("title")} placeholder="Drawing Title" />
|
||||
{formErrors.title && (
|
||||
<p className="text-sm text-destructive">{formErrors.title.message}</p>
|
||||
)}
|
||||
<Label>Title *</Label>
|
||||
<Input {...register('title')} placeholder="Drawing Title" />
|
||||
{formErrors.title && <p className="text-sm text-destructive">{formErrors.title.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Category *</Label>
|
||||
<Select onValueChange={(v) => setValue("mapCatId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contractCategories?.map((c: { id: number; catName?: string; catCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.catName || c.catCode || c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.mapCatId && (
|
||||
<p className="text-sm text-destructive">{formErrors.mapCatId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>Volume ID</Label>
|
||||
<Input {...register("volumeId")} placeholder="Vol. 1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Page No.</Label>
|
||||
<Input {...register("volumePage")} type="number" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Category *</Label>
|
||||
<Select onValueChange={(v) => setValue('mapCatId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contractCategories?.map(
|
||||
(c: { id: number; catName?: string; catCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.catName || c.catCode || c.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.mapCatId && <p className="text-sm text-destructive">{formErrors.mapCatId.message}</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>Volume ID</Label>
|
||||
<Input {...register('volumeId')} placeholder="Vol. 1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Page No.</Label>
|
||||
<Input {...register('volumePage')} type="number" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SHOP FIELDS */}
|
||||
{drawingType === 'SHOP' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Shop Drawing No *</Label>
|
||||
<Input {...register("drawingNumber")} placeholder="e.g. SD-101" />
|
||||
{formErrors.drawingNumber && (
|
||||
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legacy Number</Label>
|
||||
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Shop Drawing No *</Label>
|
||||
<Input {...register('drawingNumber')} placeholder="e.g. SD-101" />
|
||||
{formErrors.drawingNumber && (
|
||||
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legacy Number</Label>
|
||||
<Input {...register('legacyDrawingNumber')} placeholder="Legacy No." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Main Category *</Label>
|
||||
<Select onValueChange={(v) => {
|
||||
setValue("mainCategoryId", v);
|
||||
setSelectedShopMainCat(v ? parseInt(v) : undefined);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Main Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopMainCats?.map((c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.mainCategoryName || c.mainCategoryCode || c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.mainCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sub Category *</Label>
|
||||
<Select onValueChange={(v) => setValue("subCategoryId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Sub Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopSubCats?.map((c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.subCategoryName || c.subCategoryCode || c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.subCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Main Category *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setValue('mainCategoryId', v);
|
||||
setSelectedShopMainCat(v ? Number(v) : undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Main Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopMainCats?.map(
|
||||
(c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.mainCategoryName || c.mainCategoryCode || c.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.mainCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sub Category *</Label>
|
||||
<Select onValueChange={(v) => setValue('subCategoryId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Sub Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopSubCats?.map(
|
||||
(c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.subCategoryName || c.subCategoryCode || c.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.subCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Revision Title *</Label>
|
||||
<Input {...register("title")} placeholder="Current Revision Title" />
|
||||
{formErrors.title && (
|
||||
<p className="text-sm text-destructive">{formErrors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Revision Title *</Label>
|
||||
<Input {...register('title')} placeholder="Current Revision Title" />
|
||||
{formErrors.title && <p className="text-sm text-destructive">{formErrors.title.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register("description")} />
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register('description')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AS BUILT FIELDS */}
|
||||
{drawingType === 'AS_BUILT' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Drawing No *</Label>
|
||||
<Input {...register("drawingNumber")} placeholder="e.g. AB-101" />
|
||||
{formErrors.drawingNumber && (
|
||||
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legacy Number</Label>
|
||||
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Drawing No *</Label>
|
||||
<Input {...register('drawingNumber')} placeholder="e.g. AB-101" />
|
||||
{formErrors.drawingNumber && (
|
||||
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legacy Number</Label>
|
||||
<Input {...register('legacyDrawingNumber')} placeholder="Legacy No." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Main Category *</Label>
|
||||
<Select onValueChange={(v) => {
|
||||
setValue("mainCategoryId", v);
|
||||
setSelectedShopMainCat(v ? parseInt(v) : undefined);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Main Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopMainCats?.map((c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.mainCategoryName || c.mainCategoryCode || c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.mainCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sub Category *</Label>
|
||||
<Select onValueChange={(v) => setValue("subCategoryId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Sub Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopSubCats?.map((c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.subCategoryName || c.subCategoryCode || c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.subCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Main Category *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setValue('mainCategoryId', v);
|
||||
setSelectedShopMainCat(v ? Number(v) : undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Main Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopMainCats?.map(
|
||||
(c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.mainCategoryName || c.mainCategoryCode || c.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.mainCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sub Category *</Label>
|
||||
<Select onValueChange={(v) => setValue('subCategoryId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Sub Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopSubCats?.map(
|
||||
(c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.subCategoryName || c.subCategoryCode || c.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.subCategoryId && (
|
||||
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Title *</Label>
|
||||
<Input {...register("title")} placeholder="Drawing Title" />
|
||||
{formErrors.title && (
|
||||
<p className="text-sm text-destructive">{formErrors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register("description")} />
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<Label>Title *</Label>
|
||||
<Input {...register('title')} placeholder="Drawing Title" />
|
||||
{formErrors.title && <p className="text-sm text-destructive">{formErrors.title.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register('description')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -409,14 +421,11 @@ export function DrawingUploadForm() {
|
||||
className="cursor-pointer"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setValue("file", file);
|
||||
if (file) setValue('file', file);
|
||||
}}
|
||||
/>
|
||||
{errors.file && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
|
||||
)}
|
||||
{errors.file && <p className="text-sm text-destructive mt-1">{errors.file.message}</p>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// File: components/layout/dashboard-shell.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useUIStore } from "@/lib/stores/ui-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUIStore } from '@/lib/stores/ui-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const { isSidebarOpen } = useUIStore();
|
||||
@@ -10,12 +10,12 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col min-h-screen transition-all duration-300 ease-in-out",
|
||||
'flex flex-col min-h-screen transition-all duration-300 ease-in-out',
|
||||
// ปรับ Margin ซ้าย ตามสถานะ Sidebar
|
||||
isSidebarOpen ? "md:ml-[240px]" : "md:ml-[70px]"
|
||||
isSidebarOpen ? 'md:ml-[240px]' : 'md:ml-[70px]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, FileText, Clipboard, Image, Loader2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command, CommandGroup, CommandItem, CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useSearchSuggestions } from "@/hooks/use-search";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, FileText, Clipboard, Image, Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useSearchSuggestions } from '@/hooks/use-search';
|
||||
|
||||
/** Search suggestion item returned from the API */
|
||||
interface SearchSuggestion {
|
||||
@@ -39,7 +33,7 @@ function useDebounceValue<T>(value: T, delay: number): T {
|
||||
export function GlobalSearch() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const debouncedQuery = useDebounceValue(query, 300);
|
||||
|
||||
@@ -62,10 +56,14 @@ export function GlobalSearch() {
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "correspondence": return <FileText className="mr-2 h-4 w-4" />;
|
||||
case "rfa": return <Clipboard className="mr-2 h-4 w-4" />;
|
||||
case "drawing": return <Image className="mr-2 h-4 w-4" />;
|
||||
default: return <Search className="mr-2 h-4 w-4" />;
|
||||
case 'correspondence':
|
||||
return <FileText className="mr-2 h-4 w-4" />;
|
||||
case 'rfa':
|
||||
return <Clipboard className="mr-2 h-4 w-4" />;
|
||||
case 'drawing':
|
||||
return <Image className="mr-2 h-4 w-4" />;
|
||||
default:
|
||||
return <Search className="mr-2 h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,17 +79,19 @@ export function GlobalSearch() {
|
||||
className="pl-8 w-full bg-background"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
onFocus={() => {
|
||||
if (suggestions && suggestions.length > 0) setOpen(true);
|
||||
}}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-2.5 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{isLoading && <Loader2 className="absolute right-2.5 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<PopoverContent
|
||||
className="p-0 w-[var(--radix-popover-trigger-width)]"
|
||||
align="start"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command>
|
||||
<CommandList>
|
||||
{suggestions && suggestions.length > 0 && (
|
||||
@@ -114,9 +114,7 @@ export function GlobalSearch() {
|
||||
</CommandGroup>
|
||||
)}
|
||||
{(!suggestions || suggestions.length === 0) && !isLoading && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No suggestions found.
|
||||
</div>
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">No suggestions found.</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// File: components/layout/navbar.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import { Menu, Bell } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUIStore } from "@/lib/stores/ui-store";
|
||||
import { UserNav } from "./user-nav";
|
||||
import _Link from 'next/link';
|
||||
import { Menu, Bell } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUIStore } from '@/lib/stores/ui-store';
|
||||
import { UserNav } from './user-nav';
|
||||
|
||||
export function Navbar() {
|
||||
const { toggleSidebar } = useUIStore();
|
||||
@@ -13,21 +13,14 @@ export function Navbar() {
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:pr-6 lg:pl-1 sticky top-0 z-30">
|
||||
{/* Toggle Sidebar Button (Mobile Only) */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<Button variant="outline" size="icon" className="shrink-0 md:hidden" onClick={toggleSidebar}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle navigation menu</span>
|
||||
</Button>
|
||||
|
||||
<div className="w-full flex-1">
|
||||
{/* Breadcrumbs หรือ Search Bar จะมาใส่ตรงนี้ */}
|
||||
<h1 className="text-lg font-semibold md:text-xl hidden md:block">
|
||||
Document Management System
|
||||
</h1>
|
||||
<h1 className="text-lg font-semibold md:text-xl hidden md:block">Document Management System</h1>
|
||||
</div>
|
||||
|
||||
{/* Right Actions (เหลือชุดเดียวที่ถูกต้อง) */}
|
||||
@@ -36,10 +29,10 @@ export function Navbar() {
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
|
||||
|
||||
{/* User Menu */}
|
||||
<UserNav />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Bell, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bell, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useNotifications, useMarkNotificationRead } from "@/hooks/use-notification";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Notification } from "@/types/notification";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useNotifications, useMarkNotificationRead } from '@/hooks/use-notification';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Notification } from '@/types/notification';
|
||||
|
||||
export function NotificationsDropdown() {
|
||||
const router = useRouter();
|
||||
@@ -54,30 +54,24 @@ export function NotificationsDropdown() {
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No new notifications
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">No new notifications</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.slice(0, 5).map((notification: Notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.notificationId}
|
||||
className={`flex flex-col items-start p-3 cursor-pointer ${
|
||||
!notification.isRead ? 'bg-muted/30' : ''
|
||||
}`}
|
||||
className={`flex flex-col items-start p-3 cursor-pointer ${!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.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}
|
||||
<span className="font-medium text-sm">{notification.title}</span>
|
||||
{!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.createdAt), {
|
||||
addSuffix: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, Settings, User } from "lucide-react";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { signOut, useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LogOut, Settings, User } from 'lucide-react';
|
||||
|
||||
export function UserMenu() {
|
||||
const router = useRouter();
|
||||
@@ -24,18 +24,18 @@ export function UserMenu() {
|
||||
// Generate initials from name or username
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const initials = user.name ? getInitials(user.name) : "U";
|
||||
const initials = user.name ? getInitials(user.name) : 'U';
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false });
|
||||
router.push("/login");
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -43,9 +43,7 @@ export function UserMenu() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary/10 text-primary">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -53,20 +51,16 @@ export function UserMenu() {
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground mt-1">
|
||||
Role: {user.role}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground mt-1">Role: {user.role}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
<DropdownMenuItem onClick={() => router.push('/profile')}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<DropdownMenuItem onClick={() => router.push('/settings')}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// File: components/layout/user-nav.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,9 +12,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { signOut, useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function UserNav() {
|
||||
const { data: session } = useSession();
|
||||
@@ -26,22 +22,24 @@ export function UserNav() {
|
||||
|
||||
// Helper function to get initials from name
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
?.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.substring(0, 2) || "US";
|
||||
return (
|
||||
name
|
||||
?.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.substring(0, 2) || 'US'
|
||||
);
|
||||
};
|
||||
|
||||
const userName = session?.user?.name || "User";
|
||||
const userEmail = session?.user?.email || "user@example.com";
|
||||
const userName = session?.user?.name || 'User';
|
||||
const userEmail = session?.user?.email || 'user@example.com';
|
||||
// ใช้ role หรือ organization หากมีใน session (ต้องแก้ type ใน next-auth.d.ts แล้ว)
|
||||
const userRole = session?.user?.role || "Viewer";
|
||||
const userRole = session?.user?.role || 'Viewer';
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false });
|
||||
router.push("/login");
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,7 +48,7 @@ export function UserNav() {
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
{/* ใส่ URL รูปถ้ามี */}
|
||||
<AvatarImage src={session?.user?.image || ""} alt={userName} />
|
||||
<AvatarImage src={session?.user?.image || ''} alt={userName} />
|
||||
<AvatarFallback>{getInitials(userName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
@@ -59,12 +57,8 @@ export function UserNav() {
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{userName}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{userEmail}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-primary mt-1 font-semibold">
|
||||
{userRole}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{userEmail}</p>
|
||||
<p className="text-xs leading-none text-primary mt-1 font-semibold">{userRole}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -86,4 +80,4 @@ export function UserNav() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export function AuditLogsTable() {
|
||||
const [logs, setLogs] = useState<any[]>([]); // Replace with AuditLog type
|
||||
const [logs, setLogs] = useState<unknown[]>([]); // Replace with AuditLog type
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,9 +14,9 @@ export function AuditLogsTable() {
|
||||
try {
|
||||
const data = await documentNumberingService.getMetrics(); // Using metrics endpoint for now as it contains logs
|
||||
if (data && data.audit) {
|
||||
setLogs(data.audit);
|
||||
setLogs(data.audit);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Failed to fetch audit logs - empty state shown
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -49,15 +42,17 @@ export function AuditLogsTable() {
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center">No logs found.</TableCell>
|
||||
<TableCell colSpan={5} className="text-center">
|
||||
No logs found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{format(new Date(log.createdAt), "yyyy-MM-dd HH:mm:ss")}</TableCell>
|
||||
<TableCell>{format(new Date(log.createdAt), 'yyyy-MM-dd HH:mm:ss')}</TableCell>
|
||||
<TableCell>{log.operation}</TableCell>
|
||||
<TableCell>{log.generatedNumber}</TableCell>
|
||||
<TableCell>{log.createdBy || "System"}</TableCell>
|
||||
<TableCell>{log.createdBy || 'System'}</TableCell>
|
||||
<TableCell>{log.status}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
|
||||
export function BulkImportForm({ projectId = 1 }: { projectId?: number | string }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -22,14 +22,14 @@ export function BulkImportForm({ projectId = 1 }: { projectId?: number | string
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("projectId", projectId.toString());
|
||||
formData.append('file', file);
|
||||
formData.append('projectId', projectId.toString());
|
||||
|
||||
await documentNumberingService.bulkImport(formData);
|
||||
toast.success("Bulk import initiated. Check audit logs for progress.");
|
||||
toast.success('Bulk import initiated. Check audit logs for progress.');
|
||||
setFile(null);
|
||||
} catch (error) {
|
||||
toast.error("Failed to import numbers.");
|
||||
} catch (_error) {
|
||||
toast.error('Failed to import numbers.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -37,17 +37,17 @@ export function BulkImportForm({ projectId = 1 }: { projectId?: number | string
|
||||
|
||||
return (
|
||||
<div className="border p-4 rounded-md space-y-4">
|
||||
<h3 className="text-lg font-medium">Bulk Import Numbers</h3>
|
||||
<p className="text-sm text-gray-500">Import legacy numbers via CSV to reserve them in the system.</p>
|
||||
<h3 className="text-lg font-medium">Bulk Import Numbers</h3>
|
||||
<p className="text-sm text-gray-500">Import legacy numbers via CSV to reserve them in the system.</p>
|
||||
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="csv-file">CSV File</Label>
|
||||
<Input id="csv-file" type="file" accept=".csv,.xlsx" onChange={handleFileChange} />
|
||||
</div>
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="csv-file">CSV File</Label>
|
||||
<Input id="csv-file" type="file" accept=".csv,.xlsx" onChange={handleFileChange} />
|
||||
</div>
|
||||
|
||||
<Button onClick={handleUpload} disabled={!file || loading}>
|
||||
{loading ? "Importing..." : "Upload & Import"}
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={!file || loading}>
|
||||
{loading ? 'Importing...' : 'Upload & Import'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { CancelNumberDto } from "@/types/dto/numbering.dto";
|
||||
import { useState } from "react";
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { CancelNumberDto } from '@/types/dto/numbering.dto';
|
||||
import { useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
documentNumber: z.string().min(3, "Document Number is required"),
|
||||
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||
documentNumber: z.string().min(3, 'Document Number is required'),
|
||||
reason: z.string().min(5, 'Reason must be at least 5 characters'),
|
||||
});
|
||||
|
||||
type CancelNumberFormData = z.infer<typeof formSchema>;
|
||||
@@ -31,8 +24,8 @@ export function CancelNumberForm() {
|
||||
const form = useForm<CancelNumberFormData>({
|
||||
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
|
||||
defaultValues: {
|
||||
documentNumber: "",
|
||||
reason: "",
|
||||
documentNumber: '',
|
||||
reason: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,10 +34,10 @@ export function CancelNumberForm() {
|
||||
try {
|
||||
const dto: CancelNumberDto = values;
|
||||
await documentNumberingService.cancelNumber(dto);
|
||||
toast.success("Number cancelled successfully.");
|
||||
toast.success('Number cancelled successfully.');
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error("Failed to cancel number. It may not exist or is already cancelled.");
|
||||
} catch (_error) {
|
||||
toast.error('Failed to cancel number. It may not exist or is already cancelled.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -54,30 +47,40 @@ export function CancelNumberForm() {
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md">
|
||||
<h3 className="text-lg font-medium">Cancel Number</h3>
|
||||
<p className="text-sm text-gray-500">Permanently cancel a number (e.g. if generated by mistake). It cannot be reused.</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Permanently cancel a number (e.g. if generated by mistake). It cannot be reused.
|
||||
</p>
|
||||
|
||||
<FormField control={form.control} name="documentNumber" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reason for cancellation..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reason for cancellation..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="destructive" disabled={loading}>
|
||||
{loading ? "Cancelling..." : "Cancel Number"}
|
||||
{loading ? 'Cancelling...' : 'Cancel Number'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { ManualOverrideDto } from "@/types/dto/numbering.dto";
|
||||
import { useState } from "react";
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { ManualOverrideDto } from '@/types/dto/numbering.dto';
|
||||
import { useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
projectId: z.coerce.number().min(1, "Project is required"),
|
||||
originatorOrganizationId: z.coerce.number().min(1, "Originator is required"),
|
||||
recipientOrganizationId: z.coerce.number().min(1, "Recipient is required"),
|
||||
correspondenceTypeId: z.coerce.number().min(1, "Type is required"),
|
||||
newLastNumber: z.coerce.number().min(1, "New number is required"),
|
||||
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||
resetScope: z.string().optional()
|
||||
projectId: z.coerce.number().min(1, 'Project is required'),
|
||||
originatorOrganizationId: z.coerce.number().min(1, 'Originator is required'),
|
||||
recipientOrganizationId: z.coerce.number().min(1, 'Recipient is required'),
|
||||
correspondenceTypeId: z.coerce.number().min(1, 'Type is required'),
|
||||
newLastNumber: z.coerce.number().min(1, 'New number is required'),
|
||||
reason: z.string().min(5, 'Reason must be at least 5 characters'),
|
||||
resetScope: z.string().optional(),
|
||||
});
|
||||
|
||||
export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | string }) {
|
||||
@@ -40,8 +32,8 @@ export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | str
|
||||
recipientOrganizationId: 0,
|
||||
correspondenceTypeId: 0,
|
||||
newLastNumber: 0,
|
||||
reason: "",
|
||||
resetScope: "YEAR_2025" // Example, should be dynamic or selected
|
||||
reason: '',
|
||||
resetScope: 'YEAR_2025', // Example, should be dynamic or selected
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,13 +42,13 @@ export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | str
|
||||
try {
|
||||
const dto: ManualOverrideDto = {
|
||||
...values,
|
||||
resetScope: values.resetScope || "YEAR_" + new Date().getFullYear()
|
||||
resetScope: values.resetScope || 'YEAR_' + new Date().getFullYear(),
|
||||
};
|
||||
await documentNumberingService.manualOverride(dto);
|
||||
toast.success("Manual override applied successfully.");
|
||||
toast.success('Manual override applied successfully.');
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error("Failed to apply override.");
|
||||
} catch (_error) {
|
||||
toast.error('Failed to apply override.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -66,65 +58,97 @@ export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | str
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md mt-4">
|
||||
<h3 className="text-lg font-medium">Manual Override Sequence</h3>
|
||||
<p className="text-sm text-gray-500">Careful: This updates the LAST generated number. Next number will receive +1.</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Careful: This updates the LAST generated number. Next number will receive +1.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Allow simple text input for IDs for now, ideally Selects from Master Data */}
|
||||
<FormField control={form.control} name="projectId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="correspondenceTypeId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="originatorOrganizationId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Originator Org ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="recipientOrganizationId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Recipient Org ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
{/* Allow simple text input for IDs for now, ideally Selects from Master Data */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="correspondenceTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="originatorOrganizationId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Originator Org ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipientOrganizationId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Recipient Org ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="newLastNumber" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Set Last Number To</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you set 99, the next auto-generated number will be 100.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newLastNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Set Last Number To</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>If you set 99, the next auto-generated number will be 100.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Why are you overriding?" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Why are you overriding?" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Applying..." : "Apply Override"}
|
||||
{loading ? 'Applying...' : 'Apply Override'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { NumberingMetrics } from "@/types/dto/numbering.dto";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, _CardDescription } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { NumberingMetrics } from '@/types/dto/numbering.dto';
|
||||
|
||||
export function MetricsDashboard() {
|
||||
const [metrics, setMetrics] = useState<Partial<NumberingMetrics>>({});
|
||||
@@ -15,7 +15,7 @@ export function MetricsDashboard() {
|
||||
try {
|
||||
const data = await documentNumberingService.getMetrics();
|
||||
setMetrics(data);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Failed to fetch metrics - handled by loading state
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -48,12 +48,12 @@ export function MetricsDashboard() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Sequence Utilization</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Sequence Utilization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{utilization}%</div>
|
||||
<Progress value={utilization} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Average capacity used</p>
|
||||
<div className="text-2xl font-bold">{utilization}%</div>
|
||||
<Progress value={utilization} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Average capacity used</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -68,13 +68,13 @@ export function MetricsDashboard() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.errors?.length || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">In the last 24 hours</p>
|
||||
</CardContent>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.errors?.length || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">In the last 24 hours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SequenceViewer() {
|
||||
try {
|
||||
const response = await numberingApi.getSequences();
|
||||
// Handle wrapped response { data: [...] } or direct array
|
||||
const data = Array.isArray(response) ? response : (response as { data?: NumberSequence[] })?.data ?? [];
|
||||
const data = Array.isArray(response) ? response : ((response as { data?: NumberSequence[] })?.data ?? []);
|
||||
setSequences(data);
|
||||
} catch {
|
||||
// Failed to fetch sequences - show empty state
|
||||
@@ -43,12 +43,7 @@ export function SequenceViewer() {
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Number Counters</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchSequences}
|
||||
disabled={loading}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={fetchSequences} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -64,9 +59,7 @@ export function SequenceViewer() {
|
||||
|
||||
<div className="space-y-2">
|
||||
{filteredSequences.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
No sequences found
|
||||
</div>
|
||||
<div className="text-center text-muted-foreground py-4">No sequences found</div>
|
||||
)}
|
||||
{filteredSequences.map((seq, index) => (
|
||||
<div
|
||||
@@ -78,15 +71,11 @@ export function SequenceViewer() {
|
||||
<span className="font-medium">Year {seq.year}</span>
|
||||
<Badge variant="outline">Project: {seq.projectId}</Badge>
|
||||
<Badge>Type: {seq.typeId}</Badge>
|
||||
{seq.disciplineId > 0 && (
|
||||
<Badge variant="secondary">Disc: {seq.disciplineId}</Badge>
|
||||
)}
|
||||
{seq.disciplineId > 0 && <Badge variant="secondary">Disc: {seq.disciplineId}</Badge>}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground font-medium">
|
||||
Counter: {seq.lastNumber}
|
||||
</span>{' '}
|
||||
| Originator: {seq.originatorId} | Recipient:{' '}
|
||||
<span className="text-foreground font-medium">Counter: {seq.lastNumber}</span> | Originator:{' '}
|
||||
{seq.originatorId} | Recipient:{' '}
|
||||
{seq.recipientOrganizationId === -1 ? 'All' : seq.recipientOrganizationId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,21 +5,11 @@ import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { NumberingTemplate } from '@/lib/api/numbering';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
|
||||
// Aligned with Backend replacement logic
|
||||
const VARIABLES = [
|
||||
@@ -36,13 +26,13 @@ const VARIABLES = [
|
||||
];
|
||||
|
||||
export interface TemplateEditorProps {
|
||||
template?: NumberingTemplate;
|
||||
projectId: number | string;
|
||||
projectName: string;
|
||||
correspondenceTypes: unknown[];
|
||||
disciplines: unknown[];
|
||||
onSave: (data: Partial<NumberingTemplate>) => void;
|
||||
onCancel: () => void;
|
||||
template?: NumberingTemplate;
|
||||
projectId: number | string;
|
||||
projectName: string;
|
||||
correspondenceTypes: unknown[];
|
||||
disciplines: unknown[];
|
||||
onSave: (data: Partial<NumberingTemplate>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function TemplateEditor({
|
||||
@@ -52,7 +42,7 @@ export function TemplateEditor({
|
||||
correspondenceTypes,
|
||||
disciplines,
|
||||
onSave,
|
||||
onCancel
|
||||
onCancel,
|
||||
}: TemplateEditorProps) {
|
||||
const [format, setFormat] = useState(template?.formatTemplate || '');
|
||||
const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || '');
|
||||
@@ -65,18 +55,20 @@ export function TemplateEditor({
|
||||
// Generate preview
|
||||
let previewText = format || '';
|
||||
VARIABLES.forEach((v) => {
|
||||
// Simple mock replacement for preview
|
||||
let replacement = v.example;
|
||||
if (v.key === '{YEAR:BE}') replacement = (new Date().getFullYear() + 543).toString();
|
||||
if (v.key === '{YEAR:CE}') replacement = new Date().getFullYear().toString();
|
||||
// Simple mock replacement for preview
|
||||
let replacement = v.example;
|
||||
if (v.key === '{YEAR:BE}') replacement = (new Date().getFullYear() + 543).toString();
|
||||
if (v.key === '{YEAR:CE}') replacement = new Date().getFullYear().toString();
|
||||
|
||||
// Dynamic context based on selection (optional visual enhancement)
|
||||
if (v.key === '{TYPE}' && typeId) {
|
||||
const t = (correspondenceTypes as { id: number; typeCode: string; typeName: string }[]).find((ct) => ct.id?.toString() === typeId);
|
||||
if (t) replacement = t.typeCode;
|
||||
}
|
||||
// Dynamic context based on selection (optional visual enhancement)
|
||||
if (v.key === '{TYPE}' && typeId) {
|
||||
const t = (correspondenceTypes as { id: number; typeCode: string; typeName: string }[]).find(
|
||||
(ct) => ct.id?.toString() === typeId
|
||||
);
|
||||
if (t) replacement = t.typeCode;
|
||||
}
|
||||
|
||||
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
|
||||
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
|
||||
});
|
||||
setPreview(previewText);
|
||||
}, [format, typeId, correspondenceTypes]);
|
||||
@@ -86,14 +78,14 @@ export function TemplateEditor({
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...template,
|
||||
projectId: projectId,
|
||||
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
|
||||
disciplineId: Number(disciplineId),
|
||||
formatTemplate: format,
|
||||
resetSequenceYearly: reset,
|
||||
});
|
||||
onSave({
|
||||
...template,
|
||||
projectId: projectId,
|
||||
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
|
||||
disciplineId: Number(disciplineId),
|
||||
formatTemplate: format,
|
||||
resetSequenceYearly: reset,
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = format.length > 0; // typeId is optional (null = default for all types)
|
||||
@@ -102,121 +94,125 @@ export function TemplateEditor({
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Define how document numbers are generated for this project.</p>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Define how document numbers are generated for this project.</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
|
||||
Project: {projectName}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
|
||||
Project: {projectName}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Configuration Column */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Document Type (Optional)</Label>
|
||||
<Select value={typeId} onValueChange={setTypeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Default (All Types)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (All Types)</SelectItem>
|
||||
{correspondenceTypes.map((type: unknown) => {
|
||||
const typedType = type as { id: number; typeCode: string; typeName: string };
|
||||
return (
|
||||
<SelectItem key={typedType.id} value={typedType.id.toString()}>
|
||||
{typedType.typeCode} - {typedType.typeName}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Leave empty to create a default template for this project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Discipline (Optional)</Label>
|
||||
<Select value={disciplineId} onValueChange={setDisciplineId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Disciplines" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">All Disciplines</SelectItem>
|
||||
{disciplines.map((d: any) => (
|
||||
<SelectItem key={d.id} value={d.id.toString()}>
|
||||
{d.disciplineCode} - {d.codeNameEn || d.codeNameTh}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reset Rule</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox checked={reset} onCheckedChange={(c) => setReset(!!c)} />
|
||||
<span className="text-sm">Reset Annually</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Configuration Column */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Document Type (Optional)</Label>
|
||||
<Select value={typeId} onValueChange={setTypeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Default (All Types)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (All Types)</SelectItem>
|
||||
{correspondenceTypes.map((type: unknown) => {
|
||||
const typedType = type as { id: number; typeCode: string; typeName: string };
|
||||
return (
|
||||
<SelectItem key={typedType.id} value={typedType.id.toString()}>
|
||||
{typedType.typeCode} - {typedType.typeName}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Leave empty to create a default template for this project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format Column */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Template Format *</Label>
|
||||
<Input
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
placeholder="{ORG}-{TYPE}-{SEQ:4}"
|
||||
className="font-mono text-base mb-2"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VARIABLES.map((v) => (
|
||||
<HoverCard key={v.key}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => insertVariable(v.key)}
|
||||
type="button"
|
||||
className="font-mono text-xs bg-slate-50 hover:bg-slate-100"
|
||||
>
|
||||
{v.key}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-60 p-3">
|
||||
<p className="font-semibold text-sm">{v.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Example: <span className="font-mono">{v.example}</span></p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50/50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-green-700 font-semibold mb-2">Preview Output</p>
|
||||
<p className="text-2xl font-mono font-bold text-green-800 tracking-tight">
|
||||
{preview || '...'}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
* This is an approximation. Actual numbers depend on runtime context.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Discipline (Optional)</Label>
|
||||
<Select value={disciplineId} onValueChange={setDisciplineId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Disciplines" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">All Disciplines</SelectItem>
|
||||
{disciplines.map((d: unknown) => (
|
||||
<SelectItem key={d.id} value={d.id.toString()}>
|
||||
{d.disciplineCode} - {d.codeNameEn || d.codeNameTh}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reset Rule</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox checked={reset} onCheckedChange={(c) => setReset(!!c)} />
|
||||
<span className="text-sm">Reset Annually</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Column */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Template Format *</Label>
|
||||
<Input
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
placeholder="{ORG}-{TYPE}-{SEQ:4}"
|
||||
className="font-mono text-base mb-2"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VARIABLES.map((v) => (
|
||||
<HoverCard key={v.key}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => insertVariable(v.key)}
|
||||
type="button"
|
||||
className="font-mono text-xs bg-slate-50 hover:bg-slate-100"
|
||||
>
|
||||
{v.key}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-60 p-3">
|
||||
<p className="font-semibold text-sm">{v.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Example: <span className="font-mono">{v.example}</span>
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50/50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-green-700 font-semibold mb-2">Preview Output</p>
|
||||
<p className="text-2xl font-mono font-bold text-green-800 tracking-tight">{preview || '...'}</p>
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
* This is an approximation. Actual numbers depend on runtime context.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid}>Save Template</Button>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid}>
|
||||
Save Template
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useOrganizations, useCorrespondenceTypes, useDisciplines, useContracts } from '@/hooks/use-master-data';
|
||||
import { Organization } from '@/types/organization';
|
||||
|
||||
@@ -35,19 +24,18 @@ interface Discipline {
|
||||
disciplineCode: string;
|
||||
}
|
||||
|
||||
|
||||
interface TemplateTesterProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template: NumberingTemplate | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template: NumberingTemplate | null;
|
||||
}
|
||||
|
||||
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
|
||||
const [testData, setTestData] = useState({
|
||||
originatorId: "",
|
||||
recipientId: "",
|
||||
correspondenceTypeId: "",
|
||||
disciplineId: "",
|
||||
originatorId: '',
|
||||
recipientId: '',
|
||||
correspondenceTypeId: '',
|
||||
disciplineId: '',
|
||||
year: new Date().getFullYear(),
|
||||
});
|
||||
const [testResult, setTestResult] = useState<{ number: string; isDefault?: boolean } | null>(null);
|
||||
@@ -69,28 +57,28 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
setLoading(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const payload = {
|
||||
projectId: projectId,
|
||||
originatorOrganizationId: testData.originatorId || "0",
|
||||
recipientOrganizationId: testData.recipientId || "0",
|
||||
correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"),
|
||||
disciplineId: parseInt(testData.disciplineId || "0"),
|
||||
year: testData.year
|
||||
};
|
||||
console.log("TemplateTester: Sending payload:", payload);
|
||||
const result = await numberingApi.previewNumber(payload);
|
||||
console.log("TemplateTester: Received result:", result);
|
||||
|
||||
setTestResult({
|
||||
number: result.previewNumber,
|
||||
isDefault: result.isDefault
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Test Preview Error:", error);
|
||||
const errMsg = error?.response?.data?.message || error?.message || "Unknown error";
|
||||
setTestResult({ number: `Error: ${errMsg}`, isDefault: false });
|
||||
const payload = {
|
||||
projectId: projectId,
|
||||
originatorOrganizationId: testData.originatorId || '0',
|
||||
recipientOrganizationId: testData.recipientId || '0',
|
||||
correspondenceTypeId: Number(testData.correspondenceTypeId || '0'),
|
||||
disciplineId: Number(testData.disciplineId || '0'),
|
||||
year: testData.year,
|
||||
};
|
||||
// console.log("TemplateTester: Sending payload:", payload); /* TODO: Remove before prod */
|
||||
const result = await numberingApi.previewNumber(payload);
|
||||
// console.log("TemplateTester: Received result:", result); /* TODO: Remove before prod */
|
||||
|
||||
setTestResult({
|
||||
number: result.previewNumber,
|
||||
isDefault: result.isDefault,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// console.error("Test Preview Error:", error); /* TODO: Remove before prod */
|
||||
const errMsg = error?.response?.data?.message || error?.message || 'Unknown error';
|
||||
setTestResult({ number: `Error: ${errMsg}`, isDefault: false });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,132 +90,139 @@ 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?.formatTemplate}</span>
|
||||
Template: <span className="font-mono font-bold text-foreground">{template?.formatTemplate}</span>
|
||||
</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 className="md:col-span-2">
|
||||
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Originator */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Originator (ORG)</label>
|
||||
<Select
|
||||
value={testData.originatorId}
|
||||
onValueChange={(val) => setTestData({...testData, originatorId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Originator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(organizations as Organization[])?.map((org) => (
|
||||
<SelectItem key={org.uuid} value={org.uuid}>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-4">Template Tester</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Originator */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Originator (ORG)</label>
|
||||
<Select
|
||||
value={testData.originatorId}
|
||||
onValueChange={(val) => setTestData({ ...testData, originatorId: val })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Originator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(organizations as Organization[])?.map((org) => (
|
||||
<SelectItem key={org.uuid} value={org.uuid}>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Recipient */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Recipient (REC)</label>
|
||||
<Select
|
||||
value={testData.recipientId}
|
||||
onValueChange={(val) => setTestData({...testData, recipientId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Recipient" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(organizations as Organization[])?.map((org) => (
|
||||
<SelectItem key={org.uuid} value={org.uuid}>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Recipient */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Recipient (REC)</label>
|
||||
<Select
|
||||
value={testData.recipientId}
|
||||
onValueChange={(val) => setTestData({ ...testData, recipientId: val })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Recipient" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(organizations as Organization[])?.map((org) => (
|
||||
<SelectItem key={org.uuid} value={org.uuid}>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Document Type */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Document Type (TYPE)</label>
|
||||
<Select
|
||||
value={testData.correspondenceTypeId}
|
||||
onValueChange={(val) => setTestData({...testData, correspondenceTypeId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Default (All Types)</SelectItem>
|
||||
{(correspondenceTypes as CorrespondenceType[])?.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id.toString()}>
|
||||
{type.typeCode} - {type.typeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Document Type */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Document Type (TYPE)</label>
|
||||
<Select
|
||||
value={testData.correspondenceTypeId}
|
||||
onValueChange={(val) => setTestData({ ...testData, correspondenceTypeId: val })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Default (All Types)</SelectItem>
|
||||
{(correspondenceTypes as CorrespondenceType[])?.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id.toString()}>
|
||||
{type.typeCode} - {type.typeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Discipline */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Discipline (DIS)</label>
|
||||
<Select
|
||||
value={testData.disciplineId}
|
||||
onValueChange={(val) => setTestData({...testData, disciplineId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Discipline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">None</SelectItem>
|
||||
{(disciplines as Discipline[])?.map((disc) => (
|
||||
<SelectItem key={disc.id} value={disc.id.toString()}>
|
||||
{disc.disciplineCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Format Preview: {template?.formatTemplate}
|
||||
</p>
|
||||
</div>
|
||||
{/* Discipline */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Discipline (DIS)</label>
|
||||
<Select
|
||||
value={testData.disciplineId}
|
||||
onValueChange={(val) => setTestData({ ...testData, disciplineId: val })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Discipline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">None</SelectItem>
|
||||
{(disciplines as Discipline[])?.map((disc) => (
|
||||
<SelectItem key={disc.id} value={disc.id.toString()}>
|
||||
{disc.disciplineCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">Format Preview: {template?.formatTemplate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button onClick={handleGenerate} className="w-full mt-4" disabled={loading || !template}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate Test Number
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<Card className={`p-4 mt-4 border text-center ${(testResult.number || '').startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-sm text-muted-foreground">{(testResult.number || '').startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}</p>
|
||||
{testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">Default Template</Badge>
|
||||
)}
|
||||
{!testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1 border-green-300 text-green-700 bg-green-50">Specific Template</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-2xl font-mono font-bold ${(testResult.number || '').startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}>
|
||||
{testResult.number || (
|
||||
<div className="text-xs font-mono text-left bg-slate-100 p-2 overflow-auto max-h-48 border rounded text-foreground">
|
||||
Empty Result. Raw: {JSON.stringify(testResult, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<Button onClick={handleGenerate} className="w-full mt-4" disabled={loading || !template}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate Test Number
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<Card
|
||||
className={`p-4 mt-4 border text-center ${(testResult.number || '').startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(testResult.number || '').startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}
|
||||
</p>
|
||||
{testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">
|
||||
Default Template
|
||||
</Badge>
|
||||
)}
|
||||
{!testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1 border-green-300 text-green-700 bg-green-50">
|
||||
Specific Template
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-mono font-bold ${(testResult.number || '').startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}
|
||||
>
|
||||
{testResult.number || (
|
||||
<div className="text-xs font-mono text-left bg-slate-100 p-2 overflow-auto max-h-48 border rounded text-foreground">
|
||||
Empty Result. Raw: {JSON.stringify(testResult, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { VoidReplaceDto } from "@/types/dto/numbering.dto";
|
||||
import { useState } from "react";
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { VoidReplaceDto } from '@/types/dto/numbering.dto';
|
||||
import { useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
documentNumber: z.string().min(3, "Document Number is required"),
|
||||
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||
documentNumber: z.string().min(3, 'Document Number is required'),
|
||||
reason: z.string().min(5, 'Reason must be at least 5 characters'),
|
||||
replace: z.boolean(),
|
||||
projectId: z.number()
|
||||
projectId: z.number(),
|
||||
});
|
||||
|
||||
type VoidReplaceFormData = z.infer<typeof formSchema>;
|
||||
@@ -35,10 +27,10 @@ export function VoidReplaceForm({ projectId = 1 }: { projectId?: number | string
|
||||
const form = useForm<VoidReplaceFormData>({
|
||||
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
|
||||
defaultValues: {
|
||||
documentNumber: "",
|
||||
reason: "",
|
||||
documentNumber: '',
|
||||
reason: '',
|
||||
replace: false,
|
||||
projectId: Number(projectId)
|
||||
projectId: Number(projectId),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,10 +41,10 @@ export function VoidReplaceForm({ projectId = 1 }: { projectId?: number | string
|
||||
...values,
|
||||
};
|
||||
await documentNumberingService.voidAndReplace(dto);
|
||||
toast.success("Number voided successfully. " + (values.replace ? "Replacement generated." : ""));
|
||||
toast.success('Number voided successfully. ' + (values.replace ? 'Replacement generated.' : ''));
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error("Failed to void number. Check if it exists.");
|
||||
} catch (_error) {
|
||||
toast.error('Failed to void number. Check if it exists.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -64,47 +56,52 @@ export function VoidReplaceForm({ projectId = 1 }: { projectId?: number | string
|
||||
<h3 className="text-lg font-medium">Void & Replace Number</h3>
|
||||
<p className="text-sm text-gray-500">Void a generated number. Useful for skipped numbers or errors.</p>
|
||||
|
||||
<FormField control={form.control} name="documentNumber" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reason for voiding..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reason for voiding..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField control={form.control} name="replace" render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Generate Replacement?
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
If checked, a new number will be reserved immediately.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="replace"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Generate Replacement?</FormLabel>
|
||||
<FormDescription>If checked, a new number will be reserved immediately.</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="destructive" disabled={loading}>
|
||||
{loading ? "Processing..." : "Void Number"}
|
||||
{loading ? 'Processing...' : 'Void Number'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import type { RFA, RFAItem } from "@/types/rfa";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useProcessRFA } from "@/hooks/use-rfa";
|
||||
import type { RFA, RFAItem } from '@/types/rfa';
|
||||
import { StatusBadge } from '@/components/common/status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useProcessRFA } from '@/hooks/use-rfa';
|
||||
|
||||
interface RFADetailProps {
|
||||
data: RFA;
|
||||
}
|
||||
|
||||
export function RFADetail({ data }: RFADetailProps) {
|
||||
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
|
||||
const [comments, setComments] = useState("");
|
||||
const [actionState, setActionState] = useState<'approve' | 'reject' | null>(null);
|
||||
const [comments, setComments] = useState('');
|
||||
const processMutation = useProcessRFA();
|
||||
const currentRevision = data.revisions.find((revision) => revision.isCurrent) ?? data.revisions[0];
|
||||
const currentItems = currentRevision?.items ?? [];
|
||||
const currentStatus = currentRevision?.statusCode?.statusName || currentRevision?.statusCode?.statusCode || "Unknown";
|
||||
const currentStatus = currentRevision?.statusCode?.statusName || currentRevision?.statusCode?.statusCode || 'Unknown';
|
||||
const createdAt = data.correspondence?.createdAt || currentRevision?.createdAt;
|
||||
|
||||
const getDrawingNumber = (item: RFAItem) =>
|
||||
item.shopDrawingRevision?.shopDrawing?.drawingNumber ||
|
||||
item.asBuiltDrawingRevision?.asBuiltDrawing?.drawingNumber ||
|
||||
"-";
|
||||
'-';
|
||||
|
||||
const getRevisionLabel = (item: RFAItem) => {
|
||||
if (item.shopDrawingRevision?.revisionLabel) {
|
||||
@@ -47,16 +47,16 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
return String(item.asBuiltDrawingRevision.revisionNumber);
|
||||
}
|
||||
|
||||
return "-";
|
||||
return '-';
|
||||
};
|
||||
|
||||
const getRevisionTitle = (item: RFAItem) =>
|
||||
item.shopDrawingRevision?.title || item.asBuiltDrawingRevision?.title || "-";
|
||||
item.shopDrawingRevision?.title || item.asBuiltDrawingRevision?.title || '-';
|
||||
|
||||
const handleProcess = () => {
|
||||
if (!actionState) return;
|
||||
|
||||
const apiAction = actionState === "approve" ? "APPROVE" : "REJECT";
|
||||
const apiAction = actionState === 'approve' ? 'APPROVE' : 'REJECT';
|
||||
|
||||
processMutation.mutate(
|
||||
{
|
||||
@@ -69,7 +69,7 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
{
|
||||
onSuccess: () => {
|
||||
setActionState(null);
|
||||
setComments("");
|
||||
setComments('');
|
||||
// Query invalidation handled in hook
|
||||
},
|
||||
}
|
||||
@@ -87,29 +87,24 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{data.correspondence?.correspondenceNumber || "RFA"}</h1>
|
||||
<h1 className="text-2xl font-bold">{data.correspondence?.correspondenceNumber || 'RFA'}</h1>
|
||||
{createdAt && (
|
||||
<p className="text-muted-foreground">
|
||||
Created on {format(new Date(createdAt), "dd MMM yyyy HH:mm")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Created on {format(new Date(createdAt), 'dd MMM yyyy HH:mm')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStatus === "PENDING" && (
|
||||
{currentStatus === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setActionState("reject")}
|
||||
onClick={() => setActionState('reject')}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={() => setActionState("approve")}
|
||||
>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white" onClick={() => setActionState('approve')}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
@@ -117,35 +112,37 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Input Area */}
|
||||
{actionState && (
|
||||
{/* Action Input Area */}
|
||||
{actionState && (
|
||||
<Card className="border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{actionState === "approve" ? "Confirm Approval" : "Confirm Rejection"}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-lg">
|
||||
{actionState === 'approve' ? 'Confirm Approval' : 'Confirm Rejection'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Comments</Label>
|
||||
<Textarea
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Enter comments..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setActionState(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant={actionState === "approve" ? "default" : "destructive"}
|
||||
onClick={handleProcess}
|
||||
disabled={processMutation.isPending}
|
||||
className={actionState === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm {actionState === "approve" ? "Approve" : "Reject"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Comments</Label>
|
||||
<Textarea
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Enter comments..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setActionState(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={actionState === 'approve' ? 'default' : 'destructive'}
|
||||
onClick={handleProcess}
|
||||
disabled={processMutation.isPending}
|
||||
className={actionState === 'approve' ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
>
|
||||
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm {actionState === 'approve' ? 'Approve' : 'Reject'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -156,7 +153,7 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl">{currentRevision?.subject || "Untitled RFA"}</CardTitle>
|
||||
<CardTitle className="text-xl">{currentRevision?.subject || 'Untitled RFA'}</CardTitle>
|
||||
<StatusBadge status={currentStatus} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -164,7 +161,7 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Description</h3>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">
|
||||
{currentRevision?.description || "No description provided."}
|
||||
{currentRevision?.description || 'No description provided.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -212,14 +209,14 @@ export function RFADetail({ data }: RFADetailProps) {
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Project</p>
|
||||
<p className="font-medium mt-1">{data.correspondence?.project?.projectName || "-"}</p>
|
||||
<p className="font-medium mt-1">{data.correspondence?.project?.projectName || '-'}</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 || data.discipline?.code || "-"}</p>
|
||||
<p className="font-medium mt-1">{data.discipline?.name || data.discipline?.code || '-'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+180
-188
@@ -1,42 +1,36 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useForm, type SubmitErrorHandler } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCreateRFA } from "@/hooks/use-rfa";
|
||||
import { useDrawings } from "@/hooks/use-drawing";
|
||||
import { useDisciplines, useContracts, useOrganizations } from "@/hooks/use-master-data";
|
||||
import { useCorrespondenceTypes, useRfaTypes } from "@/hooks/use-reference-data";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { CreateRfaDto } from "@/types/dto/rfa/rfa.dto";
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { correspondenceService } from "@/lib/services/correspondence.service";
|
||||
import { useForm, type SubmitErrorHandler } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCreateRFA } from '@/hooks/use-rfa';
|
||||
import { useDrawings } from '@/hooks/use-drawing';
|
||||
import { useDisciplines, useContracts, useOrganizations } from '@/hooks/use-master-data';
|
||||
import { useCorrespondenceTypes, useRfaTypes } from '@/hooks/use-reference-data';
|
||||
import { useProjects } from '@/hooks/use-projects';
|
||||
import { CreateRfaDto } from '@/types/dto/rfa/rfa.dto';
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { correspondenceService } from '@/lib/services/correspondence.service';
|
||||
|
||||
const rfaSchema = z.object({
|
||||
projectId: z.string().min(1, "Project is required"), // ADR-019: UUID
|
||||
contractId: z.string().min(1, "Contract is required"),
|
||||
disciplineId: z.number().min(1, "Discipline is required"),
|
||||
rfaTypeId: z.number().min(1, "Type is required"),
|
||||
subject: z.string().min(5, "Subject must be at least 5 characters"),
|
||||
projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID
|
||||
contractId: z.string().min(1, 'Contract is required'),
|
||||
disciplineId: z.number().min(1, 'Discipline is required'),
|
||||
rfaTypeId: z.number().min(1, 'Type is required'),
|
||||
subject: z.string().min(5, 'Subject must be at least 5 characters'),
|
||||
description: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
remarks: z.string().optional(),
|
||||
toOrganizationId: z.string().min(1, "Please select To Organization"),
|
||||
toOrganizationId: z.string().min(1, 'Please select To Organization'),
|
||||
dueDate: z.string().optional(),
|
||||
shopDrawingRevisionIds: z.array(z.string()).optional(),
|
||||
asBuiltDrawingRevisionIds: z.array(z.string()).optional(),
|
||||
@@ -110,7 +104,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
|
||||
return current as T[];
|
||||
}
|
||||
|
||||
if (!current || typeof current !== "object" || !("data" in current)) {
|
||||
if (!current || typeof current !== 'object' || !('data' in current)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -126,7 +120,7 @@ const dedupeByKey = <T,>(items: T[], getKey: (item: T) => string | number | unde
|
||||
return items.filter((item) => {
|
||||
const key = getKey(item);
|
||||
|
||||
if (key === undefined || key === "" || seen.has(key)) {
|
||||
if (key === undefined || key === '' || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -136,7 +130,7 @@ const dedupeByKey = <T,>(items: T[], getKey: (item: T) => string | number | unde
|
||||
};
|
||||
|
||||
const getOptionValue = (value?: string | number): string | undefined => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -148,10 +142,7 @@ export function RFAForm() {
|
||||
const createMutation = useCreateRFA();
|
||||
|
||||
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
|
||||
const projects = dedupeByKey(
|
||||
extractArrayData<ProjectOption>(projectsData),
|
||||
(project) => project.uuid ?? project.id
|
||||
);
|
||||
const projects = dedupeByKey(extractArrayData<ProjectOption>(projectsData), (project) => project.uuid ?? project.id);
|
||||
const { data: organizationsData, isLoading: isLoadingOrganizations } = useOrganizations({ isActive: true });
|
||||
const organizations = dedupeByKey(
|
||||
extractArrayData<OrganizationOption>(organizationsData),
|
||||
@@ -159,9 +150,7 @@ export function RFAForm() {
|
||||
);
|
||||
const { data: correspondenceTypesData } = useCorrespondenceTypes();
|
||||
const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData);
|
||||
const rfaCorrespondenceType = correspondenceTypes.find(
|
||||
(type) => type.typeCode?.toUpperCase() === "RFA"
|
||||
);
|
||||
const rfaCorrespondenceType = correspondenceTypes.find((type) => type.typeCode?.toUpperCase() === 'RFA');
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -174,37 +163,37 @@ export function RFAForm() {
|
||||
} = useForm<RFAFormData>({
|
||||
resolver: zodResolver(rfaSchema),
|
||||
defaultValues: {
|
||||
projectId: "",
|
||||
contractId: "",
|
||||
projectId: '',
|
||||
contractId: '',
|
||||
disciplineId: 0,
|
||||
rfaTypeId: 0,
|
||||
subject: "",
|
||||
description: "",
|
||||
body: "",
|
||||
remarks: "",
|
||||
toOrganizationId: "",
|
||||
dueDate: "",
|
||||
subject: '',
|
||||
description: '',
|
||||
body: '',
|
||||
remarks: '',
|
||||
toOrganizationId: '',
|
||||
dueDate: '',
|
||||
shopDrawingRevisionIds: [],
|
||||
asBuiltDrawingRevisionIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedProjectId = watch("projectId");
|
||||
const selectedProjectId = watch('projectId');
|
||||
const { data: contractsData, isLoading: isLoadingContracts } = useContracts(selectedProjectId);
|
||||
const contracts = dedupeByKey(
|
||||
extractArrayData<ContractOption>(contractsData),
|
||||
(contract) => contract.uuid ?? contract.id
|
||||
);
|
||||
|
||||
const selectedContractId = watch("contractId");
|
||||
const selectedContractId = watch('contractId');
|
||||
const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
|
||||
const disciplines = dedupeByKey(extractArrayData<DisciplineOption>(disciplinesData), (discipline) => discipline.id);
|
||||
const { data: rfaTypesData, isLoading: isLoadingRfaTypes } = useRfaTypes(selectedContractId);
|
||||
const rfaTypes = dedupeByKey(extractArrayData<RfaTypeOption>(rfaTypesData), (rfaType) => rfaType.id);
|
||||
const [shopDrawingSearch, setShopDrawingSearch] = useState("");
|
||||
const [shopDrawingSearch, setShopDrawingSearch] = useState('');
|
||||
const [shopDrawingPage, setShopDrawingPage] = useState(1);
|
||||
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings("SHOP", {
|
||||
projectUuid: selectedProjectId || "",
|
||||
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings('SHOP', {
|
||||
projectUuid: selectedProjectId || '',
|
||||
search: shopDrawingSearch,
|
||||
page: shopDrawingPage,
|
||||
limit: 10,
|
||||
@@ -214,10 +203,10 @@ export function RFAForm() {
|
||||
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
|
||||
);
|
||||
|
||||
const [asBuiltDrawingSearch, setAsBuiltDrawingSearch] = useState("");
|
||||
const [asBuiltDrawingSearch, setAsBuiltDrawingSearch] = useState('');
|
||||
const [asBuiltDrawingPage, setAsBuiltDrawingPage] = useState(1);
|
||||
const { data: asBuiltDrawingsData, isLoading: isLoadingAsBuiltDrawings } = useDrawings("AS_BUILT", {
|
||||
projectUuid: selectedProjectId || "",
|
||||
const { data: asBuiltDrawingsData, isLoading: isLoadingAsBuiltDrawings } = useDrawings('AS_BUILT', {
|
||||
projectUuid: selectedProjectId || '',
|
||||
search: asBuiltDrawingSearch,
|
||||
page: asBuiltDrawingPage,
|
||||
limit: 10,
|
||||
@@ -226,41 +215,41 @@ export function RFAForm() {
|
||||
extractArrayData<SelectableDrawingOption>(asBuiltDrawingsData),
|
||||
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
|
||||
);
|
||||
const selectedDisciplineId = watch("disciplineId");
|
||||
const selectedDisciplineId = watch('disciplineId');
|
||||
|
||||
const rfaTypeId = watch("rfaTypeId");
|
||||
const disciplineId = watch("disciplineId");
|
||||
const toOrganizationId = watch("toOrganizationId");
|
||||
const selectedShopDrawingRevisionIds = watch("shopDrawingRevisionIds") ?? [];
|
||||
const selectedAsBuiltDrawingRevisionIds = watch("asBuiltDrawingRevisionIds") ?? [];
|
||||
const rfaTypeId = watch('rfaTypeId');
|
||||
const disciplineId = watch('disciplineId');
|
||||
const toOrganizationId = watch('toOrganizationId');
|
||||
const selectedShopDrawingRevisionIds = watch('shopDrawingRevisionIds') ?? [];
|
||||
const selectedAsBuiltDrawingRevisionIds = watch('asBuiltDrawingRevisionIds') ?? [];
|
||||
const selectedRfaType = rfaTypes.find((rfaType) => rfaType.id === rfaTypeId);
|
||||
const selectedRfaTypeCode = selectedRfaType?.typeCode?.toUpperCase();
|
||||
const requiresShopDrawings = selectedRfaTypeCode === "DDW" || selectedRfaTypeCode === "SDW";
|
||||
const requiresAsBuiltDrawings = selectedRfaTypeCode === "ADW";
|
||||
const requiresShopDrawings = selectedRfaTypeCode === 'DDW' || selectedRfaTypeCode === 'SDW';
|
||||
const requiresAsBuiltDrawings = selectedRfaTypeCode === 'ADW';
|
||||
|
||||
useEffect(() => {
|
||||
// Reset page and search when project changes
|
||||
setShopDrawingPage(1);
|
||||
setShopDrawingSearch("");
|
||||
setShopDrawingSearch('');
|
||||
setAsBuiltDrawingPage(1);
|
||||
setAsBuiltDrawingSearch("");
|
||||
setAsBuiltDrawingSearch('');
|
||||
|
||||
if (requiresShopDrawings) {
|
||||
setValue("asBuiltDrawingRevisionIds", []);
|
||||
clearErrors("asBuiltDrawingRevisionIds");
|
||||
setValue('asBuiltDrawingRevisionIds', []);
|
||||
clearErrors('asBuiltDrawingRevisionIds');
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresAsBuiltDrawings) {
|
||||
setValue("shopDrawingRevisionIds", []);
|
||||
clearErrors("shopDrawingRevisionIds");
|
||||
setValue('shopDrawingRevisionIds', []);
|
||||
clearErrors('shopDrawingRevisionIds');
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("shopDrawingRevisionIds", []);
|
||||
setValue("asBuiltDrawingRevisionIds", []);
|
||||
clearErrors("shopDrawingRevisionIds");
|
||||
clearErrors("asBuiltDrawingRevisionIds");
|
||||
setValue('shopDrawingRevisionIds', []);
|
||||
setValue('asBuiltDrawingRevisionIds', []);
|
||||
clearErrors('shopDrawingRevisionIds');
|
||||
clearErrors('asBuiltDrawingRevisionIds');
|
||||
}, [requiresShopDrawings, requiresAsBuiltDrawings, selectedProjectId, setValue, clearErrors]);
|
||||
|
||||
// -- Preview Logic --
|
||||
@@ -268,23 +257,23 @@ export function RFAForm() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || !rfaCorrespondenceType?.id || !rfaTypeId || !disciplineId || !toOrganizationId) {
|
||||
setPreview(null);
|
||||
return;
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const res = await correspondenceService.previewNumber({
|
||||
projectId: selectedProjectId,
|
||||
typeId: rfaCorrespondenceType.id,
|
||||
disciplineId,
|
||||
recipients: [{ organizationId: toOrganizationId, type: 'TO' }],
|
||||
subject: watch("subject") || "Preview Subject"
|
||||
});
|
||||
setPreview(res);
|
||||
} catch (err) {
|
||||
setPreview(null);
|
||||
}
|
||||
try {
|
||||
const res = await correspondenceService.previewNumber({
|
||||
projectId: selectedProjectId,
|
||||
typeId: rfaCorrespondenceType.id,
|
||||
disciplineId,
|
||||
recipients: [{ organizationId: toOrganizationId, type: 'TO' }],
|
||||
subject: watch('subject') || 'Preview Subject',
|
||||
});
|
||||
setPreview(res);
|
||||
} catch (_err) {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(fetchPreview, 500);
|
||||
@@ -293,23 +282,23 @@ export function RFAForm() {
|
||||
|
||||
const onSubmit = (data: RFAFormData) => {
|
||||
if (requiresShopDrawings && data.shopDrawingRevisionIds?.length === 0) {
|
||||
setError("shopDrawingRevisionIds", {
|
||||
type: "manual",
|
||||
message: "Please select at least one Shop Drawing Revision",
|
||||
setError('shopDrawingRevisionIds', {
|
||||
type: 'manual',
|
||||
message: 'Please select at least one Shop Drawing Revision',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresAsBuiltDrawings && data.asBuiltDrawingRevisionIds?.length === 0) {
|
||||
setError("asBuiltDrawingRevisionIds", {
|
||||
type: "manual",
|
||||
message: "Please select at least one As-Built Drawing Revision",
|
||||
setError('asBuiltDrawingRevisionIds', {
|
||||
type: 'manual',
|
||||
message: 'Please select at least one As-Built Drawing Revision',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearErrors("shopDrawingRevisionIds");
|
||||
clearErrors("asBuiltDrawingRevisionIds");
|
||||
clearErrors('shopDrawingRevisionIds');
|
||||
clearErrors('asBuiltDrawingRevisionIds');
|
||||
|
||||
const payload: CreateRfaDto = {
|
||||
...data,
|
||||
@@ -318,7 +307,7 @@ export function RFAForm() {
|
||||
};
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
router.push("/rfas");
|
||||
router.push('/rfas');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -333,15 +322,15 @@ export function RFAForm() {
|
||||
<form onSubmit={handleFormSubmit} className="max-w-4xl space-y-6">
|
||||
{preview && (
|
||||
<Card className="p-4 bg-muted border-l-4 border-l-primary">
|
||||
<p className="text-sm text-muted-foreground mb-1">Document Number Preview</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl font-bold font-mono text-primary tracking-wide">{preview.number}</span>
|
||||
{preview.isDefaultTemplate && (
|
||||
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
Default Template
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-1">Document Number Preview</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl font-bold font-mono text-primary tracking-wide">{preview.number}</span>
|
||||
{preview.isDefaultTemplate && (
|
||||
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
Default Template
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -351,27 +340,23 @@ export function RFAForm() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
|
||||
{errors.subject && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.subject.message}
|
||||
</p>
|
||||
)}
|
||||
<Input id="subject" {...register('subject')} placeholder="Enter subject" />
|
||||
{errors.subject && <p className="text-sm text-destructive mt-1">{errors.subject.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="body">Body (Content)</Label>
|
||||
<Textarea id="body" {...register("body")} rows={4} placeholder="Enter content..." />
|
||||
<Label htmlFor="body">Body (Content)</Label>
|
||||
<Textarea id="body" {...register('body')} rows={4} placeholder="Enter content..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="remarks">Remarks</Label>
|
||||
<Input id="remarks" {...register("remarks")} placeholder="Optional remarks" />
|
||||
<div>
|
||||
<Label htmlFor="remarks">Remarks</Label>
|
||||
<Input id="remarks" {...register('remarks')} placeholder="Optional remarks" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input id="description" {...register("description")} placeholder="Enter key description" />
|
||||
<Input id="description" {...register('description')} placeholder="Enter key description" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -379,17 +364,17 @@ export function RFAForm() {
|
||||
<Select
|
||||
value={selectedProjectId || undefined}
|
||||
onValueChange={(val) => {
|
||||
setValue("projectId", val);
|
||||
setValue("contractId", "");
|
||||
setValue("disciplineId", 0);
|
||||
setValue("rfaTypeId", 0);
|
||||
setValue("shopDrawingRevisionIds", []);
|
||||
setValue("asBuiltDrawingRevisionIds", []);
|
||||
setValue('projectId', val);
|
||||
setValue('contractId', '');
|
||||
setValue('disciplineId', 0);
|
||||
setValue('rfaTypeId', 0);
|
||||
setValue('shopDrawingRevisionIds', []);
|
||||
setValue('asBuiltDrawingRevisionIds', []);
|
||||
}}
|
||||
disabled={isLoadingProjects}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
|
||||
<SelectValue placeholder={isLoadingProjects ? 'Loading...' : 'Select Project'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((p) => {
|
||||
@@ -400,16 +385,14 @@ export function RFAForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectItem key={projectValue} value={projectValue}>
|
||||
{p.projectName || p.projectCode}
|
||||
</SelectItem>
|
||||
<SelectItem key={projectValue} value={projectValue}>
|
||||
{p.projectName || p.projectCode}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.projectId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.projectId.message}</p>
|
||||
)}
|
||||
{errors.projectId && <p className="text-sm text-destructive mt-1">{errors.projectId.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -418,16 +401,16 @@ export function RFAForm() {
|
||||
<Select
|
||||
value={selectedContractId || undefined}
|
||||
onValueChange={(val) => {
|
||||
setValue("contractId", val);
|
||||
setValue("disciplineId", 0);
|
||||
setValue("rfaTypeId", 0);
|
||||
setValue("shopDrawingRevisionIds", []);
|
||||
setValue("asBuiltDrawingRevisionIds", []);
|
||||
setValue('contractId', val);
|
||||
setValue('disciplineId', 0);
|
||||
setValue('rfaTypeId', 0);
|
||||
setValue('shopDrawingRevisionIds', []);
|
||||
setValue('asBuiltDrawingRevisionIds', []);
|
||||
}}
|
||||
disabled={!selectedProjectId || isLoadingContracts}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingContracts ? "Loading..." : "Select Contract"} />
|
||||
<SelectValue placeholder={isLoadingContracts ? 'Loading...' : 'Select Contract'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contracts.map((c) => {
|
||||
@@ -438,27 +421,25 @@ export function RFAForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectItem key={contractValue} value={contractValue}>
|
||||
{c.contractName || c.name || c.contractCode}
|
||||
</SelectItem>
|
||||
<SelectItem key={contractValue} value={contractValue}>
|
||||
{c.contractName || c.name || c.contractCode}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.contractId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.contractId.message}</p>
|
||||
)}
|
||||
{errors.contractId && <p className="text-sm text-destructive mt-1">{errors.contractId.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Discipline *</Label>
|
||||
<Select
|
||||
value={selectedDisciplineId > 0 ? String(selectedDisciplineId) : undefined}
|
||||
onValueChange={(val) => setValue("disciplineId", Number(val))}
|
||||
onValueChange={(val) => setValue('disciplineId', Number(val))}
|
||||
disabled={!selectedContractId || isLoadingDisciplines}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} />
|
||||
<SelectValue placeholder={isLoadingDisciplines ? 'Loading...' : 'Select Discipline'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{disciplines.map((d) => (
|
||||
@@ -467,13 +448,13 @@ export function RFAForm() {
|
||||
</SelectItem>
|
||||
))}
|
||||
{!isLoadingDisciplines && disciplines.length === 0 && (
|
||||
<SelectItem value="0" disabled>No disciplines found</SelectItem>
|
||||
<SelectItem value="0" disabled>
|
||||
No disciplines found
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.disciplineId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.disciplineId.message}</p>
|
||||
)}
|
||||
{errors.disciplineId && <p className="text-sm text-destructive mt-1">{errors.disciplineId.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -483,37 +464,35 @@ export function RFAForm() {
|
||||
<Select
|
||||
value={rfaTypeId > 0 ? String(rfaTypeId) : undefined}
|
||||
onValueChange={(val) => {
|
||||
setValue("rfaTypeId", Number(val));
|
||||
setValue("shopDrawingRevisionIds", []);
|
||||
setValue("asBuiltDrawingRevisionIds", []);
|
||||
setValue('rfaTypeId', Number(val));
|
||||
setValue('shopDrawingRevisionIds', []);
|
||||
setValue('asBuiltDrawingRevisionIds', []);
|
||||
}}
|
||||
disabled={!selectedContractId || isLoadingRfaTypes}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingRfaTypes ? "Loading..." : "Select RFA Type"} />
|
||||
<SelectValue placeholder={isLoadingRfaTypes ? 'Loading...' : 'Select RFA Type'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rfaTypes.map((rfaType) => (
|
||||
<SelectItem key={rfaType.id} value={String(rfaType.id)}>
|
||||
{`${rfaType.typeCode || "RFA"} - ${rfaType.typeName || rfaType.typeNameEn || rfaType.typeNameTh || "Unnamed Type"}`}
|
||||
{`${rfaType.typeCode || 'RFA'} - ${rfaType.typeName || rfaType.typeNameEn || rfaType.typeNameTh || 'Unnamed Type'}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.rfaTypeId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.rfaTypeId.message}</p>
|
||||
)}
|
||||
{errors.rfaTypeId && <p className="text-sm text-destructive mt-1">{errors.rfaTypeId.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>To Organization *</Label>
|
||||
<Select
|
||||
value={toOrganizationId || undefined}
|
||||
onValueChange={(val) => setValue("toOrganizationId", val)}
|
||||
onValueChange={(val) => setValue('toOrganizationId', val)}
|
||||
disabled={isLoadingOrganizations}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingOrganizations ? "Loading..." : "Select To Organization"} />
|
||||
<SelectValue placeholder={isLoadingOrganizations ? 'Loading...' : 'Select To Organization'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((organization) => {
|
||||
@@ -524,9 +503,9 @@ export function RFAForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectItem key={organizationValue} value={organizationValue}>
|
||||
{`${organization.organizationCode || "ORG"} - ${organization.organizationName || "Unnamed Organization"}`}
|
||||
</SelectItem>
|
||||
<SelectItem key={organizationValue} value={organizationValue}>
|
||||
{`${organization.organizationCode || 'ORG'} - ${organization.organizationName || 'Unnamed Organization'}`}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
@@ -545,8 +524,8 @@ export function RFAForm() {
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{requiresShopDrawings
|
||||
? "RFA Type นี้ต้องอ้างอิง Shop Drawing Revision อย่างน้อย 1 รายการ"
|
||||
: "RFA Type นี้ต้องอ้างอิง As-Built Drawing Revision อย่างน้อย 1 รายการ"}
|
||||
? 'RFA Type นี้ต้องอ้างอิง Shop Drawing Revision อย่างน้อย 1 รายการ'
|
||||
: 'RFA Type นี้ต้องอ้างอิง As-Built Drawing Revision อย่างน้อย 1 รายการ'}
|
||||
</p>
|
||||
|
||||
{requiresShopDrawings && (
|
||||
@@ -563,9 +542,7 @@ export function RFAForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingShopDrawings && (
|
||||
<p className="text-sm text-muted-foreground">Loading Shop Drawings...</p>
|
||||
)}
|
||||
{isLoadingShopDrawings && <p className="text-sm text-muted-foreground">Loading Shop Drawings...</p>}
|
||||
{!isLoadingShopDrawings && shopDrawings.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No Shop Drawings found for the selected project.</p>
|
||||
)}
|
||||
@@ -585,19 +562,25 @@ export function RFAForm() {
|
||||
<Checkbox
|
||||
checked={selectedShopDrawingRevisionIds.includes(revisionUuid)}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextValues = checked === true
|
||||
? [...selectedShopDrawingRevisionIds, revisionUuid]
|
||||
: selectedShopDrawingRevisionIds.filter((value) => value !== revisionUuid);
|
||||
setValue("shopDrawingRevisionIds", nextValues, { shouldDirty: true, shouldValidate: true });
|
||||
clearErrors("shopDrawingRevisionIds");
|
||||
const nextValues =
|
||||
checked === true
|
||||
? [...selectedShopDrawingRevisionIds, revisionUuid]
|
||||
: selectedShopDrawingRevisionIds.filter((value) => value !== revisionUuid);
|
||||
setValue('shopDrawingRevisionIds', nextValues, { shouldDirty: true, shouldValidate: true });
|
||||
clearErrors('shopDrawingRevisionIds');
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{drawing.drawingNumber || "Unnamed Shop Drawing"}</p>
|
||||
<p className="text-sm text-muted-foreground">{drawing.currentRevision?.title || drawing.title || "Untitled Revision"}</p>
|
||||
<p className="font-medium">{drawing.drawingNumber || 'Unnamed Shop Drawing'}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{drawing.currentRevision?.title || drawing.title || 'Untitled Revision'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Revision {drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || "-"}
|
||||
{drawing.currentRevision?.legacyDrawingNumber ? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}` : ""}
|
||||
Revision{' '}
|
||||
{drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || '-'}
|
||||
{drawing.currentRevision?.legacyDrawingNumber
|
||||
? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -675,19 +658,28 @@ export function RFAForm() {
|
||||
<Checkbox
|
||||
checked={selectedAsBuiltDrawingRevisionIds.includes(revisionUuid)}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextValues = checked === true
|
||||
? [...selectedAsBuiltDrawingRevisionIds, revisionUuid]
|
||||
: selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionUuid);
|
||||
setValue("asBuiltDrawingRevisionIds", nextValues, { shouldDirty: true, shouldValidate: true });
|
||||
clearErrors("asBuiltDrawingRevisionIds");
|
||||
const nextValues =
|
||||
checked === true
|
||||
? [...selectedAsBuiltDrawingRevisionIds, revisionUuid]
|
||||
: selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionUuid);
|
||||
setValue('asBuiltDrawingRevisionIds', nextValues, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
clearErrors('asBuiltDrawingRevisionIds');
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{drawing.drawingNumber || "Unnamed As-Built Drawing"}</p>
|
||||
<p className="text-sm text-muted-foreground">{drawing.currentRevision?.title || drawing.title || "Untitled Revision"}</p>
|
||||
<p className="font-medium">{drawing.drawingNumber || 'Unnamed As-Built Drawing'}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{drawing.currentRevision?.title || drawing.title || 'Untitled Revision'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Revision {drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || "-"}
|
||||
{drawing.currentRevision?.legacyDrawingNumber ? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}` : ""}
|
||||
Revision{' '}
|
||||
{drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || '-'}
|
||||
{drawing.currentRevision?.legacyDrawingNumber
|
||||
? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { RFA } from "@/types/rfa";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Edit, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { RFA } from '@/types/rfa';
|
||||
import { DataTable } from '@/components/common/data-table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { StatusBadge } from '@/components/common/status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Eye, Edit, FileText } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface RFAListProps {
|
||||
data: RFA[];
|
||||
@@ -18,15 +18,15 @@ export function RFAList({ data }: RFAListProps) {
|
||||
|
||||
const columns: ColumnDef<RFA>[] = [
|
||||
{
|
||||
accessorKey: "rfa_number",
|
||||
header: "RFA No.",
|
||||
accessorKey: 'rfa_number',
|
||||
header: 'RFA No.',
|
||||
cell: ({ row }) => {
|
||||
return <span className="font-medium">{row.original.correspondence?.correspondenceNumber || '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
accessorKey: 'subject',
|
||||
header: 'Subject',
|
||||
cell: ({ row }) => {
|
||||
const rev = row.original.revisions?.[0];
|
||||
return (
|
||||
@@ -37,56 +37,56 @@ export function RFAList({ data }: RFAListProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "contract_name", // AccessorKey can be anything if we provide cell
|
||||
header: "Contract",
|
||||
accessorKey: 'contract_name', // AccessorKey can be anything if we provide cell
|
||||
header: 'Contract',
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.correspondence?.project?.projectName || '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "discipline_name",
|
||||
header: "Discipline",
|
||||
accessorKey: 'discipline_name',
|
||||
header: 'Discipline',
|
||||
cell: ({ row }) => <span>{row.original.discipline?.name || '-'}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.correspondence?.createdAt || row.original.revisions?.[0]?.createdAt; // Fallback or strict?
|
||||
// In backend I set RFA -> Correspondence (createdAt is in Correspondence base)
|
||||
// But RFA revision also has createdAt?
|
||||
// Use correspondence.createdAt usually for document date.
|
||||
return date ? format(new Date(date), "dd MMM yyyy") : '-';
|
||||
const date = row.original.correspondence?.createdAt || row.original.revisions?.[0]?.createdAt; // Fallback or strict?
|
||||
// In backend I set RFA -> Correspondence (createdAt is in Correspondence base)
|
||||
// But RFA revision also has createdAt?
|
||||
// Use correspondence.createdAt usually for document date.
|
||||
return date ? format(new Date(date), 'dd MMM yyyy') : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.revisions?.[0]?.statusCode?.statusName || row.original.revisions?.[0]?.statusCode?.statusCode;
|
||||
const status =
|
||||
row.original.revisions?.[0]?.statusCode?.statusName || row.original.revisions?.[0]?.statusCode?.statusCode;
|
||||
return <StatusBadge status={status || 'Unknown'} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
|
||||
const handleViewFile = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const firstItem = item.revisions?.[0]?.items?.[0];
|
||||
const firstAttachment =
|
||||
firstItem?.shopDrawingRevision?.attachments?.[0] ||
|
||||
firstItem?.asBuiltDrawingRevision?.attachments?.[0];
|
||||
if (firstAttachment?.url) {
|
||||
window.open(firstAttachment.url, '_blank');
|
||||
} else {
|
||||
// Use alert or toast. Assuming toast is available or use generic alert for now if toast not imported
|
||||
// But rfa.service.ts in use-rfa.ts uses 'sonner', so 'sonner' is likely available.
|
||||
// I will try to use toast from 'sonner' if I import it, or just window.alert for safety.
|
||||
// User said "หน้าต่างแจ้งเตือน" -> Alert window.
|
||||
alert("ไม่พบไฟล์แนบ (No file attached)");
|
||||
}
|
||||
e.preventDefault();
|
||||
const firstItem = item.revisions?.[0]?.items?.[0];
|
||||
const firstAttachment =
|
||||
firstItem?.shopDrawingRevision?.attachments?.[0] || firstItem?.asBuiltDrawingRevision?.attachments?.[0];
|
||||
if (firstAttachment?.url) {
|
||||
window.open(firstAttachment.url, '_blank');
|
||||
} else {
|
||||
// Use alert or toast. Assuming toast is available or use generic alert for now if toast not imported
|
||||
// But rfa.service.ts in use-rfa.ts uses 'sonner', so 'sonner' is likely available.
|
||||
// I will try to use toast from 'sonner' if I import it, or just window.alert for safety.
|
||||
// User said "หน้าต่างแจ้งเตือน" -> Alert window.
|
||||
alert('ไม่พบไฟล์แนบ (No file attached)');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -96,11 +96,11 @@ export function RFAList({ data }: RFAListProps) {
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" title="View File" onClick={handleViewFile}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="View File" onClick={handleViewFile}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link href={`/rfas/${row.original.uuid}/edit`}>
|
||||
<Button variant="ghost" size="icon" title="Edit">
|
||||
<Button variant="ghost" size="icon" title="Edit">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchFilters as FilterType } from "@/types/search";
|
||||
import { useState } from "react";
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SearchFilters as FilterType } from '@/types/search';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
onFilterChange: (filters: FilterType) => void;
|
||||
@@ -19,9 +19,7 @@ export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
|
||||
|
||||
const handleTypeChange = (type: string, checked: boolean) => {
|
||||
const currentTypes = filters.types || [];
|
||||
const newTypes = checked
|
||||
? [...currentTypes, type]
|
||||
: currentTypes.filter((t) => t !== type);
|
||||
const newTypes = checked ? [...currentTypes, type] : currentTypes.filter((t) => t !== type);
|
||||
|
||||
const newFilters = { ...filters, types: newTypes };
|
||||
setFilters(newFilters);
|
||||
@@ -30,9 +28,7 @@ export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
|
||||
|
||||
const handleStatusChange = (status: string, checked: boolean) => {
|
||||
const currentStatuses = filters.statuses || [];
|
||||
const newStatuses = checked
|
||||
? [...currentStatuses, status]
|
||||
: currentStatuses.filter((s) => s !== status);
|
||||
const newStatuses = checked ? [...currentStatuses, status] : currentStatuses.filter((s) => s !== status);
|
||||
|
||||
const newFilters = { ...filters, statuses: newStatuses };
|
||||
setFilters(newFilters);
|
||||
@@ -50,7 +46,7 @@ export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Document Type</h3>
|
||||
<div className="space-y-2">
|
||||
{["correspondence", "rfa", "drawing"].map((type) => (
|
||||
{['correspondence', 'rfa', 'drawing'].map((type) => (
|
||||
<div key={type} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`type-${type}`}
|
||||
@@ -68,7 +64,7 @@ export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Status</h3>
|
||||
<div className="space-y-2">
|
||||
{["DRAFT", "PENDING", "APPROVED", "REJECTED", "IN_REVIEW"].map((status) => (
|
||||
{['DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'IN_REVIEW'].map((status) => (
|
||||
<div key={status} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`status-${status}`}
|
||||
@@ -76,18 +72,14 @@ export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
|
||||
onCheckedChange={(checked) => handleStatusChange(status, checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor={`status-${status}`} className="text-sm capitalize">
|
||||
{status.replace("_", " ").toLowerCase()}
|
||||
{status.replace('_', ' ').toLowerCase()}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<Button variant="outline" className="w-full" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { FileText, Clipboard, Image, Loader2 } from "lucide-react";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { format } from "date-fns";
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Link from 'next/link';
|
||||
import { FileText, Clipboard, Image, Loader2 } from 'lucide-react';
|
||||
import { SearchResult } from '@/types/search';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: SearchResult[];
|
||||
@@ -25,18 +25,18 @@ export function SearchResults({ results, query, loading }: SearchResultsProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 text-center text-muted-foreground">
|
||||
{query ? `No results found for "${query}"` : "Enter a search term to start"}
|
||||
{query ? `No results found for "${query}"` : 'Enter a search term to start'}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "correspondence":
|
||||
case 'correspondence':
|
||||
return FileText;
|
||||
case "rfa":
|
||||
case 'rfa':
|
||||
return Clipboard;
|
||||
case "drawing":
|
||||
case 'drawing':
|
||||
return Image;
|
||||
default:
|
||||
return FileText;
|
||||
@@ -53,10 +53,7 @@ export function SearchResults({ results, query, loading }: SearchResultsProps) {
|
||||
const Icon = getIcon(result.type);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${result.type}-${result.uuid}-${index}`}
|
||||
className="p-6 hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<Card key={`${result.type}-${result.uuid}-${index}`} className="p-6 hover:shadow-md transition-shadow group">
|
||||
<Link href={getLink(result)}>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
@@ -68,23 +65,21 @@ export function SearchResults({ results, query, loading }: SearchResultsProps) {
|
||||
<h3
|
||||
className="text-lg font-semibold group-hover:text-primary transition-colors"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: result.highlight || result.title
|
||||
__html: result.highlight || result.title,
|
||||
}}
|
||||
/>
|
||||
<Badge variant="secondary" className="capitalize">{result.type}</Badge>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{result.type}
|
||||
</Badge>
|
||||
<Badge variant="outline">{result.status}</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">
|
||||
{result.description}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{result.description}</p>
|
||||
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{result.documentNumber}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{format(new Date(result.createdAt), "dd MMM yyyy")}
|
||||
</span>
|
||||
<span>{format(new Date(result.createdAt), 'dd MMM yyyy')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,59 +1,35 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { transmittalService } from "@/lib/services/transmittal.service";
|
||||
import { correspondenceService } from "@/lib/services/correspondence.service";
|
||||
import { projectService } from "@/lib/services/project.service";
|
||||
import { organizationService } from "@/lib/services/organization.service";
|
||||
import { CreateTransmittalDto } from "@/types/dto/transmittal/transmittal.dto";
|
||||
import { transmittalService } from '@/lib/services/transmittal.service';
|
||||
import { correspondenceService } from '@/lib/services/correspondence.service';
|
||||
import { projectService } from '@/lib/services/project.service';
|
||||
import { organizationService } from '@/lib/services/organization.service';
|
||||
import { CreateTransmittalDto } from '@/types/dto/transmittal/transmittal.dto';
|
||||
|
||||
// UI Components
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Check, ChevronsUpDown, Trash2, Plus, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Schema for items
|
||||
const itemSchema = z.object({
|
||||
itemType: z.enum(["DRAWING", "RFA", "CORRESPONDENCE"]),
|
||||
itemId: z.coerce.number().min(1, "Document ID is required"),
|
||||
itemType: z.enum(['DRAWING', 'RFA', 'CORRESPONDENCE']),
|
||||
itemId: z.coerce.number().min(1, 'Document ID is required'),
|
||||
description: z.string().optional(),
|
||||
// Virtual fields for UI display
|
||||
documentNumber: z.string().optional(),
|
||||
@@ -61,13 +37,13 @@ const itemSchema = z.object({
|
||||
|
||||
// Main form schema
|
||||
const formSchema = z.object({
|
||||
projectId: z.string().min(1, "Project is required"), // ADR-019: UUID
|
||||
recipientOrganizationId: z.string().min(1, "Recipient is required"), // ADR-019: UUID
|
||||
correspondenceId: z.string().min(1, "Correspondence is required"), // ADR-019: UUID string
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
purpose: z.enum(["FOR_APPROVAL", "FOR_INFORMATION", "FOR_REVIEW", "OTHER"]),
|
||||
projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID
|
||||
recipientOrganizationId: z.string().min(1, 'Recipient is required'), // ADR-019: UUID
|
||||
correspondenceId: z.string().min(1, 'Correspondence is required'), // ADR-019: UUID string
|
||||
subject: z.string().min(1, 'Subject is required'),
|
||||
purpose: z.enum(['FOR_APPROVAL', 'FOR_INFORMATION', 'FOR_REVIEW', 'OTHER']),
|
||||
remarks: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, "At least one item is required"),
|
||||
items: z.array(itemSchema).min(1, 'At least one item is required'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -79,78 +55,74 @@ export function TransmittalForm() {
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
|
||||
defaultValues: {
|
||||
projectId: "",
|
||||
recipientOrganizationId: "",
|
||||
correspondenceId: "",
|
||||
subject: "",
|
||||
purpose: "FOR_APPROVAL",
|
||||
remarks: "",
|
||||
items: [
|
||||
{ itemType: "DRAWING", itemId: 0, description: "" },
|
||||
],
|
||||
projectId: '',
|
||||
recipientOrganizationId: '',
|
||||
correspondenceId: '',
|
||||
subject: '',
|
||||
purpose: 'FOR_APPROVAL',
|
||||
remarks: '',
|
||||
items: [{ itemType: 'DRAWING', itemId: 0, description: '' }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "items",
|
||||
name: 'items',
|
||||
});
|
||||
|
||||
// ADR-019: Fetch projects and organizations for UUID-based selectors
|
||||
const { data: projectsData, isLoading: isLoadingProjects } = useQuery({
|
||||
queryKey: ["projects-dropdown"],
|
||||
queryKey: ['projects-dropdown'],
|
||||
queryFn: () => projectService.getAll(),
|
||||
});
|
||||
const projectsList = projectsData?.data || projectsData || [];
|
||||
|
||||
const { data: orgsData, isLoading: isLoadingOrgs } = useQuery({
|
||||
queryKey: ["organizations-dropdown"],
|
||||
queryKey: ['organizations-dropdown'],
|
||||
queryFn: () => organizationService.getAll(),
|
||||
});
|
||||
const orgsList = orgsData?.data || orgsData || [];
|
||||
|
||||
// Fetch correspondences (for header linkage)
|
||||
const { data: correspondences } = useQuery({
|
||||
queryKey: ["correspondences-dropdown"],
|
||||
queryKey: ['correspondences-dropdown'],
|
||||
queryFn: () => correspondenceService.getAll({ limit: 50 }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateTransmittalDto) => transmittalService.create(data),
|
||||
onSuccess: (result) => {
|
||||
toast.success("Transmittal created successfully");
|
||||
toast.success('Transmittal created successfully');
|
||||
// ADR-019: Navigate using UUID from correspondence
|
||||
router.push(`/transmittals/${result.correspondence?.uuid || result.uuid}`);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to create transmittal");
|
||||
toast.error('Failed to create transmittal');
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
// ADR-019: All IDs are now UUID strings from the form
|
||||
const cleanPayload: CreateTransmittalDto = {
|
||||
projectId: data.projectId,
|
||||
recipientOrganizationId: data.recipientOrganizationId,
|
||||
correspondenceId: data.correspondenceId,
|
||||
subject: data.subject,
|
||||
purpose: data.purpose as string,
|
||||
remarks: data.remarks,
|
||||
items: data.items.map(item => ({
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
description: item.description
|
||||
}))
|
||||
projectId: data.projectId,
|
||||
recipientOrganizationId: data.recipientOrganizationId,
|
||||
correspondenceId: data.correspondenceId,
|
||||
subject: data.subject,
|
||||
purpose: data.purpose as string,
|
||||
remarks: data.remarks,
|
||||
items: data.items.map((item) => ({
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
description: item.description,
|
||||
})),
|
||||
};
|
||||
|
||||
createMutation.mutate(cleanPayload);
|
||||
};
|
||||
|
||||
const selectedDocId = form.watch("correspondenceId");
|
||||
const selectedDocId = form.watch('correspondenceId');
|
||||
const correspondenceList = correspondences?.data || [];
|
||||
const selectedDoc = correspondenceList.find(
|
||||
(c: { uuid: string }) => c.uuid === selectedDocId
|
||||
);
|
||||
const selectedDoc = correspondenceList.find((c: { uuid: string }) => c.uuid === selectedDocId);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -172,15 +144,17 @@ export function TransmittalForm() {
|
||||
<Select onValueChange={field.onChange} value={field.value} disabled={isLoadingProjects}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
|
||||
<SelectValue placeholder={isLoadingProjects ? 'Loading...' : 'Select Project'} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(Array.isArray(projectsList) ? projectsList : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
|
||||
<SelectItem key={p.uuid} value={p.uuid}>
|
||||
{p.projectName || p.projectCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(Array.isArray(projectsList) ? projectsList : []).map(
|
||||
(p: { uuid: string; projectName?: string; projectCode?: string }) => (
|
||||
<SelectItem key={p.uuid} value={p.uuid}>
|
||||
{p.projectName || p.projectCode}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -197,15 +171,17 @@ export function TransmittalForm() {
|
||||
<Select onValueChange={field.onChange} value={field.value} disabled={isLoadingOrgs}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
|
||||
<SelectValue placeholder={isLoadingOrgs ? 'Loading...' : 'Select Organization'} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(Array.isArray(orgsList) ? orgsList : []).map((o: { uuid: string; organizationName?: string; organizationCode?: string }) => (
|
||||
<SelectItem key={o.uuid} value={o.uuid}>
|
||||
{o.organizationName || o.organizationCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(Array.isArray(orgsList) ? orgsList : []).map(
|
||||
(o: { uuid: string; organizationName?: string; organizationCode?: string }) => (
|
||||
<SelectItem key={o.uuid} value={o.uuid}>
|
||||
{o.organizationName || o.organizationCode}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -228,14 +204,11 @@ export function TransmittalForm() {
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
className={cn('justify-between', !field.value && 'text-muted-foreground')}
|
||||
>
|
||||
{selectedDoc
|
||||
? (selectedDoc as { correspondenceNumber?: string }).correspondenceNumber || 'Selected'
|
||||
: "Select reference..."}
|
||||
: 'Select reference...'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -246,28 +219,24 @@ export function TransmittalForm() {
|
||||
<CommandList>
|
||||
<CommandEmpty>No document found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{correspondenceList.map(
|
||||
(doc: { uuid: string; correspondenceNumber?: string }) => (
|
||||
<CommandItem
|
||||
key={doc.uuid}
|
||||
value={doc.correspondenceNumber || doc.uuid}
|
||||
onSelect={() => {
|
||||
form.setValue("correspondenceId", doc.uuid);
|
||||
setDocOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
doc.uuid === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{doc.correspondenceNumber || doc.uuid}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
{correspondenceList.map((doc: { uuid: string; correspondenceNumber?: string }) => (
|
||||
<CommandItem
|
||||
key={doc.uuid}
|
||||
value={doc.correspondenceNumber || doc.uuid}
|
||||
onSelect={() => {
|
||||
form.setValue('correspondenceId', doc.uuid);
|
||||
setDocOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
doc.uuid === field.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{doc.correspondenceNumber || doc.uuid}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -285,10 +254,7 @@ export function TransmittalForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Purpose</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select purpose" />
|
||||
@@ -296,9 +262,7 @@ export function TransmittalForm() {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="FOR_APPROVAL">For Approval</SelectItem>
|
||||
<SelectItem value="FOR_INFORMATION">
|
||||
For Information
|
||||
</SelectItem>
|
||||
<SelectItem value="FOR_INFORMATION">For Information</SelectItem>
|
||||
<SelectItem value="FOR_REVIEW">For Review</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -332,11 +296,7 @@ export function TransmittalForm() {
|
||||
<FormItem>
|
||||
<FormLabel>Remarks (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Additional notes..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
<Textarea placeholder="Additional notes..." className="resize-none" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -354,12 +314,15 @@ export function TransmittalForm() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
itemType: "DRAWING",
|
||||
itemId: 0,
|
||||
description: "",
|
||||
documentNumber: "",
|
||||
}, { focusIndex: fields.length })
|
||||
append(
|
||||
{
|
||||
itemType: 'DRAWING',
|
||||
itemId: 0,
|
||||
description: '',
|
||||
documentNumber: '',
|
||||
},
|
||||
{ focusIndex: fields.length }
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
@@ -369,10 +332,7 @@ export function TransmittalForm() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-12 gap-4 items-end border p-4 rounded-lg bg-muted/20"
|
||||
>
|
||||
<div key={field.id} className="grid grid-cols-12 gap-4 items-end border p-4 rounded-lg bg-muted/20">
|
||||
{/* Item Type */}
|
||||
<div className="col-span-3">
|
||||
<FormField
|
||||
@@ -381,10 +341,7 @@ export function TransmittalForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -393,9 +350,7 @@ export function TransmittalForm() {
|
||||
<SelectContent>
|
||||
<SelectItem value="DRAWING">Drawing</SelectItem>
|
||||
<SelectItem value="RFA">RFA</SelectItem>
|
||||
<SelectItem value="CORRESPONDENCE">
|
||||
Correspondence
|
||||
</SelectItem>
|
||||
<SelectItem value="CORRESPONDENCE">Correspondence</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
@@ -416,9 +371,7 @@ export function TransmittalForm() {
|
||||
type="number"
|
||||
placeholder="ID"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
{/* In real app, this would be another AsyncSelect/Combobox */}
|
||||
@@ -458,25 +411,17 @@ export function TransmittalForm() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage>
|
||||
{form.formState.errors.items?.root?.message}
|
||||
</FormMessage>
|
||||
<FormMessage>{form.formState.errors.items?.root?.message}</FormMessage>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Transmittal
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Transmittal } from "@/types/transmittal";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Transmittal } from '@/types/transmittal';
|
||||
import { DataTable } from '@/components/common/data-table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Eye } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface TransmittalListProps {
|
||||
data: Transmittal[];
|
||||
@@ -18,18 +18,18 @@ export function TransmittalList({ data }: TransmittalListProps) {
|
||||
|
||||
const columns: ColumnDef<Transmittal>[] = [
|
||||
{
|
||||
id: "transmittalNo",
|
||||
header: "Transmittal No.",
|
||||
id: 'transmittalNo',
|
||||
header: 'Transmittal No.',
|
||||
cell: ({ row }) => {
|
||||
const no = row.original.correspondence?.correspondenceNumber || row.original.transmittalNo || '-';
|
||||
return <span className="font-medium">{no}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject",
|
||||
id: 'subject',
|
||||
header: 'Subject',
|
||||
cell: ({ row }) => {
|
||||
const currentRev = row.original.correspondence?.revisions?.find(r => r.isCurrent);
|
||||
const currentRev = row.original.correspondence?.revisions?.find((r) => r.isCurrent);
|
||||
const subject = currentRev?.title || row.original.subject || '-';
|
||||
return (
|
||||
<div className="max-w-[300px] truncate" title={subject}>
|
||||
@@ -39,31 +39,29 @@ export function TransmittalList({ data }: TransmittalListProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "purpose",
|
||||
header: "Purpose",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">{row.getValue("purpose") || "OTHER"}</Badge>
|
||||
),
|
||||
accessorKey: 'purpose',
|
||||
header: 'Purpose',
|
||||
cell: ({ row }) => <Badge variant="outline">{row.getValue('purpose') || 'OTHER'}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: "items",
|
||||
header: "Items",
|
||||
accessorKey: 'items',
|
||||
header: 'Items',
|
||||
cell: ({ row }) => {
|
||||
const items = row.original.items || [];
|
||||
return <span>{items.length} items</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: "Date",
|
||||
id: 'createdAt',
|
||||
header: 'Date',
|
||||
cell: ({ row }) => {
|
||||
const dateStr = row.original.correspondence?.createdAt || row.original.createdAt;
|
||||
if (!dateStr) return '-';
|
||||
return format(new Date(dateStr), "dd MMM yyyy");
|
||||
return format(new Date(dateStr), 'dd MMM yyyy');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
|
||||
@@ -101,7 +101,11 @@ describe('Button', () => {
|
||||
|
||||
it('should not fire click when disabled', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button disabled onClick={handleClick}>Disabled</Button>);
|
||||
render(
|
||||
<Button disabled onClick={handleClick}>
|
||||
Disabled
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
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"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
@@ -18,14 +18,14 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ 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",
|
||||
'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
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
@@ -36,79 +36,48 @@ const AlertDialogContent = React.forwardRef<
|
||||
<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",
|
||||
'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
|
||||
));
|
||||
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 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 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
|
||||
<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
|
||||
<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
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
@@ -116,15 +85,11 @@ const AlertDialogCancel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
@@ -138,4 +103,4 @@ export {
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,59 +1,43 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
default: 'bg-background text-foreground',
|
||||
destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// File: components/ui/avatar.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
@@ -11,26 +11,19 @@ const Avatar = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
@@ -38,13 +31,10 @@ const AvatarFallback = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
// File: components/ui/badge.tsx
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white hover:bg-green-600",
|
||||
warning:
|
||||
"border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-500 text-white hover:bg-green-600',
|
||||
warning: 'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,59 +1,50 @@
|
||||
// File: components/ui/button.tsx
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// กำหนด Variants ของปุ่มโดยใช้ cva
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
// Button Component หลัก
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
// ถ้า asChild เป็น true จะใช้ Slot เพื่อส่ง props ไปยัง child component แทน
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,66 +1,56 @@
|
||||
// File: components/ui/calendar.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day: cn(buttonVariants({ variant: 'ghost' }), 'h-9 w-9 p-0 font-normal aria-selected:opacity-100'),
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar }
|
||||
export { Calendar };
|
||||
|
||||
@@ -1,79 +1,41 @@
|
||||
// File: components/ui/card.tsx
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// File: components/ui/checkbox.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -13,18 +13,16 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
@@ -13,13 +13,13 @@ const Command = React.forwardRef<
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
@@ -30,15 +30,15 @@ const CommandInput = React.forwardRef<
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
@@ -46,25 +46,19 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
@@ -73,26 +67,22 @@ const CommandGroup = React.forwardRef<
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
@@ -101,30 +91,19 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
|
||||
};
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
export {
|
||||
Command,
|
||||
@@ -135,4 +114,4 @@ export {
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
@@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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",
|
||||
'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}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.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",
|
||||
'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}
|
||||
@@ -50,36 +50,18 @@ const DialogContent = React.forwardRef<
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
@@ -87,26 +69,19 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
@@ -119,4 +94,4 @@ export {
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// File: components/ui/dropdown-menu.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { _Check, ChevronRight, _Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
@@ -17,78 +17,70 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
// Export ส่วนประกอบอื่นๆ ที่อาจใช้ (Shortcut, Group, Sub) เพื่อความครบถ้วน
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -96,8 +88,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -106,26 +98,18 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
@@ -140,4 +124,4 @@ export {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuShortcut,
|
||||
}
|
||||
};
|
||||
|
||||
+67
-114
@@ -1,36 +1,27 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
@@ -49,7 +40,7 @@ const useFormField = () => {
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
@@ -68,23 +59,20 @@ type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
@@ -92,87 +80,52 @@ const FormLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />;
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
|
||||
({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
return <p ref={ref} id={formDescriptionId} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p ref={ref} id={formMessageId} className={cn('text-sm font-medium text-destructive', className)} {...props}>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
import * as React from 'react';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
// File: components/ui/input.tsx
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
// File: components/ui/label.tsx
|
||||
"use client" // ต้องระบุว่าเป็น Client Component เนื่องจากมีการใช้ Hooks ภายใน Radix
|
||||
'use client'; // ต้องระบุว่าเป็น Client Component เนื่องจากมีการใช้ Hooks ภายใน Radix
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70');
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
// File: components/ui/popover.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// File: components/ui/progress.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
@@ -11,10 +11,7 @@ const Progress = React.forwardRef<
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
@@ -22,7 +19,7 @@ const Progress = React.forwardRef<
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,48 +1,40 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// File: components/ui/select.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
@@ -36,16 +36,13 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
@@ -53,29 +50,25 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
@@ -84,9 +77,9 @@ const SelectContent = React.forwardRef<
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -94,20 +87,16 @@ const SelectContent = React.forwardRef<
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
<SelectPrimitive.Label ref={ref} className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)} {...props} />
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
@@ -116,7 +105,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -129,20 +118,16 @@ const SelectItem = React.forwardRef<
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
@@ -155,4 +140,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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
|
||||
>(({ 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 }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
@@ -21,110 +21,79 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.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",
|
||||
'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}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
side: 'right',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
@@ -137,4 +106,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
export function Toaster({ ...props }: ToasterProps) {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// File: components/ui/switch.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
'peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,11 +19,11 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
));
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -1,117 +1,72 @@
|
||||
// File: components/ui/table.tsx
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// File: components/ui/tabs.tsx
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
@@ -14,13 +14,13 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
@@ -29,13 +29,13 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
@@ -44,12 +44,12 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
// File: components/ui/textarea.tsx
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -47,7 +47,7 @@ export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSL
|
||||
try {
|
||||
const result = await workflowApi.validateDSL(dsl);
|
||||
setValidationResult(result);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Validation failed - error state shown in UI
|
||||
setValidationResult({ valid: false, errors: ['Validation failed due to server error'] });
|
||||
} finally {
|
||||
@@ -67,13 +67,13 @@ export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSL
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
// Mock test execution
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setTestResult({ success: true, message: "Workflow simulation completed successfully." });
|
||||
// Mock test execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setTestResult({ success: true, message: 'Workflow simulation completed successfully.' });
|
||||
} catch {
|
||||
setTestResult({ success: false, message: "Workflow simulation failed." });
|
||||
setTestResult({ success: false, message: 'Workflow simulation failed.' });
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,24 +82,16 @@ export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSL
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Workflow DSL</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={validateDSL}
|
||||
disabled={isValidating || readOnly}
|
||||
>
|
||||
<Button variant="outline" onClick={validateDSL} disabled={isValidating || readOnly}>
|
||||
{isValidating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Validate
|
||||
</Button>
|
||||
<Button variant="outline" onClick={testWorkflow} disabled={isTesting || readOnly}>
|
||||
{isTesting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isTesting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
@@ -127,12 +119,11 @@ export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSL
|
||||
</Card>
|
||||
|
||||
{validationResult && (
|
||||
<Alert variant={validationResult.valid ? 'default' : 'destructive'} className={validationResult.valid ? "border-green-500 text-green-700 dark:text-green-400" : ""}>
|
||||
{validationResult.valid ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
<Alert
|
||||
variant={validationResult.valid ? 'default' : 'destructive'}
|
||||
className={validationResult.valid ? 'border-green-500 text-green-700 dark:text-green-400' : ''}
|
||||
>
|
||||
{validationResult.valid ? <CheckCircle className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
||||
<AlertDescription>
|
||||
{validationResult.valid ? (
|
||||
<span className="font-semibold">DSL is valid and ready to deploy.</span>
|
||||
@@ -152,12 +143,13 @@ export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSL
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<Alert variant={testResult.success ? 'default' : 'destructive'} className={testResult.success ? "border-blue-500 text-blue-700 dark:text-blue-400" : ""}>
|
||||
{testResult.success ? <CheckCircle className="h-4 w-4"/> : <AlertCircle className="h-4 w-4"/>}
|
||||
<AlertDescription>
|
||||
{testResult.message}
|
||||
</AlertDescription>
|
||||
{testResult && (
|
||||
<Alert
|
||||
variant={testResult.success ? 'default' : 'destructive'}
|
||||
className={testResult.success ? 'border-blue-500 text-blue-700 dark:text-blue-400' : ''}
|
||||
>
|
||||
{testResult.success ? <CheckCircle className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
||||
<AlertDescription>{testResult.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -68,24 +68,24 @@ interface ParsedDslShape {
|
||||
|
||||
// Define custom node styles (simplified for now)
|
||||
const nodeStyle = {
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ddd',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
background: 'white',
|
||||
color: '#333',
|
||||
width: 180, // Increased width for role display
|
||||
textAlign: 'center' as const,
|
||||
whiteSpace: 'pre-wrap' as const, // Allow multiline
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ddd',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
background: 'white',
|
||||
color: '#333',
|
||||
width: 180, // Increased width for role display
|
||||
textAlign: 'center' as const,
|
||||
whiteSpace: 'pre-wrap' as const, // Allow multiline
|
||||
};
|
||||
|
||||
const conditionNodeStyle = {
|
||||
...nodeStyle,
|
||||
background: '#fef3c7', // Amber-100
|
||||
borderColor: '#d97706', // Amber-600
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: '24px', // More rounded
|
||||
...nodeStyle,
|
||||
background: '#fef3c7', // Amber-100
|
||||
borderColor: '#d97706', // Amber-600
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: '24px', // More rounded
|
||||
};
|
||||
|
||||
const initialNodes: Node[] = [
|
||||
@@ -99,11 +99,11 @@ const initialNodes: Node[] = [
|
||||
];
|
||||
|
||||
interface VisualWorkflowBuilderProps {
|
||||
initialNodes?: Node[];
|
||||
initialEdges?: Edge[];
|
||||
dslString?: string;
|
||||
onSave?: (nodes: Node[], edges: Edge[]) => void;
|
||||
onDslChange?: (dsl: string) => void;
|
||||
initialNodes?: Node[];
|
||||
initialEdges?: Edge[];
|
||||
dslString?: string;
|
||||
onSave?: (nodes: Node[], edges: Edge[]) => void;
|
||||
onDslChange?: (dsl: string) => void;
|
||||
}
|
||||
|
||||
const createNode = (
|
||||
@@ -141,10 +141,10 @@ const createNode = (
|
||||
label: isStart || isEnd ? name : `${name}\n(${options?.role || 'No Role'})`,
|
||||
name,
|
||||
role: options?.role,
|
||||
type: options?.type || (isStart ? 'START' : isEnd ? 'END' : 'TASK')
|
||||
type: options?.type || (isStart ? 'START' : isEnd ? 'END' : 'TASK'),
|
||||
},
|
||||
position: { x: 250, y: yOffset },
|
||||
style
|
||||
style,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -153,10 +153,10 @@ const createEdge = (source: string, target: string, label: string): Edge => ({
|
||||
source,
|
||||
target,
|
||||
label,
|
||||
markerEnd: { type: MarkerType.ArrowClosed }
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
});
|
||||
|
||||
function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
|
||||
function parseDSL(dsl: string): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
let yOffset = 50;
|
||||
@@ -186,7 +186,7 @@ function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
|
||||
isStart,
|
||||
isEnd,
|
||||
role,
|
||||
type: state.type
|
||||
type: state.type,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -219,7 +219,7 @@ function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
|
||||
createNode(stateName, yOffset, {
|
||||
isStart,
|
||||
isEnd,
|
||||
role: roles.join(', ')
|
||||
role: roles.join(', '),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -235,26 +235,32 @@ function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
|
||||
yOffset += 120;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Failed to parse DSL as JSON - nodes/edges remain empty
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: propEdges, dslString, onSave, onDslChange }: VisualWorkflowBuilderProps) {
|
||||
function VisualWorkflowBuilderContent({
|
||||
initialNodes: propNodes,
|
||||
initialEdges: propEdges,
|
||||
dslString,
|
||||
onSave,
|
||||
onDslChange,
|
||||
}: VisualWorkflowBuilderProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(propNodes || initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(propEdges || []);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Sync DSL to nodes when dslString changes
|
||||
useEffect(() => {
|
||||
if (dslString) {
|
||||
const { nodes: newNodes, edges: newEdges } = parseDSL(dslString);
|
||||
setNodes(newNodes.length > 0 ? newNodes : propNodes || initialNodes);
|
||||
setEdges(newNodes.length > 0 ? newEdges : propEdges || []);
|
||||
setTimeout(() => fitView(), 100);
|
||||
}
|
||||
if (dslString) {
|
||||
const { nodes: newNodes, edges: newEdges } = parseDSL(dslString);
|
||||
setNodes(newNodes.length > 0 ? newNodes : propNodes || initialNodes);
|
||||
setEdges(newNodes.length > 0 ? newEdges : propEdges || []);
|
||||
setTimeout(() => fitView(), 100);
|
||||
}
|
||||
}, [dslString, fitView, propEdges, propNodes, setEdges, setNodes]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
@@ -274,72 +280,79 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
|
||||
};
|
||||
|
||||
if (type === 'end') {
|
||||
newNode.style = { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' };
|
||||
newNode.type = 'output';
|
||||
newNode.style = { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' };
|
||||
newNode.type = 'output';
|
||||
} else if (type === 'start') {
|
||||
newNode.style = { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' };
|
||||
newNode.type = 'input';
|
||||
newNode.style = { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' };
|
||||
newNode.type = 'input';
|
||||
} else if (type === 'condition') {
|
||||
newNode.style = conditionNodeStyle;
|
||||
newNode.style = conditionNodeStyle;
|
||||
}
|
||||
|
||||
setNodes((nds) => nds.concat(newNode));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.(nodes, edges);
|
||||
onSave?.(nodes, edges);
|
||||
};
|
||||
|
||||
// Generate JSON DSL
|
||||
const generateDSL = () => {
|
||||
const states = nodes.map(n => {
|
||||
const outgoingEdges = edges.filter(e => e.source === n.id);
|
||||
const onConfig: Record<string, { to: string }> = {};
|
||||
const states = nodes.map((n) => {
|
||||
const outgoingEdges = edges.filter((e) => e.source === n.id);
|
||||
const onConfig: Record<string, { to: string }> = {};
|
||||
|
||||
outgoingEdges.forEach(e => {
|
||||
const eventName = e.label || 'PROCEED';
|
||||
onConfig[eventName as string] = { to: e.target };
|
||||
});
|
||||
outgoingEdges.forEach((e) => {
|
||||
const eventName = e.label || 'PROCEED';
|
||||
onConfig[eventName as string] = { to: e.target };
|
||||
});
|
||||
|
||||
const isStartNode = n.type === 'input';
|
||||
const isEndNode = n.type === 'output';
|
||||
const nodeData = n.data as WorkflowStateNodeData;
|
||||
const isStartNode = n.type === 'input';
|
||||
const isEndNode = n.type === 'output';
|
||||
const nodeData = n.data as WorkflowStateNodeData;
|
||||
|
||||
const stateObj: { name: string; type?: string; role?: string; initial?: boolean; terminal?: boolean; on?: Record<string, { to: string }> } = {
|
||||
name: nodeData.name || nodeData.label?.split('\n')[0] || n.id,
|
||||
};
|
||||
const stateObj: {
|
||||
name: string;
|
||||
type?: string;
|
||||
role?: string;
|
||||
initial?: boolean;
|
||||
terminal?: boolean;
|
||||
on?: Record<string, { to: string }>;
|
||||
} = {
|
||||
name: nodeData.name || nodeData.label?.split('\n')[0] || n.id,
|
||||
};
|
||||
|
||||
if (nodeData.type && nodeData.type !== 'START' && nodeData.type !== 'END' && nodeData.type !== 'TASK') {
|
||||
stateObj.type = nodeData.type;
|
||||
}
|
||||
if (nodeData.type && nodeData.type !== 'START' && nodeData.type !== 'END' && nodeData.type !== 'TASK') {
|
||||
stateObj.type = nodeData.type;
|
||||
}
|
||||
|
||||
if (nodeData.role && !isStartNode && !isEndNode) {
|
||||
stateObj.role = nodeData.role;
|
||||
}
|
||||
if (nodeData.role && !isStartNode && !isEndNode) {
|
||||
stateObj.role = nodeData.role;
|
||||
}
|
||||
|
||||
if (isStartNode) {
|
||||
stateObj.initial = true;
|
||||
}
|
||||
if (isEndNode) {
|
||||
stateObj.terminal = true;
|
||||
}
|
||||
if (Object.keys(onConfig).length > 0) {
|
||||
stateObj.on = onConfig;
|
||||
}
|
||||
if (isStartNode) {
|
||||
stateObj.initial = true;
|
||||
}
|
||||
if (isEndNode) {
|
||||
stateObj.terminal = true;
|
||||
}
|
||||
if (Object.keys(onConfig).length > 0) {
|
||||
stateObj.on = onConfig;
|
||||
}
|
||||
|
||||
return stateObj;
|
||||
return stateObj;
|
||||
});
|
||||
|
||||
const dslObj = {
|
||||
workflow: "VISUAL_WORKFLOW",
|
||||
version: 1,
|
||||
states
|
||||
workflow: 'VISUAL_WORKFLOW',
|
||||
version: 1,
|
||||
states,
|
||||
};
|
||||
const dsl = JSON.stringify(dslObj, null, 2);
|
||||
|
||||
// DSL generated from visual builder
|
||||
onDslChange?.(dsl);
|
||||
alert("DSL Updated from Visual Builder!");
|
||||
alert('DSL Updated from Visual Builder!');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -357,29 +370,32 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
|
||||
<Controls />
|
||||
<Background color="#aaa" gap={16} />
|
||||
|
||||
<Panel position="top-right" className="flex gap-2 p-2 bg-white/80 dark:bg-black/50 rounded-lg backdrop-blur-sm border shadow-sm">
|
||||
<Button size="sm" variant="secondary" onClick={() => addNode('step', 'New Step')}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Step
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => addNode('condition', 'Condition')}>
|
||||
<Layout className="mr-2 h-4 w-4" /> Condition
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => addNode('end', 'End')}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add End
|
||||
</Button>
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="flex gap-2 p-2 bg-white/80 dark:bg-black/50 rounded-lg backdrop-blur-sm border shadow-sm"
|
||||
>
|
||||
<Button size="sm" variant="secondary" onClick={() => addNode('step', 'New Step')}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Step
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => addNode('condition', 'Condition')}>
|
||||
<Layout className="mr-2 h-4 w-4" /> Condition
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => addNode('end', 'End')}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add End
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
<Panel position="bottom-left" className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
<Save className="mr-2 h-4 w-4" /> Save Visual State
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={generateDSL}>
|
||||
<Download className="mr-2 h-4 w-4" /> Generate DSL
|
||||
</Button>
|
||||
</Panel>
|
||||
<Panel position="bottom-left" className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
<Save className="mr-2 h-4 w-4" /> Save Visual State
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={generateDSL}>
|
||||
<Download className="mr-2 h-4 w-4" /> Generate DSL
|
||||
</Button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>Tip: Drag to connect nodes. Use backspace to delete selected nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,9 +403,9 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
|
||||
}
|
||||
|
||||
export function VisualWorkflowBuilder(props: VisualWorkflowBuilderProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<VisualWorkflowBuilderContent {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<VisualWorkflowBuilderContent {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user