260218:1712 20260218 TASK-BEFE-001n
All checks were successful
Build and Deploy / deploy (push) Successful in 4m55s

This commit is contained in:
admin
2026-02-18 17:12:11 +07:00
parent 01ce68acda
commit b84284f8a9
54 changed files with 1307 additions and 339 deletions

View File

@@ -0,0 +1,396 @@
"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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectService } from "@/lib/services/project.service";
import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Trash, Plus, Search } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { toast } from "sonner";
import apiClient from "@/lib/api/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { contractService } from "@/lib/services/contract.service";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { SearchContractDto, CreateContractDto, UpdateContractDto } from "@/types/dto/contract/contract.dto";
import { AxiosError } from "axios";
interface Project {
id: number;
projectCode: string;
projectName: string;
}
interface Contract {
id: number;
contractCode: string;
contractName: string;
projectId: number;
description?: string;
startDate?: string;
endDate?: string;
project?: {
projectCode: string;
projectName: string;
}
}
const contractSchema = z.object({
contractCode: z.string().min(1, "Contract Code is required"),
contractName: z.string().min(1, "Contract Name is required"),
projectId: z.string().min(1, "Project is required"),
description: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
});
type ContractFormData = z.infer<typeof contractSchema>;
const useContracts = (params?: SearchContractDto) => {
return useQuery({
queryKey: ['contracts', params],
queryFn: () => contractService.getAll(params),
});
};
const useProjectsList = () => {
return useQuery({
queryKey: ['projects-list'],
queryFn: () => projectService.getAll(),
});
};
export default function ContractsPage() {
const [search, setSearch] = useState("");
const { data: contracts, isLoading } = useContracts({ search: search || undefined });
const { data: projects } = useProjectsList();
const queryClient = useQueryClient();
const createContract = useMutation({
mutationFn: (data: CreateContractDto) => apiClient.post("/contracts", data).then(res => res.data),
onSuccess: () => {
toast.success("Contract created successfully");
queryClient.invalidateQueries({ queryKey: ['contracts'] });
setDialogOpen(false);
},
onError: (err: AxiosError<{ message: string }>) => toast.error(err.response?.data?.message || "Failed to create contract")
});
const updateContract = useMutation({
mutationFn: ({ id, data }: { id: number, data: UpdateContractDto }) => apiClient.patch(`/contracts/${id}`, data).then(res => res.data),
onSuccess: () => {
toast.success("Contract updated successfully");
queryClient.invalidateQueries({ queryKey: ['contracts'] });
setDialogOpen(false);
},
onError: (err: AxiosError<{ message: string }>) => toast.error(err.response?.data?.message || "Failed to update contract")
});
const deleteContract = useMutation({
mutationFn: (id: number) => apiClient.delete(`/contracts/${id}`).then(res => res.data),
onSuccess: () => {
toast.success("Contract deleted successfully");
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
onError: (err: AxiosError<{ message: string }>) => toast.error(err.response?.data?.message || "Failed to delete contract")
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
// Stats for Delete Dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
const handleDeleteClick = (contract: Contract) => {
setContractToDelete(contract);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (contractToDelete) {
deleteContract.mutate(contractToDelete.id, {
onSuccess: () => {
setDeleteDialogOpen(false);
setContractToDelete(null);
},
});
}
};
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<ContractFormData>({
resolver: zodResolver(contractSchema),
defaultValues: {
contractCode: "",
contractName: "",
projectId: "",
description: "",
},
});
const columns: ColumnDef<Contract>[] = [
{
accessorKey: "contractCode",
header: "Code",
cell: ({ row }) => <span className="font-medium">{row.original.contractCode}</span>
},
{ accessorKey: "contractName", header: "Name" },
{
accessorKey: "project.projectCode",
header: "Project",
cell: ({ row }) => row.original.project?.projectCode || "-"
},
{ accessorKey: "startDate", header: "Start Date" },
{ accessorKey: "endDate", header: "End Date" },
{
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 focus:text-red-600"
onClick={() => handleDeleteClick(row.original)}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
];
const handleEdit = (contract: Contract) => {
setEditingId(contract.id);
reset({
contractCode: contract.contractCode,
contractName: contract.contractName,
projectId: contract.projectId?.toString() || "",
description: contract.description || "",
startDate: contract.startDate ? new Date(contract.startDate).toISOString().split('T')[0] : "",
endDate: contract.endDate ? new Date(contract.endDate).toISOString().split('T')[0] : "",
});
setDialogOpen(true);
};
const handleCreate = () => {
setEditingId(null);
reset({
contractCode: "",
contractName: "",
projectId: "",
description: "",
startDate: "",
endDate: "",
});
setDialogOpen(true);
};
const onSubmit = (data: ContractFormData) => {
const submitData = {
...data,
projectId: parseInt(data.projectId),
};
if (editingId) {
updateContract.mutate({ id: editingId, data: submitData });
} else {
createContract.mutate(submitData);
}
};
return (
<div className="space-y-6 p-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Contracts</h1>
<p className="text-muted-foreground mt-1">Manage construction contracts</p>
</div>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" /> Add Contract
</Button>
</div>
<div className="flex items-center space-x-2 bg-muted/30 p-4 rounded-lg">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search contracts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 bg-background"
/>
</div>
</div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center space-x-4">
<Skeleton className="h-12 w-full" />
</div>
))}
</div>
) : (
<DataTable columns={columns} data={contracts || []} />
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? "Edit Contract" : "New Contract"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label>Project *</Label>
<Select
value={watch("projectId")}
onValueChange={(value) => setValue("projectId", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent>
{(projects as Project[])?.map((p) => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.projectCode} - {p.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.projectId && (
<p className="text-sm text-red-500">{errors.projectId.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Contract Code *</Label>
<Input
placeholder="e.g. C-001"
{...register("contractCode")}
/>
{errors.contractCode && (
<p className="text-sm text-red-500">{errors.contractCode.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Contract Name *</Label>
<Input
placeholder="e.g. Main Construction"
{...register("contractName")}
/>
{errors.contractName && (
<p className="text-sm text-red-500">{errors.contractName.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
placeholder="Optional description"
{...register("description")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" {...register("startDate")} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" {...register("endDate")} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createContract.isPending || updateContract.isPending}>
{editingId ? "Save Changes" : "Create Contract"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the contract
<span className="font-semibold text-foreground"> {contractToDelete?.contractCode} </span>
and remove it from the system.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteContract.isPending ? "Deleting..." : "Delete Contract"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService, ContractCategory, ContractSubCategory } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface Category {
id: number;
catCode: string;
catName: string;
description?: string;
sortOrder: number;
}
export default function ContractCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<Category>[] = [
{
accessorKey: "catCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("catCode")}
</Badge>
),
},
{
accessorKey: "catName",
header: "Category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Contract Drawing Categories</h1>
<p className="text-muted-foreground mt-1">
Manage main categories () for contract drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Category"
title="Contract Drawing Categories"
description="Manage main categories (หมวดหมู่หลัก) for contract drawings"
queryKey={["contract-drawing-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getContractCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createContractCategory({ ...data, projectId: selectedProjectId })}
updateFn={(id, data) => drawingMasterDataService.updateContractCategory(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractCategory(id)}
columns={columns}
fields={[
{ name: "catCode", label: "Category Code", type: "text", required: true },
{ name: "catName", label: "Category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
{/*
Note: For mapping, we should ideally have a separate "Mappings" column or action button.
Since GenericCrudTable might not support custom action columns easily without modification,
we are currently just listing categories. To add mapping functionality, we might need
to either extend GenericCrudTable or create a dedicated page for mappings.
Given the constraints, I will add a "Mapped Sub-categories" management section
that opens when clicking a category ROW or adding a custom action if GenericCrudTable supports it.
For now, let's assume we need to extend GenericCrudTable or replace it to support this specific requirement.
However, to keep it simple and consistent:
Let's add a separate section below the table or a dialog triggered by a custom cell.
*/}
<div className="mt-8 border-t pt-8">
<CategoryMappingSection projectId={selectedProjectId} />
</div>
</div>
);
}
function CategoryMappingSection({ projectId }: { projectId: number }) {
// ... logic to manage mappings would go here ...
// But to properly implement this, we need a full mapping UI.
// Let's defer this implementation pattern to a separate component to keep this file clean
// and just mount it here.
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Category Mappings (Map Sub-categories to Categories)</h2>
<div className="bg-muted/30 p-4 rounded-lg border-dashed border">
<p className="text-sm text-muted-foreground">Select a category to view and manage its sub-categories.</p>
{/*
Real implementation would be complex here.
Better approach: Add a "Manage Sub-categories" button to the Categories table if possible.
Or simpler: A separate "Mapping" page.
*/}
<ManageMappings projectId={projectId} />
</div>
</div>
)
}
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; // Use sonner instead of use-toast
function ManageMappings({ projectId }: { projectId: number }) {
const queryClient = useQueryClient();
const [selectedCat, setSelectedCat] = useState<string>("");
const [selectedSubCat, setSelectedSubCat] = useState<string>("");
const { data: categories = [] } = useQuery({
queryKey: ["contract-categories", String(projectId)],
queryFn: () => drawingMasterDataService.getContractCategories(projectId),
});
const { data: subCategories = [] } = useQuery({
queryKey: ["contract-sub-categories", String(projectId)],
queryFn: () => drawingMasterDataService.getContractSubCategories(projectId),
});
const { data: mappings = [] } = useQuery({
queryKey: ["contract-mappings", String(projectId), selectedCat],
queryFn: () => drawingMasterDataService.getContractMappings(projectId, selectedCat ? parseInt(selectedCat) : undefined),
enabled: !!selectedCat,
});
const createMutation = useMutation({
mutationFn: drawingMasterDataService.createContractMapping,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contract-mappings"] });
toast.success("Mapping created");
setSelectedSubCat("");
},
});
const deleteMutation = useMutation({
mutationFn: drawingMasterDataService.deleteContractMapping,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contract-mappings"] });
toast.success("Mapping removed");
}
});
const handleAdd = () => {
if (!selectedCat || !selectedSubCat) return;
createMutation.mutate({
projectId,
categoryId: parseInt(selectedCat),
subCategoryId: parseInt(selectedSubCat),
});
};
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Select Category</label>
<Select value={selectedCat} onValueChange={setSelectedCat}>
<SelectTrigger>
<SelectValue placeholder="Select Category..." />
</SelectTrigger>
<SelectContent>
{categories.map((c: ContractCategory) => (
<SelectItem key={c.id} value={String(c.id)}>{c.catCode} - {c.catName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedCat && (
<div className="space-y-4">
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Add Sub-Category</label>
<Select value={selectedSubCat} onValueChange={setSelectedSubCat}>
<SelectTrigger>
<SelectValue placeholder="Select Sub-Category to add..." />
</SelectTrigger>
<SelectContent>
{subCategories
.filter((s: ContractSubCategory) => !mappings.find((m: { subCategory: { id: number } }) => m.subCategory.id === s.id))
.map((s: ContractSubCategory) => (
<SelectItem key={s.id} value={String(s.id)}>{s.subCatCode} - {s.subCatName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleAdd} disabled={!selectedSubCat || createMutation.isPending}>
<Plus className="h-4 w-4 mr-2" /> Add
</Button>
</div>
<div className="border rounded-md">
<div className="p-2 bg-muted/50 font-medium text-sm grid grid-cols-[1fr,auto] gap-2">
<span>Mapped Sub-Categories</span>
<span>Action</span>
</div>
{mappings.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">No sub-categories mapped yet.</div>
) : (
<div className="divide-y">
{mappings.map((m: { id: number; subCategory: ContractSubCategory }) => (
<div key={m.id} className="p-2 grid grid-cols-[1fr,auto] gap-2 items-center">
<span className="text-sm">{m.subCategory.subCatCode} - {m.subCategory.subCatName}</span>
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(m.id)} disabled={deleteMutation.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface SubCategory {
id: number;
subCatCode: string;
subCatName: string;
description?: string;
sortOrder: number;
}
export default function ContractSubCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<SubCategory>[] = [
{
accessorKey: "subCatCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("subCatCode")}
</Badge>
),
},
{
accessorKey: "subCatName",
header: "Sub-category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Contract Drawing Sub-categories</h1>
<p className="text-muted-foreground mt-1">
Manage sub-categories () for contract drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage sub-categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Sub-category"
title="Contract Drawing Sub-categories"
description="Manage sub-categories (หมวดหมู่ย่อย) for contract drawings"
queryKey={["contract-drawing-sub-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getContractSubCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createContractSubCategory({ ...data, projectId: selectedProjectId })}
updateFn={(id, data) => drawingMasterDataService.updateContractSubCategory(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractSubCategory(id)}
columns={columns}
fields={[
{ name: "subCatCode", label: "Sub-category Code", type: "text", required: true },
{ name: "subCatName", label: "Sub-category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface Volume {
id: number;
volumeCode: string;
volumeName: string;
description?: string;
sortOrder: number;
}
export default function ContractVolumesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<Volume>[] = [
{
accessorKey: "volumeCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("volumeCode")}
</Badge>
),
},
{
accessorKey: "volumeName",
header: "Volume Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Contract Drawing Volumes</h1>
<p className="text-muted-foreground mt-1">
Manage drawing volumes () for contract drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage volumes.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Volume"
title="Contract Drawing Volumes"
description="Manage drawing volumes (เล่ม) for contract drawings"
queryKey={["contract-drawing-volumes", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getContractVolumes(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createContractVolume({ ...data, projectId: selectedProjectId })}
updateFn={(id, data) => drawingMasterDataService.updateContractVolume(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractVolume(id)}
columns={columns}
fields={[
{ name: "volumeCode", label: "Volume Code", type: "text", required: true },
{ name: "volumeName", label: "Volume Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
FileStack,
FolderTree,
Layers,
BookOpen,
FileBox
} from "lucide-react";
import Link from "next/link";
const contractDrawingMenu = [
{
title: "Volumes",
description: "Manage contract drawing volumes (เล่ม)",
href: "/admin/drawings/contract/volumes",
icon: BookOpen,
},
{
title: "Categories",
description: "Manage main categories (หมวดหมู่หลัก)",
href: "/admin/drawings/contract/categories",
icon: FolderTree,
},
{
title: "Sub-categories",
description: "Manage sub-categories (หมวดหมู่ย่อย)",
href: "/admin/drawings/contract/sub-categories",
icon: Layers,
},
];
const shopDrawingMenu = [
{
title: "Main Categories",
description: "Manage main categories (หมวดหมู่หลัก)",
href: "/admin/drawings/shop/main-categories",
icon: FolderTree,
},
{
title: "Sub-categories",
description: "Manage sub-categories (หมวดหมู่ย่อย)",
href: "/admin/drawings/shop/sub-categories",
icon: Layers,
},
];
export default function DrawingsAdminPage() {
return (
<div className="p-6 space-y-8">
<div>
<h1 className="text-2xl font-bold">Drawing Master Data</h1>
<p className="text-muted-foreground mt-1">
Manage categories and volumes for Contract and Shop Drawings
</p>
</div>
{/* Contract Drawings Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileStack className="h-5 w-5 text-blue-600" />
<h2 className="text-lg font-semibold">Contract Drawings</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{contractDrawingMenu.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full border-blue-200 hover:border-blue-400">
<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-blue-600" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
{/* Shop Drawings Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileBox className="h-5 w-5 text-green-600" />
<h2 className="text-lg font-semibold">Shop Drawings / As Built</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shopDrawingMenu.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full border-green-200 hover:border-green-400">
<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-green-600" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, CheckCircle, XCircle } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface MainCategory {
id: number;
mainCategoryCode: string;
mainCategoryName: string;
description?: string;
isActive: boolean;
sortOrder: number;
}
export default function ShopMainCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<MainCategory>[] = [
{
accessorKey: "mainCategoryCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("mainCategoryCode")}
</Badge>
),
},
{
accessorKey: "mainCategoryName",
header: "Category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "isActive",
header: "Active",
cell: ({ row }) => (
row.getValue("isActive") ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Shop Drawing Main Categories</h1>
<p className="text-muted-foreground mt-1">
Manage main categories () for shop drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage main categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Main Category"
title="Shop Drawing Main Categories"
description="Manage main categories (หมวดหมู่หลัก) for shop drawings"
queryKey={["shop-drawing-main-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getShopMainCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createShopMainCategory({
...data,
projectId: selectedProjectId,
isActive: data.isActive === "true" || data.isActive === true
})}
updateFn={(id, data) => drawingMasterDataService.updateShopMainCategory(id, {
...data,
isActive: data.isActive === "true" || data.isActive === true
})}
deleteFn={(id) => drawingMasterDataService.deleteShopMainCategory(id)}
columns={columns}
fields={[
{ name: "mainCategoryCode", label: "Category Code", type: "text", required: true },
{ name: "mainCategoryName", label: "Category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "isActive", label: "Active", type: "checkbox" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, CheckCircle, XCircle } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface SubCategory {
id: number;
subCategoryCode: string;
subCategoryName: string;
description?: string;
isActive: boolean;
sortOrder: number;
}
export default function ShopSubCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<SubCategory>[] = [
{
accessorKey: "subCategoryCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("subCategoryCode")}
</Badge>
),
},
{
accessorKey: "subCategoryName",
header: "Sub-category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "isActive",
header: "Active",
cell: ({ row }) => (
row.getValue("isActive") ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Shop Drawing Sub-categories</h1>
<p className="text-muted-foreground mt-1">
Manage sub-categories () for shop drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage sub-categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Sub-category"
title="Shop Drawing Sub-categories"
description="Manage sub-categories (หมวดหมู่ย่อย) for shop drawings"
queryKey={["shop-drawing-sub-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getShopSubCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createShopSubCategory({
...data,
projectId: selectedProjectId,
isActive: data.isActive === "true" || data.isActive === true
})}
updateFn={(id, data) => drawingMasterDataService.updateShopSubCategory(id, {
...data,
isActive: data.isActive === "true" || data.isActive === true
})}
deleteFn={(id) => drawingMasterDataService.deleteShopSubCategory(id)}
columns={columns}
fields={[
{ name: "subCategoryCode", label: "Sub-category Code", type: "text", required: true },
{ name: "subCategoryName", label: "Sub-category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "isActive", label: "Active", type: "checkbox" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useState, useEffect } from "react";
import { TemplateEditor } from "@/components/numbering/template-editor";
import { SequenceViewer } from "@/components/numbering/sequence-viewer";
import { numberingApi } from "@/lib/api/numbering";
import { NumberingTemplate } from "@/lib/api/numbering"; // Correct import
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCorrespondenceTypes, useContracts, useDisciplines } from "@/hooks/use-master-data";
import { useProjects } from "@/hooks/use-projects";
export default function EditTemplatePage({ params }: { params: { id: string } }) {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [template, setTemplate] = useState<NumberingTemplate | null>(null);
// Master Data
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
const { data: projects = [] } = useProjects();
const projectId = template?.projectId || 1;
const { data: contracts = [] } = useContracts(projectId);
const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName || 'LCBP3';
useEffect(() => {
const fetchTemplate = async () => {
setLoading(true);
try {
const data = await numberingApi.getTemplate(parseInt(params.id));
if (data) {
setTemplate(data);
}
} catch (error) {
console.error("Failed to fetch template", error);
} finally {
setLoading(false);
}
};
fetchTemplate();
}, [params.id]);
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate({ ...data, id: parseInt(params.id) });
router.push("/admin/numbering");
} catch (error) {
console.error("Failed to update template", error);
alert("Failed to update template");
}
};
const handleCancel = () => {
router.push("/admin/numbering");
};
if (loading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!template) {
return (
<div className="p-6">
<p className="text-muted-foreground">Template not found</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold">Edit Numbering Template</h1>
<Tabs defaultValue="config">
<TabsList>
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="sequences">Sequences</TabsTrigger>
</TabsList>
<TabsContent value="config" className="mt-4">
<TemplateEditor
template={template}
projectId={template.projectId || 1}
projectName={selectedProjectName}
correspondenceTypes={correspondenceTypes}
disciplines={disciplines}
onSave={handleSave}
onCancel={handleCancel}
/>
</TabsContent>
<TabsContent value="sequences" className="mt-4">
<SequenceViewer />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { TemplateEditor } from "@/components/numbering/template-editor";
import { numberingApi, NumberingTemplate } from "@/lib/api/numbering";
import { useRouter } from "next/navigation";
import { useCorrespondenceTypes, useContracts, useDisciplines } from "@/hooks/use-master-data";
import { useProjects } from "@/hooks/use-projects";
export default function NewTemplatePage() {
const router = useRouter();
// Master Data
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
const { data: projects = [] } = useProjects();
const projectId = 1; // Default or sync with selection
const { data: contracts = [] } = useContracts(projectId);
const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName = projects.find((p: any) => p.id === projectId)?.projectName || 'LCBP3';
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate(data);
router.push("/admin/numbering");
} catch (error) {
console.error("Failed to create template", error);
alert("Failed to create template");
}
};
const handleCancel = () => {
router.push("/admin/numbering");
};
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold">New Numbering Template</h1>
<TemplateEditor
projectId={projectId}
projectName={selectedProjectName}
correspondenceTypes={correspondenceTypes}
disciplines={disciplines}
onSave={handleSave}
onCancel={handleCancel}
/>
</div>
);
}

View File

@@ -0,0 +1,238 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Plus, Edit, Play } from 'lucide-react';
import { numberingApi, NumberingTemplate } from '@/lib/api/numbering';
import { TemplateEditor } from '@/components/numbering/template-editor';
import { SequenceViewer } from '@/components/numbering/sequence-viewer';
import { TemplateTester } from '@/components/numbering/template-tester';
import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
import { ManualOverrideForm } from '@/components/numbering/manual-override-form';
import { MetricsDashboard } from '@/components/numbering/metrics-dashboard';
import { AuditLogsTable } from '@/components/numbering/audit-logs-table';
import { VoidReplaceForm } from '@/components/numbering/void-replace-form';
import { CancelNumberForm } from '@/components/numbering/cancel-number-form';
import { BulkImportForm } from '@/components/numbering/bulk-import-form';
export default function NumberingPage() {
const { data: projects = [] } = useProjects();
const [selectedProjectId, setSelectedProjectId] = useState("1");
const [activeTab, setActiveTab] = useState("templates");
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
// View states
const [isEditing, setIsEditing] = useState(false);
const [activeTemplate, setActiveTemplate] = useState<NumberingTemplate | undefined>(undefined);
const [isTesting, setIsTesting] = useState(false);
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id.toString() === selectedProjectId)?.projectName || 'Unknown Project';
// Master Data
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
const { data: contracts = [] } = useContracts(Number(selectedProjectId));
const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId);
const loadTemplates = async () => {
try {
const response = await numberingApi.getTemplates();
// Handle wrapped response { data: [...] } or direct array
const data = Array.isArray(response) ? response : (response as { data?: NumberingTemplate[] })?.data ?? [];
setTemplates(data);
} catch {
toast.error("Failed to load templates");
setTemplates([]);
}
};
useEffect(() => {
loadTemplates();
}, []);
const handleEdit = (template?: NumberingTemplate) => {
setActiveTemplate(template);
setIsEditing(true);
};
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate(data);
toast.success(data.id ? "Template updated" : "Template created");
setIsEditing(false);
loadTemplates();
} catch {
toast.error("Failed to save template");
}
};
const handleTest = (template: NumberingTemplate) => {
setTestTemplate(template);
setIsTesting(true);
};
if (isEditing) {
return (
<div className="p-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4">
<TemplateEditor
template={activeTemplate}
projectId={Number(selectedProjectId)}
projectName={selectedProjectName}
correspondenceTypes={correspondenceTypes}
disciplines={disciplines}
onSave={handleSave}
onCancel={() => setIsEditing(false)}
/>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Document Numbering
</h1>
<p className="text-muted-foreground mt-1">
Manage numbering templates, audit logs, and tools
</p>
</div>
<div className="flex gap-2">
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectCode: string; projectName: string }) => (
<SelectItem key={project.id} value={project.id.toString()}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="templates">Templates</TabsTrigger>
<TabsTrigger value="metrics">Metrics & Audit</TabsTrigger>
<TabsTrigger value="tools">Admin Tools</TabsTrigger>
</TabsList>
<TabsContent value="templates" className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => handleEdit(undefined)}>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="grid gap-4">
{templates
.filter(t => !t.projectId || t.projectId === Number(selectedProjectId))
.map((template) => (
<Card key={template.id} className="p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.correspondenceType?.typeName || 'Default Format'}
</h3>
<Badge variant="outline" className="text-xs">
{template.project?.projectCode || selectedProjectName}
</Badge>
{template.description && <Badge variant="secondary">{template.description}</Badge>}
</div>
<div className="bg-slate-100 dark:bg-slate-900 rounded px-3 py-2 mb-3 font-mono text-sm inline-block border">
{template.formatTemplate}
</div>
<div className="grid grid-cols-2 gap-4 text-sm mt-2">
<div>
<span className="text-muted-foreground">Type Code: </span>
<span className="font-medium font-mono text-green-600 dark:text-green-400">
{template.correspondenceType?.typeCode || 'DEFAULT'}
</span>
</div>
<div>
<span className="text-muted-foreground">Reset: </span>
<span>
{template.resetSequenceYearly ? 'Annually' : 'Continuous'}
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(template)}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="outline" size="sm" onClick={() => handleTest(template)}>
<Play className="mr-2 h-4 w-4" />
Test
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
<div className="space-y-4">
<SequenceViewer />
</div>
</div>
</TabsContent>
<TabsContent value="metrics" className="space-y-4">
<MetricsDashboard />
<div className="mt-6">
<h3 className="text-lg font-medium mb-4">Audit Logs</h3>
<AuditLogsTable />
</div>
</TabsContent>
<TabsContent value="tools" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<ManualOverrideForm projectId={Number(selectedProjectId)} />
<VoidReplaceForm projectId={Number(selectedProjectId)} />
<CancelNumberForm />
<div className="md:col-span-2">
<BulkImportForm projectId={Number(selectedProjectId)} />
</div>
</div>
</TabsContent>
</Tabs>
<TemplateTester
open={isTesting}
onOpenChange={setIsTesting}
template={testTemplate}
/>
</div>
);
}

View File

@@ -0,0 +1,313 @@
"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,
DialogFooter,
} from "@/components/ui/dialog";
import {
useProjects,
useCreateProject,
useUpdateProject,
useDeleteProject,
} from "@/hooks/use-projects";
import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Trash, Plus, Folder, Search as SearchIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
interface Project {
id: number;
projectCode: string;
projectName: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
const projectSchema = z.object({
projectCode: z.string().min(1, "Project Code is required"),
projectName: z.string().min(1, "Project Name is required"),
isActive: z.boolean().optional(),
});
type ProjectFormData = z.infer<typeof projectSchema>;
export default function ProjectsPage() {
const [search, setSearch] = useState("");
const { data: projects, isLoading } = useProjects({ search: search || undefined });
const createProject = useCreateProject();
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
// Stats for Delete Dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const handleDeleteClick = (project: Project) => {
setProjectToDelete(project);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (projectToDelete) {
deleteProject.mutate(projectToDelete.id, {
onSuccess: () => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
},
});
}
};
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<ProjectFormData>({
resolver: zodResolver(projectSchema),
defaultValues: {
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 focus:text-red-600"
onClick={() => handleDeleteClick(row.original)}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
const handleEdit = (project: Project) => {
setEditingId(project.id);
reset({
projectCode: project.projectCode,
projectName: project.projectName,
isActive: project.isActive,
});
setDialogOpen(true);
};
const handleCreate = () => {
setEditingId(null);
reset({
projectCode: "",
projectName: "",
isActive: true,
});
setDialogOpen(true);
};
const onSubmit = (data: ProjectFormData) => {
if (editingId) {
updateProject.mutate(
{ id: editingId, data },
{
onSuccess: () => setDialogOpen(false),
}
);
} else {
createProject.mutate(data, {
onSuccess: () => setDialogOpen(false),
});
}
};
return (
<div className="space-y-6 p-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={handleCreate}>
<Plus className="mr-2 h-4 w-4" /> Add Project
</Button>
</div>
<div className="flex items-center space-x-2 bg-muted/30 p-4 rounded-lg">
<div className="relative flex-1 max-w-sm">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects by code or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 bg-background"
/>
</div>
</div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center space-x-4">
<Skeleton className="h-12 w-full" />
</div>
))}
</div>
) : (
<DataTable columns={columns} data={projects || []} />
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingId ? "Edit Project" : "New Project"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label>Project Code *</Label>
<Input
placeholder="e.g. LCBP3"
{...register("projectCode")}
disabled={!!editingId} // Code is immutable after creation usually
/>
{errors.projectCode && (
<p className="text-sm text-red-500">{errors.projectCode.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Project Name *</Label>
<Input
placeholder="Full project name"
{...register("projectName")}
/>
{errors.projectName && (
<p className="text-sm text-red-500">{errors.projectName.message}</p>
)}
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch
id="active"
checked={watch("isActive")}
onCheckedChange={(checked) => setValue("isActive", checked)}
/>
<Label htmlFor="active">Active Status</Label>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={createProject.isPending || updateProject.isPending}
>
{editingId ? "Save Changes" : "Create Project"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the project
<span className="font-semibold text-foreground"> {projectToDelete?.projectCode} </span>
and remove it from the system.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteProject.isPending ? "Deleting..." : "Delete Project"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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 CorrespondenceTypesPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "typeCode",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("typeCode")}</span>
),
},
{
accessorKey: "typeName",
header: "Name",
},
{
accessorKey: "sortOrder",
header: "Sort Order",
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => (
<span
className={`px-2 py-1 rounded-full text-xs ${
row.getValue("isActive")
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{row.getValue("isActive") ? "Active" : "Inactive"}
</span>
),
},
];
return (
<div className="p-6">
<GenericCrudTable
entityName="Correspondence Type"
title="Correspondence Types Management"
description="Manage global correspondence types (e.g., LETTER, TRANSMITTAL)"
queryKey={["correspondence-types"]}
fetchFn={() => masterDataService.getCorrespondenceTypes()}
createFn={(data) => masterDataService.createCorrespondenceType(data)}
updateFn={(id, data) => masterDataService.updateCorrespondenceType(id, data)}
deleteFn={(id) => masterDataService.deleteCorrespondenceType(id)}
columns={columns}
fields={[
{ name: "typeCode", label: "Code", type: "text", required: true },
{ name: "typeName", label: "Name", type: "text", required: true },
{ name: "sortOrder", label: "Sort Order", type: "text" },
{ name: "isActive", label: "Active", type: "checkbox" },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { contractService } from "@/lib/services/contract.service";
import { ColumnDef } from "@tanstack/react-table";
import { useState, useEffect } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function DisciplinesPage() {
const [contracts, setContracts] = useState<any[]>([]);
const [selectedContractId, setSelectedContractId] = useState<string | null>(
null
);
useEffect(() => {
// Fetch contracts for filter and form options
contractService.getAll().then((data) => {
setContracts(Array.isArray(data) ? data : []);
}).catch(err => {
console.error("Failed to load contracts:", err);
setContracts([]);
});
}, []);
const columns: ColumnDef<any>[] = [
{
accessorKey: "disciplineCode",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">
{row.getValue("disciplineCode")}
</span>
),
},
{
accessorKey: "codeNameTh",
header: "Name (TH)",
},
{
accessorKey: "codeNameEn",
header: "Name (EN)",
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => (
<span
className={`px-2 py-1 rounded-full text-xs ${
row.getValue("isActive")
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{row.getValue("isActive") ? "Active" : "Inactive"}
</span>
),
},
];
const contractOptions = contracts.map((c) => ({
label: `${c.contractName} (${c.contractNo})`,
value: c.id,
}));
return (
<div className="p-6">
<GenericCrudTable
entityName="Discipline"
title="Disciplines Management"
description="Manage system disciplines (e.g., ARCH, STR, MEC)"
queryKey={["disciplines", selectedContractId ?? "all"]}
fetchFn={() =>
masterDataService.getDisciplines(
selectedContractId ? parseInt(selectedContractId) : undefined
)
}
createFn={(data) => masterDataService.createDiscipline(data)}
updateFn={(id, data) => Promise.reject("Not implemented yet")} // Update endpoint needs to be verified/added if missing
deleteFn={(id) => masterDataService.deleteDiscipline(id)}
columns={columns}
filters={
<div className="w-[300px]">
<Select
value={selectedContractId || "all"}
onValueChange={(val) =>
setSelectedContractId(val === "all" ? null : val)
}
>
<SelectTrigger>
<SelectValue placeholder="Filter by Contract" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
{c.contractName} ({c.contractNo})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
fields={[
{
name: "contractId",
label: "Contract",
type: "select",
required: true,
options: contractOptions,
},
{
name: "disciplineCode",
label: "Code",
type: "text",
required: true,
},
{
name: "codeNameTh",
label: "Name (TH)",
type: "text",
required: true,
},
{ name: "codeNameEn", label: "Name (EN)", type: "text" },
{ name: "isActive", 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: "subTypeCode",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("subTypeCode")}</span>
),
},
{
accessorKey: "subTypeName",
header: "Name",
},
{
accessorKey: "subTypeNumber",
header: "Running Code",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("subTypeNumber") || "-"}</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, correspondenceTypeId: 3 })} // Assuming 3 is Drawings, hardcoded for now to prevent error
updateFn={() => Promise.reject("Not implemented yet")}
deleteFn={() => Promise.reject("Not implemented yet")} // Delete might be restricted
columns={columns}
fields={[
{ name: "subTypeCode", label: "Code", type: "text", required: true },
{ name: "subTypeName", label: "Name", type: "text", required: true },
{ name: "subTypeNumber", label: "Running Code", type: "text" },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,63 @@
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,
},
{
title: "Drawing Categories",
description: "Manage drawing sub-types and classifications",
href: "/admin/reference/drawing-categories",
icon: Layers,
},
];
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,128 @@
"use client";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { contractService } from "@/lib/services/contract.service";
import { ColumnDef } from "@tanstack/react-table";
import { useState, useEffect } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function RfaTypesPage() {
const [contracts, setContracts] = useState<any[]>([]);
const [selectedContractId, setSelectedContractId] = useState<string | null>(
null
);
useEffect(() => {
// Fetch contracts for filter and form options
contractService.getAll().then((data) => {
setContracts(Array.isArray(data) ? data : []);
}).catch(err => {
console.error("Failed to load contracts:", err);
setContracts([]);
});
}, []);
const columns: ColumnDef<any>[] = [
{
accessorKey: "typeCode",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("typeCode")}</span>
),
},
{
accessorKey: "typeNameTh",
header: "Name (TH)",
},
{
accessorKey: "typeNameEn",
header: "Name (EN)",
},
{
accessorKey: "remark",
header: "Remark",
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => (
<span
className={`px-2 py-1 rounded-full text-xs ${
row.getValue("isActive")
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{row.getValue("isActive") ? "Active" : "Inactive"}
</span>
),
},
];
const contractOptions = contracts.map((c) => ({
label: `${c.contractName} (${c.contractNo})`,
value: c.id,
}));
return (
<div className="p-6">
<GenericCrudTable
entityName="RFA Type"
title="RFA Types Management"
queryKey={["rfa-types", selectedContractId ?? "all"]}
fetchFn={() =>
masterDataService.getRfaTypes(
selectedContractId ? parseInt(selectedContractId) : undefined
)
}
createFn={(data) => masterDataService.createRfaType(data)}
updateFn={(id, data) => masterDataService.updateRfaType(id, data)}
deleteFn={(id) => masterDataService.deleteRfaType(id)}
columns={columns}
filters={
<div className="w-[300px]">
<Select
value={selectedContractId || "all"}
onValueChange={(val) =>
setSelectedContractId(val === "all" ? null : val)
}
>
<SelectTrigger>
<SelectValue placeholder="Filter by Contract" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
{c.contractName} ({c.contractNo})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
fields={[
{
name: "contractId",
label: "Contract",
type: "select",
required: true,
options: contractOptions,
},
{ name: "typeCode", label: "Code", type: "text", required: true },
{ name: "typeNameTh", label: "Name (TH)", type: "text", required: true },
{ name: "typeNameEn", label: "Name (EN)", type: "text" },
{ name: "remark", label: "Remark", type: "textarea" },
{ name: "isActive", label: "Active", type: "checkbox" },
]}
/>
</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,206 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DSLEditor } from '@/components/workflows/dsl-editor';
import { VisualWorkflowBuilder } from '@/components/workflows/visual-builder';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { workflowApi } from '@/lib/api/workflows';
import { Workflow, CreateWorkflowDto } from '@/types/workflow';
import { toast } from 'sonner';
import { Save, ArrowLeft, Loader2 } from 'lucide-react';
import Link from 'next/link';
export default function WorkflowEditPage() {
const params = useParams();
const router = useRouter();
const id = params?.id === 'new' ? null : Number(params?.id);
const [loading, setLoading] = useState(!!id);
const [saving, setSaving] = useState(false);
const [workflowData, setWorkflowData] = useState<Partial<Workflow>>({
workflowName: '',
description: '',
workflowType: 'CORRESPONDENCE',
dslDefinition: 'name: New Workflow\nversion: 1.0\nsteps: []',
isActive: true,
});
useEffect(() => {
if (id) {
const fetchWorkflow = async () => {
try {
const data = await workflowApi.getWorkflow(id);
if (data) {
setWorkflowData(data);
} else {
toast.error("Workflow not found");
router.push('/admin/workflows');
}
} catch (error) {
toast.error("Failed to load workflow");
console.error(error);
} finally {
setLoading(false);
}
};
fetchWorkflow();
}
}, [id, router]);
const handleSave = async () => {
if (!workflowData.workflowName) {
toast.error("Workflow name is required");
return;
}
setSaving(true);
try {
const dto: CreateWorkflowDto = {
workflowName: workflowData.workflowName || '',
description: workflowData.description || '',
workflowType: workflowData.workflowType || 'CORRESPONDENCE',
dslDefinition: workflowData.dslDefinition || '',
};
if (id) {
await workflowApi.updateWorkflow(id, dto);
toast.success("Workflow updated successfully");
} else {
await workflowApi.createWorkflow(dto);
toast.success("Workflow created successfully");
router.push('/admin/workflows');
}
} catch (error) {
toast.error("Failed to save workflow");
console.error(error);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<Link href="/admin/workflows">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">{id ? 'Edit Workflow' : 'New Workflow'}</h1>
<p className="text-muted-foreground">{id ? `Version ${workflowData.version}` : 'Create a new workflow definition'}</p>
</div>
</div>
<div className="flex gap-2">
<Link href="/admin/workflows">
<Button variant="outline">Cancel</Button>
</Link>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
{id ? 'Save Changes' : 'Create Workflow'}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-6">
<Card className="p-6">
<div className="grid gap-4">
<div>
<Label htmlFor="name">Workflow Name *</Label>
<Input
id="name"
value={workflowData.workflowName}
onChange={(e) =>
setWorkflowData({
...workflowData,
workflowName: e.target.value,
})
}
placeholder="e.g. Standard RFA Workflow"
/>
</div>
<div>
<Label htmlFor="desc">Description</Label>
<Textarea
id="desc"
value={workflowData.description}
onChange={(e) =>
setWorkflowData({
...workflowData,
description: e.target.value,
})
}
placeholder="Describe the purpose of this workflow"
/>
</div>
<div>
<Label htmlFor="type">Workflow Type</Label>
<Select
value={workflowData.workflowType}
onValueChange={(value: Workflow['workflowType']) =>
setWorkflowData({ ...workflowData, workflowType: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CORRESPONDENCE">Correspondence</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="DRAWING">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
</div>
<div className="lg:col-span-2">
<Tabs defaultValue="dsl" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="dsl">DSL Editor</TabsTrigger>
<TabsTrigger value="visual">Visual Builder</TabsTrigger>
</TabsList>
<TabsContent value="dsl" className="mt-4">
<DSLEditor
initialValue={workflowData.dslDefinition}
onChange={(value) =>
setWorkflowData({ ...workflowData, dslDefinition: value })
}
/>
</TabsContent>
<TabsContent value="visual" className="mt-4 h-[600px]">
<VisualWorkflowBuilder
dslString={workflowData.dslDefinition}
onDslChange={(newDsl) => setWorkflowData({ ...workflowData, dslDefinition: newDsl })}
onSave={() => toast.info("Visual state saving not implemented in this demo")}
/>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DSLEditor } from "@/components/workflows/dsl-editor";
import { VisualWorkflowBuilder } from "@/components/workflows/visual-builder";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { workflowApi } from "@/lib/api/workflows";
import { WorkflowType } from "@/types/workflow";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
export default function NewWorkflowPage() {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [workflowData, setWorkflowData] = useState({
workflowName: "",
description: "",
workflowType: "CORRESPONDENCE" as WorkflowType,
dslDefinition: 'name: New Workflow\nversion: 1.0\nsteps: []',
});
const handleSave = async () => {
setSaving(true);
try {
await workflowApi.createWorkflow(workflowData);
router.push("/admin/workflows");
} catch (error) {
console.error("Failed to create workflow", error);
alert("Failed to create workflow");
} finally {
setSaving(false);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">New Workflow</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>Cancel</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Workflow
</Button>
</div>
</div>
<Card className="p-6">
<div className="grid gap-4">
<div>
<Label htmlFor="workflow_name">Workflow Name *</Label>
<Input
id="workflow_name"
value={workflowData.workflowName}
onChange={(e) =>
setWorkflowData({
...workflowData,
workflowName: e.target.value,
})
}
placeholder="e.g., Special RFA Approval"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={workflowData.description}
onChange={(e) =>
setWorkflowData({
...workflowData,
description: e.target.value,
})
}
placeholder="Describe the purpose of this workflow"
/>
</div>
<div>
<Label htmlFor="workflow_type">Workflow Type</Label>
<Select
value={workflowData.workflowType}
onValueChange={(value) =>
setWorkflowData({ ...workflowData, workflowType: value as WorkflowType })
}
>
<SelectTrigger id="workflow_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CORRESPONDENCE">Correspondence</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="DRAWING">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
<Tabs defaultValue="dsl">
<TabsList>
<TabsTrigger value="dsl">DSL Editor</TabsTrigger>
<TabsTrigger value="visual">Visual Builder</TabsTrigger>
</TabsList>
<TabsContent value="dsl" className="mt-4">
<DSLEditor
initialValue={workflowData.dslDefinition}
onChange={(value) =>
setWorkflowData({ ...workflowData, dslDefinition: value })
}
/>
</TabsContent>
<TabsContent value="visual" className="mt-4">
<VisualWorkflowBuilder />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Edit, Copy, Trash, Loader2 } from "lucide-react";
import Link from "next/link";
import { Workflow } from "@/types/workflow";
import { workflowApi } from "@/lib/api/workflows";
export default function WorkflowsPage() {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchWorkflows = async () => {
setLoading(true);
try {
const data = await workflowApi.getWorkflows();
setWorkflows(data);
} catch (error) {
console.error("Failed to fetch workflows", error);
} finally {
setLoading(false);
}
};
fetchWorkflows();
}, []);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Workflow Configuration</h1>
<p className="text-muted-foreground mt-1">
Manage workflow definitions and routing rules
</p>
</div>
<Link href="/admin/workflows/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Workflow
</Button>
</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-4">
{workflows.map((workflow) => (
<Card key={workflow.workflowId} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{workflow.workflowName}
</h3>
<Badge variant={workflow.isActive ? "default" : "secondary"} className={workflow.isActive ? "bg-green-600 hover:bg-green-700" : ""}>
{workflow.isActive ? "Active" : "Inactive"}
</Badge>
<Badge variant="outline">v{workflow.version}</Badge>
</div>
<p className="text-sm text-muted-foreground mb-3">
{workflow.description}
</p>
<div className="flex gap-6 text-sm text-muted-foreground">
<span>Type: {workflow.workflowType}</span>
<span>Steps: {workflow.stepCount}</span>
<span>
Updated:{" "}
{new Date(workflow.updatedAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/workflows/${workflow.workflowId}/edit`}>
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => alert("Clone functionality mocked")}>
<Copy className="mr-2 h-4 w-4" />
Clone
</Button>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive" onClick={() => alert("Delete functionality mocked")}>
<Trash className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}