260218:1712 20260218 TASK-BEFE-001n
All checks were successful
Build and Deploy / deploy (push) Successful in 4m55s
All checks were successful
Build and Deploy / deploy (push) Successful in 4m55s
This commit is contained in:
396
frontend/app/(admin)/admin/doc-control/contracts/page.tsx
Normal file
396
frontend/app/(admin)/admin/doc-control/contracts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
114
frontend/app/(admin)/admin/doc-control/drawings/page.tsx
Normal file
114
frontend/app/(admin)/admin/doc-control/drawings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
238
frontend/app/(admin)/admin/doc-control/numbering/page.tsx
Normal file
238
frontend/app/(admin)/admin/doc-control/numbering/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
frontend/app/(admin)/admin/doc-control/projects/page.tsx
Normal file
313
frontend/app/(admin)/admin/doc-control/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
63
frontend/app/(admin)/admin/doc-control/reference/page.tsx
Normal file
63
frontend/app/(admin)/admin/doc-control/reference/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
134
frontend/app/(admin)/admin/doc-control/workflows/new/page.tsx
Normal file
134
frontend/app/(admin)/admin/doc-control/workflows/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
frontend/app/(admin)/admin/doc-control/workflows/page.tsx
Normal file
104
frontend/app/(admin)/admin/doc-control/workflows/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user