251209:1453 Frontend: progress nest = UAT & Bug Fixing
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-09 14:53:42 +07:00
parent 8aceced902
commit aa96cd90e3
125 changed files with 11052 additions and 785 deletions

View File

@@ -25,23 +25,30 @@ export default function AuditLogsPage() {
{!logs || logs.length === 0 ? (
<div className="text-center text-muted-foreground py-10">No logs found</div>
) : (
logs.map((log: any) => (
<Card key={log.audit_log_id} className="p-4">
logs.map((log: import("@/lib/services/audit-log.service").AuditLog) => (
<Card key={log.auditId} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium text-sm">{log.user_name || `User #${log.user_id}`}</span>
<Badge variant="outline" className="uppercase text-[10px]">{log.action}</Badge>
<Badge variant="secondary" className="uppercase text-[10px]">{log.entity_type}</Badge>
<span className="font-medium text-sm">
{log.user?.fullName || log.user?.username || `User #${log.userId || 'System'}`}
</span>
<Badge variant={log.severity === 'ERROR' ? 'destructive' : 'outline'} className="uppercase text-[10px]">
{log.action}
</Badge>
<Badge variant="secondary" className="uppercase text-[10px]">{log.entityType || 'General'}</Badge>
</div>
<p className="text-sm text-foreground">{log.description}</p>
<p className="text-sm text-foreground">
{typeof log.detailsJson === 'string' ? log.detailsJson : JSON.stringify(log.detailsJson || {})}
</p>
<p className="text-xs text-muted-foreground mt-2">
{formatDistanceToNow(new Date(log.created_at), { addSuffix: true })}
{log.createdAt && formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</p>
</div>
{log.ip_address && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-1 rounded">
{log.ip_address}
{/* Only show IP if available */}
{log.ipAddress && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-1 rounded hidden md:inline-block">
{log.ipAddress}
</span>
)}
</div>

View File

@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/common/data-table";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
useProjects,
useCreateProject,
useUpdateProject,
useDeleteProject,
} from "@/hooks/use-projects";
import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Trash, Plus, Folder } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
interface Project {
id: number;
projectCode: string;
projectName: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export default function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const createProject = useCreateProject();
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formData, setFormData] = useState({
projectCode: "",
projectName: "",
isActive: true,
});
const columns: ColumnDef<Project>[] = [
{
accessorKey: "projectCode",
header: "Code",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-500" />
<span className="font-medium">{row.original.projectCode}</span>
</div>
),
},
{ accessorKey: "projectName", header: "Project Name" },
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => (
<Badge variant={row.original.isActive ? "default" : "secondary"}>
{row.original.isActive ? "Active" : "Inactive"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Pencil className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => {
if (confirm(`Delete project ${row.original.projectCode}?`)) {
deleteProject.mutate(row.original.id);
}
}}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
const handleEdit = (project: Project) => {
setEditingProject(project);
setFormData({
projectCode: project.projectCode,
projectName: project.projectName,
isActive: project.isActive,
});
setDialogOpen(true);
};
const handleAdd = () => {
setEditingProject(null);
setFormData({ projectCode: "", projectName: "", isActive: true });
setDialogOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingProject) {
updateProject.mutate(
{ id: editingProject.id, data: formData },
{
onSuccess: () => setDialogOpen(false),
}
);
} else {
createProject.mutate(formData, {
onSuccess: () => setDialogOpen(false),
});
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Projects</h1>
<p className="text-muted-foreground mt-1">
Manage construction projects and configurations
</p>
</div>
<Button onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" /> Add Project
</Button>
</div>
<DataTable columns={columns} data={projects || []} />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingProject ? "Edit Project" : "New Project"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Project Code</Label>
<Input
placeholder="e.g. LCBP3"
value={formData.projectCode}
onChange={(e) =>
setFormData({ ...formData, projectCode: e.target.value })
}
required
disabled={!!editingProject} // Code is usually immutable or derived
/>
</div>
<div className="space-y-2">
<Label>Project Name</Label>
<Input
placeholder="Full project name"
value={formData.projectName}
onChange={(e) =>
setFormData({ ...formData, projectName: e.target.value })
}
required
/>
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch
id="active"
checked={formData.isActive}
onCheckedChange={(checked) =>
setFormData({ ...formData, isActive: checked })
}
/>
<Label htmlFor="active">Active Status</Label>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={createProject.isPending || updateProject.isPending}
>
Save
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import apiClient from "@/lib/api/client";
// Service wrapper
const correspondenceTypeService = {
getAll: async () => (await apiClient.get("/master/correspondence-types")).data,
create: async (data: any) => (await apiClient.post("/master/correspondence-types", data)).data,
update: async (id: number, data: any) => (await apiClient.patch(`/master/correspondence-types/${id}`, data)).data,
delete: async (id: number) => (await apiClient.delete(`/master/correspondence-types/${id}`)).data,
};
export default function CorrespondenceTypesPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "type_code",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("type_code")}</span>
),
},
{
accessorKey: "type_name_th",
header: "Name (TH)",
},
{
accessorKey: "type_name_en",
header: "Name (EN)",
},
];
return (
<div className="p-6">
<GenericCrudTable
entityName="Correspondence Type"
title="Correspondence Types Management"
queryKey={["correspondence-types"]}
fetchFn={correspondenceTypeService.getAll}
createFn={correspondenceTypeService.create}
updateFn={correspondenceTypeService.update}
deleteFn={correspondenceTypeService.delete}
columns={columns}
fields={[
{ name: "type_code", label: "Code", type: "text", required: true },
{ name: "type_name_th", label: "Name (TH)", type: "text", required: true },
{ name: "type_name_en", label: "Name (EN)", type: "text" },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { ColumnDef } from "@tanstack/react-table";
export default function DisciplinesPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "discipline_code",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("discipline_code")}</span>
),
},
{
accessorKey: "code_name_th",
header: "Name (TH)",
},
{
accessorKey: "code_name_en",
header: "Name (EN)",
},
{
accessorKey: "is_active",
header: "Status",
cell: ({ row }) => (
<span
className={`px-2 py-1 rounded-full text-xs ${
row.getValue("is_active")
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{row.getValue("is_active") ? "Active" : "Inactive"}
</span>
),
},
];
return (
<div className="p-6">
<GenericCrudTable
entityName="Discipline"
title="Disciplines Management"
description="Manage system disciplines (e.g., ARCH, STR, MEC)"
queryKey={["disciplines"]}
fetchFn={() => masterDataService.getDisciplines()} // Assuming generic fetch supports no args for all
createFn={(data) => masterDataService.createDiscipline({ ...data, contractId: 1 })} // Default contract for now
updateFn={(id, data) => Promise.reject("Not implemented yet")} // Update endpoint might need addition
deleteFn={(id) => masterDataService.deleteDiscipline(id)}
columns={columns}
fields={[
{ name: "discipline_code", label: "Code", type: "text", required: true },
{ name: "code_name_th", label: "Name (TH)", type: "text", required: true },
{ name: "code_name_en", label: "Name (EN)", type: "text" },
{ name: "is_active", label: "Active", type: "checkbox" },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { ColumnDef } from "@tanstack/react-table";
export default function DrawingCategoriesPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "type_code",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("type_code")}</span>
),
},
{
accessorKey: "type_name",
header: "Name",
},
{
accessorKey: "classification",
header: "Classification",
cell: ({ row }) => (
<span className="capitalize">{row.getValue("classification") || "General"}</span>
),
},
];
return (
<div className="p-6">
<GenericCrudTable
entityName="Drawing Category (Sub-Type)"
title="Drawing Categories Management"
description="Manage drawing sub-types and categories"
queryKey={["drawing-categories"]}
fetchFn={() => masterDataService.getSubTypes(1)} // Default contract ID 1
createFn={(data) => masterDataService.createSubType({ ...data, contractId: 1 })}
updateFn={(id, data) => Promise.reject("Not implemented yet")}
deleteFn={(id) => Promise.reject("Not implemented yet")} // Delete might be restricted
columns={columns}
fields={[
{ name: "type_code", label: "Code", type: "text", required: true },
{ name: "type_name", label: "Name", type: "text", required: true },
{ name: "classification", label: "Classification", type: "text" },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BookOpen, Tag, Settings, Layers } from "lucide-react";
import Link from "next/link";
const refMenu = [
{
title: "Disciplines",
description: "Manage system-wide disciplines (e.g., ARCH, STR)",
href: "/admin/reference/disciplines",
icon: Layers,
},
{
title: "RFA Types",
description: "Manage RFA types and approve codes",
href: "/admin/reference/rfa-types",
icon: BookOpen,
},
{
title: "Correspondence Types",
description: "Manage generic correspondence types",
href: "/admin/reference/correspondence-types",
icon: Settings,
},
{
title: "Tags",
description: "Manage system tags for documents",
href: "/admin/reference/tags",
icon: Tag,
},
];
export default function ReferenceDataPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Reference Data Management</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{refMenu.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{item.title}
</CardTitle>
<item.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { ColumnDef } from "@tanstack/react-table";
import apiClient from "@/lib/api/client";
// Extending masterDataService locally if needed or using direct API calls for specific RFA types logic
const rfaTypeService = {
getAll: async () => (await apiClient.get("/master/rfa-types")).data,
create: async (data: any) => (await apiClient.post("/master/rfa-types", data)).data, // Endpoint assumption
update: async (id: number, data: any) => (await apiClient.patch(`/master/rfa-types/${id}`, data)).data,
delete: async (id: number) => (await apiClient.delete(`/master/rfa-types/${id}`)).data,
};
export default function RfaTypesPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "type_code",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("type_code")}</span>
),
},
{
accessorKey: "type_name_th",
header: "Name (TH)",
},
{
accessorKey: "type_name_en",
header: "Name (EN)",
},
];
return (
<div className="p-6">
<GenericCrudTable
entityName="RFA Type"
title="RFA Types Management"
queryKey={["rfa-types"]}
fetchFn={rfaTypeService.getAll}
createFn={rfaTypeService.create}
updateFn={rfaTypeService.update}
deleteFn={rfaTypeService.delete}
columns={columns}
fields={[
{ name: "type_code", label: "Code", type: "text", required: true },
{ name: "type_name_th", label: "Name (TH)", type: "text", required: true },
{ name: "type_name_en", label: "Name (EN)", type: "text" },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { CreateTagDto } from "@/types/dto/master/tag.dto";
import { ColumnDef } from "@tanstack/react-table";
export default function TagsPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "tag_name",
header: "Tag Name",
},
{
accessorKey: "description",
header: "Description",
},
];
return (
<GenericCrudTable
title="Tags"
description="Manage system tags."
entityName="Tag"
queryKey={["tags"]}
fetchFn={() => masterDataService.getTags()}
createFn={(data: CreateTagDto) => masterDataService.createTag(data)}
updateFn={(id, data) => masterDataService.updateTag(id, data)}
deleteFn={(id) => masterDataService.deleteTag(id)}
columns={columns}
fields={[
{
name: "tag_name",
label: "Tag Name",
type: "text",
required: true,
},
{
name: "description",
label: "Description",
type: "textarea",
required: false,
},
]}
/>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { RbacMatrix } from "@/components/admin/security/rbac-matrix";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function RolesPage() {
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Roles & Permissions</h1>
<p className="text-muted-foreground">
Manage system roles and their assigned permissions
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>RBAC Matrix</CardTitle>
</CardHeader>
<CardContent>
<RbacMatrix />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import apiClient from "@/lib/api/client";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { LogOut, Monitor, Smartphone, RefreshCw } from "lucide-react";
import { format } from "date-fns";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
interface Session {
id: string;
userId: number;
user: {
username: string;
first_name: string;
last_name: string;
};
deviceName: string; // e.g., "Chrome on Windows"
ipAddress: string;
lastActive: string;
isCurrent: boolean;
}
const sessionService = {
getAll: async () => (await apiClient.get("/auth/sessions")).data,
revoke: async (sessionId: string) => (await apiClient.delete(`/auth/sessions/${sessionId}`)).data,
};
export default function SessionsPage() {
const queryClient = useQueryClient();
const { data: sessions = [], isLoading } = useQuery<Session[]>({
queryKey: ["sessions"],
queryFn: sessionService.getAll,
});
const revokeMutation = useMutation({
mutationFn: sessionService.revoke,
onSuccess: () => {
toast.success("Session revoked successfully");
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
onError: () => toast.error("Failed to revoke session"),
});
const columns: ColumnDef<Session>[] = [
{
accessorKey: "user",
header: "User",
cell: ({ row }) => {
const user = row.original.user;
return (
<div className="flex flex-col">
<span className="font-medium">{user.username}</span>
<span className="text-xs text-muted-foreground">
{user.first_name} {user.last_name}
</span>
</div>
);
},
},
{
accessorKey: "deviceName",
header: "Device / IP",
cell: ({ row }) => (
<div className="flex items-center gap-2">
{row.original.deviceName.toLowerCase().includes("mobile") ? (
<Smartphone className="h-4 w-4 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 text-muted-foreground" />
)}
<div className="flex flex-col">
<span>{row.original.deviceName}</span>
<span className="text-xs text-muted-foreground">{row.original.ipAddress}</span>
</div>
</div>
),
},
{
accessorKey: "lastActive",
header: "Last Active",
cell: ({ row }) => format(new Date(row.original.lastActive), "dd MMM yyyy, HH:mm"),
},
{
id: "status",
header: "Status",
cell: ({ row }) =>
row.original.isCurrent ? <Badge>Current</Badge> : <Badge variant="secondary">Active</Badge>,
},
{
id: "actions",
cell: ({ row }) => (
<Button
variant="destructive"
size="sm"
disabled={row.original.isCurrent || revokeMutation.isPending}
onClick={() => revokeMutation.mutate(row.original.id)}
>
<LogOut className="h-4 w-4 mr-2" />
Revoke
</Button>
),
},
];
if (isLoading) {
return (
<div className="flex justify-center p-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Active Sessions</h1>
<p className="text-muted-foreground">Manage user sessions and force logout if needed</p>
</div>
</div>
<DataTable columns={columns} data={sessions} />
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">System Settings</h1>
<p className="text-muted-foreground mt-1">Manage global system configurations</p>
</div>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>Configure general system behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between space-x-2">
<div className="flex flex-col space-y-1">
<Label htmlFor="maintenance-mode">Maintenance Mode</Label>
<span className="text-sm text-muted-foreground">
Prevent users from accessing the system during maintenance
</span>
</div>
<Switch id="maintenance-mode" />
</div>
<div className="flex items-center justify-between space-x-2">
<div className="flex flex-col space-y-1">
<Label htmlFor="audit-logging">Enhanced Audit Logging</Label>
<span className="text-sm text-muted-foreground">
Log detailed request/response data for debugging
</span>
</div>
<Switch id="audit-logging" defaultChecked />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notification Settings</CardTitle>
<CardDescription>Manage system-wide email notifications</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between space-x-2">
<div className="flex flex-col space-y-1">
<Label htmlFor="email-notif">Email Notifications</Label>
<span className="text-sm text-muted-foreground">
Enable or disable all outbound emails
</span>
</div>
<Switch id="email-notif" defaultChecked />
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button>Save Changes</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import apiClient from "@/lib/api/client";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { RefreshCw } from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
interface NumberingError {
id: number;
userId?: number;
errorMessage: string;
stackTrace?: string;
createdAt: string;
context?: any;
}
const logService = {
getNumberingErrors: async () => (await apiClient.get("/document-numbering/logs/errors")).data,
};
export default function NumberingLogsPage() {
const { data: errors = [], isLoading, refetch } = useQuery<NumberingError[]>({
queryKey: ["numbering-errors"],
queryFn: logService.getNumberingErrors,
});
const columns: ColumnDef<NumberingError>[] = [
{
accessorKey: "createdAt",
header: "Timestamp",
cell: ({ row }) => format(new Date(row.original.createdAt), "dd MMM yyyy, HH:mm:ss"),
},
{
accessorKey: "context.projectId", // Accessing nested property
header: "Project ID",
cell: ({ row }) => <span className="font-mono">{row.original.context?.projectId || 'N/A'}</span>,
},
{
accessorKey: "errorMessage",
header: "Message",
cell: ({ row }) => <span className="text-destructive font-medium">{row.original.errorMessage}</span>,
},
{
accessorKey: "stackTrace",
header: "Details",
cell: ({ row }) => (
<div className="max-w-[400px] truncate text-xs text-muted-foreground font-mono" title={row.original.stackTrace}>
{row.original.stackTrace}
</div>
),
},
];
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Numbering Logs</h1>
<p className="text-muted-foreground">Diagnostics for document numbering issues</p>
</div>
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
{isLoading ? (
<div className="flex justify-center p-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<DataTable columns={columns} data={errors} />
)}
</div>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { circulationService } from "@/lib/services/circulation.service";
import { Circulation, UpdateCirculationRoutingDto } from "@/types/circulation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ArrowLeft, RefreshCw, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
/**
* Get initials from name
*/
function getInitials(firstName?: string, lastName?: string): string {
const first = firstName?.charAt(0) || "";
const last = lastName?.charAt(0) || "";
return (first + last).toUpperCase() || "?";
}
/**
* Get status badge variant
*/
function getStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
switch (status?.toUpperCase()) {
case "PENDING":
return "outline";
case "IN_PROGRESS":
return "default";
case "COMPLETED":
return "secondary";
case "REJECTED":
return "destructive";
default:
return "outline";
}
}
export default function CirculationDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const id = params.id as string;
const { data: circulation, isLoading, error } = useQuery<Circulation>({
queryKey: ["circulation", id],
queryFn: () => circulationService.getById(id),
enabled: !!id,
});
const completeMutation = useMutation({
mutationFn: ({ routingId, data }: { routingId: number; data: UpdateCirculationRoutingDto }) =>
circulationService.updateRouting(routingId, data),
onSuccess: () => {
toast.success("Task completed successfully");
queryClient.invalidateQueries({ queryKey: ["circulation", id] });
},
onError: () => {
toast.error("Failed to update task status");
},
});
const handleComplete = (routingId: number) => {
completeMutation.mutate({
routingId,
data: { status: "COMPLETED", comments: "Completed via UI" },
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !circulation) {
return (
<div className="space-y-4">
<Link href="/circulation">
<Button variant="ghost">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Circulations
</Button>
</Link>
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
Failed to load circulation details.
</div>
</div>
);
}
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/circulation">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{circulation.circulationNo}</h1>
<p className="text-muted-foreground">{circulation.subject}</p>
</div>
</div>
<Badge variant={getStatusVariant(circulation.statusCode)}>
{circulation.statusCode}
</Badge>
</div>
{/* Info Card */}
<Card>
<CardHeader>
<CardTitle>Circulation Details</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Organization</p>
<p className="font-medium">
{circulation.organization?.organization_name || "-"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Created By</p>
<p className="font-medium">
{circulation.creator
? `${circulation.creator.first_name || ""} ${circulation.creator.last_name || ""}`.trim() ||
circulation.creator.username
: "-"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Created At</p>
<p className="font-medium">
{format(new Date(circulation.createdAt), "dd MMM yyyy, HH:mm")}
</p>
</div>
{circulation.correspondence && (
<div>
<p className="text-sm text-muted-foreground">Linked Document</p>
<Link
href={`/correspondences/${circulation.correspondenceId}`}
className="font-medium text-primary hover:underline"
>
{circulation.correspondence.correspondence_number}
</Link>
</div>
)}
</CardContent>
</Card>
{/* Assignees/Routings */}
<Card>
<CardHeader>
<CardTitle>Assignees</CardTitle>
</CardHeader>
<CardContent>
{circulation.routings && circulation.routings.length > 0 ? (
<div className="space-y-3">
{circulation.routings.map((routing) => (
<div
key={routing.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{getInitials(
routing.assignee?.first_name,
routing.assignee?.last_name
)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{routing.assignee
? `${routing.assignee.first_name || ""} ${routing.assignee.last_name || ""}`.trim() ||
routing.assignee.username
: "Unassigned"}
</p>
<p className="text-sm text-muted-foreground">
Step {routing.stepNumber}
{routing.comments && `${routing.comments}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={getStatusVariant(routing.status)}>
{routing.status}
</Badge>
{routing.status === "PENDING" && (
<Button
size="sm"
onClick={() => handleComplete(routing.id)}
disabled={completeMutation.isPending}
>
<CheckCircle2 className="h-4 w-4 mr-1" />
Complete
</Button>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No assignees found</p>
)}
</CardContent>
</Card>
</section>
);
}

View File

@@ -0,0 +1,335 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { circulationService } from "@/lib/services/circulation.service";
import { userService } from "@/lib/services/user.service";
import { correspondenceService } from "@/lib/services/correspondence.service";
import { CreateCirculationDto } from "@/types/circulation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
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 { Badge } from "@/components/ui/badge";
import { ArrowLeft, Check, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
// Form validation schema
const formSchema = z.object({
correspondenceId: z.number({ required_error: "Please select a document" }),
subject: z.string().min(1, "Subject is required"),
assigneeIds: z.array(z.number()).min(1, "At least one assignee is required"),
remarks: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
export default function CreateCirculationPage() {
const router = useRouter();
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [docOpen, setDocOpen] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
subject: "",
assigneeIds: [],
remarks: "",
},
});
// Fetch users for assignee selection
const { data: users = [] } = useQuery({
queryKey: ["users"],
queryFn: () => userService.getAll(),
});
// Fetch correspondences for document selection
const { data: correspondences } = useQuery({
queryKey: ["correspondences-dropdown"],
queryFn: () => correspondenceService.getAll({ limit: 100 }),
});
const createMutation = useMutation({
mutationFn: (data: CreateCirculationDto) => circulationService.create(data),
onSuccess: (result) => {
toast.success("Circulation created successfully");
router.push(`/circulation/${result.id}`);
},
onError: () => {
toast.error("Failed to create circulation");
},
});
const onSubmit = (data: FormData) => {
createMutation.mutate(data);
};
const selectedAssignees = form.watch("assigneeIds");
const selectedDocId = form.watch("correspondenceId");
const selectedDoc = correspondences?.data?.find(
(c: { id: number }) => c.id === selectedDocId
);
const toggleAssignee = (userId: number) => {
const current = form.getValues("assigneeIds");
if (current.includes(userId)) {
form.setValue(
"assigneeIds",
current.filter((id) => id !== userId)
);
} else {
form.setValue("assigneeIds", [...current, userId]);
}
};
return (
<section className="space-y-6 max-w-2xl">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/circulation">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Circulation</h1>
<p className="text-muted-foreground">
Create a new internal document circulation
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Circulation Details</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Document Selection */}
<FormField
control={form.control}
name="correspondenceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Document</FormLabel>
<Popover open={docOpen} onOpenChange={setDocOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value && "text-muted-foreground"
)}
>
{selectedDoc
? selectedDoc.correspondence_number
: "Select document..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search documents..." />
<CommandList>
<CommandEmpty>No document found.</CommandEmpty>
<CommandGroup>
{correspondences?.data?.map((doc: { id: number; correspondence_number: string }) => (
<CommandItem
key={doc.id}
value={doc.correspondence_number}
onSelect={() => {
form.setValue("correspondenceId", doc.id);
setDocOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
doc.id === field.value
? "opacity-100"
: "opacity-0"
)}
/>
{doc.correspondence_number}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{/* Subject */}
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input placeholder="Enter circulation subject" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Assignees Multi-select */}
<FormField
control={form.control}
name="assigneeIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Assignees</FormLabel>
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="justify-between h-auto min-h-10"
>
{selectedAssignees.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedAssignees.map((userId) => {
const user = users.find(
(u: { user_id: number }) => u.user_id === userId
);
return user ? (
<Badge
key={userId}
variant="secondary"
className="mr-1"
>
{user.first_name || user.username}
<X
className="ml-1 h-3 w-3 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
toggleAssignee(userId);
}}
/>
</Badge>
) : null;
})}
</div>
) : (
<span className="text-muted-foreground">
Select assignees...
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search users..." />
<CommandList>
<CommandEmpty>No user found.</CommandEmpty>
<CommandGroup>
{users.map((user: { user_id: number; username: string; first_name?: string; last_name?: string }) => (
<CommandItem
key={user.user_id}
value={user.username}
onSelect={() => toggleAssignee(user.user_id)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedAssignees.includes(user.user_id)
? "opacity-100"
: "opacity-0"
)}
/>
{user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: user.username}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{/* Remarks */}
<FormField
control={form.control}
name="remarks"
render={({ field }) => (
<FormItem>
<FormLabel>Remarks (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Additional notes..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Actions */}
<div className="flex justify-end gap-2">
<Link href="/circulation">
<Button variant="outline" type="button">
Cancel
</Button>
</Link>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating..." : "Create Circulation"}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</section>
);
}

View File

@@ -1,16 +1,72 @@
// File: e:/np-dms/lcbp3/frontend/app/(dashboard)/circulation/page.tsx
// Change Log: Added circulation page under dashboard layout
"use client";
import CirculationList from "@/components/CirculationList";
import { useQuery } from "@tanstack/react-query";
import { CirculationList } from "@/components/circulation/circulation-list";
import { circulationService } from "@/lib/services/circulation.service";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link";
import { CirculationListResponse } from "@/types/circulation";
/**
* หน้าแสดงรายการการหมุนเวียนเอกสาร (อยู่ใน Dashboard)
* Circulation list page - displays circulations for the current user's organization
*/
export default function CirculationPage() {
const {
data,
isLoading,
error,
refetch,
} = useQuery<CirculationListResponse>({
queryKey: ["circulations"],
queryFn: () => circulationService.getAll(),
});
return (
<section>
<h1 className="text-2xl font-bold mb-4">Circulation</h1>
<CirculationList />
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Circulation</h1>
<p className="text-muted-foreground">
Manage internal document circulation and assignments
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
title="Refresh"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
<Link href="/circulation/new">
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Circulation
</Button>
</Link>
</div>
</div>
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
Failed to load circulations. Please try again.
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : data ? (
<CirculationList data={data} />
) : (
<div className="text-center py-12 text-muted-foreground">
No circulations found
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { transmittalService } from "@/lib/services/transmittal.service";
import { Transmittal } from "@/types/transmittal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowLeft, RefreshCw, Printer } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
export default function TransmittalDetailPage() {
const params = useParams();
const id = params.id as string;
const { data: transmittal, isLoading, error } = useQuery<Transmittal>({
queryKey: ["transmittal", id],
queryFn: () => transmittalService.getById(id),
enabled: !!id,
});
const handlePrint = () => {
toast.info("PDF Export is coming soon...");
// TODO: Implement PDF download
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !transmittal) {
return (
<div className="space-y-4">
<Link href="/transmittals">
<Button variant="ghost">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Transmittals
</Button>
</Link>
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
Failed to load transmittal details.
</div>
</div>
);
}
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/transmittals">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{transmittal.transmittalNo}</h1>
<p className="text-muted-foreground">{transmittal.subject}</p>
</div>
</div>
<Button variant="outline" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-2" />
Export PDF
</Button>
</div>
{/* Info Card */}
<Card>
<CardHeader>
<CardTitle>Transmittal Information</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Purpose</p>
<Badge variant="outline">{transmittal.purpose || "OTHER"}</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Date</p>
<p className="font-medium">
{format(new Date(transmittal.createdAt), "dd MMM yyyy")}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Generated From</p>
{transmittal.correspondence ? (
<Link
href={`/correspondences/${transmittal.correspondenceId}`}
className="font-medium text-primary hover:underline"
>
{transmittal.correspondence.correspondence_number}
</Link>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
{transmittal.remarks && (
<div className="col-span-2">
<p className="text-sm text-muted-foreground">Remarks</p>
<p className="font-medium whitespace-pre-wrap">
{transmittal.remarks}
</p>
</div>
)}
</CardContent>
</Card>
{/* Items Table */}
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Document ID/No.</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transmittal.items?.map((item, idx) => (
<TableRow key={idx}>
<TableCell>
<Badge variant="secondary">{item.itemType}</Badge>
</TableCell>
<TableCell className="font-medium">
{item.documentNumber || `ID: ${item.itemId}`}
</TableCell>
<TableCell>{item.description || "-"}</TableCell>
</TableRow>
))}
{(!transmittal.items || transmittal.items.length === 0) && (
<TableRow>
<TableCell colSpan={3} className="text-center py-4 text-muted-foreground">
No items in this transmittal
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</section>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { TransmittalForm } from "@/components/transmittal/transmittal-form";
export default function CreateTransmittalPage() {
return (
<section className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/transmittals">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Transmittal</h1>
<p className="text-muted-foreground">
Prepare a new document transmittal slip
</p>
</div>
</div>
<TransmittalForm />
</section>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { TransmittalList } from "@/components/transmittal/transmittal-list";
import { transmittalService } from "@/lib/services/transmittal.service";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link";
import { TransmittalListResponse } from "@/types/transmittal";
export default function TransmittalPage() {
const {
data,
isLoading,
error,
refetch,
} = useQuery<TransmittalListResponse>({
queryKey: ["transmittals"],
queryFn: () => transmittalService.getAll({ projectId: 1 }),
});
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Transmittals</h1>
<p className="text-muted-foreground">
Manage document transmittal slips
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
title="Refresh"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
<Link href="/transmittals/new">
<Button>
<Plus className="h-4 w-4 mr-2" />
New Transmittal
</Button>
</Link>
</div>
</div>
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
Failed to load transmittals.
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<TransmittalList data={data?.data || []} />
)}
</section>
);
}