260322:1648 Correct Coresspondence / Doing RFA / Correct CI
CI Pipeline / build (push) Failing after 12m41s
Build and Deploy / deploy (push) Failing after 2m44s

This commit is contained in:
admin
2026-03-22 16:48:12 +07:00
parent e5deedb42e
commit 11984bfa29
683 changed files with 105251 additions and 29068 deletions
@@ -1,24 +1,21 @@
"use client";
'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 { Badge } from "@/components/ui/badge";
import {
useOrganizations,
useDeleteOrganization,
} from "@/hooks/use-master-data";
import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Trash, Plus, Search, MoreHorizontal } from "lucide-react";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { useOrganizations, useDeleteOrganization } from '@/hooks/use-master-data';
import { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash, Plus, Search, MoreHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Organization } from "@/types/organization";
import { OrganizationDialog } from "@/components/admin/organization-dialog";
} from '@/components/ui/dropdown-menu';
import { Organization } from '@/types/organization';
import { OrganizationDialog } from '@/components/admin/organization-dialog';
import {
AlertDialog,
AlertDialogAction,
@@ -28,20 +25,20 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
// Organization role types for display
const ORGANIZATION_ROLES = [
{ value: "1", label: "Owner" },
{ value: "2", label: "Designer" },
{ value: "3", label: "Consultant" },
{ value: "4", label: "Contractor" },
{ value: "5", label: "Third Party" },
{ value: '1', label: 'Owner' },
{ value: '2', label: 'Designer' },
{ value: '3', label: 'Consultant' },
{ value: '4', label: 'Contractor' },
{ value: '5', label: 'Third Party' },
] as const;
export default function OrganizationsPage() {
const [search, setSearch] = useState("");
const [search, setSearch] = useState('');
const { data: organizations, isLoading } = useOrganizations({
search: search || undefined,
});
@@ -49,8 +46,7 @@ export default function OrganizationsPage() {
const deleteOrg = useDeleteOrganization();
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedOrganization, setSelectedOrganization] =
useState<Organization | null>(null);
const [selectedOrganization, setSelectedOrganization] = useState<Organization | null>(null);
// Stats for Delete Dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -74,44 +70,40 @@ export default function OrganizationsPage() {
const columns: ColumnDef<Organization>[] = [
{
accessorKey: "organizationCode",
header: "Code",
cell: ({ row }) => (
<span className="font-medium">{row.original.organizationCode}</span>
),
accessorKey: 'organizationCode',
header: 'Code',
cell: ({ row }) => <span className="font-medium">{row.original.organizationCode}</span>,
},
{ accessorKey: "organizationName", header: "Name" },
{ accessorKey: 'organizationName', header: 'Name' },
{
accessorKey: "roleId",
header: "Role",
accessorKey: 'roleId',
header: 'Role',
cell: ({ row }) => {
const roleId = row.getValue("roleId") as number;
const role = ORGANIZATION_ROLES.find(
(r) => r.value === roleId?.toString()
);
return role ? role.label : "-";
const roleId = row.getValue('roleId') as number;
const role = ORGANIZATION_ROLES.find((r) => r.value === roleId?.toString());
return role ? role.label : '-';
},
},
{
accessorKey: "isActive",
header: "Status",
accessorKey: 'isActive',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.isActive ? "default" : "destructive"}>
{row.original.isActive ? "Active" : "Inactive"}
<Badge variant={row.original.isActive ? 'default' : 'destructive'}>
{row.original.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
accessorKey: "createdAt",
header: "Created At",
accessorKey: 'createdAt',
header: 'Created At',
cell: ({ row }) => {
if (!row.original.createdAt) return "-";
return new Date(row.original.createdAt).toLocaleDateString("en-GB");
if (!row.original.createdAt) return '-';
return new Date(row.original.createdAt).toLocaleDateString('en-GB');
},
},
{
id: "actions",
header: "Actions",
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const org = row.original;
return (
@@ -131,10 +123,7 @@ export default function OrganizationsPage() {
>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={() => handleDeleteClick(org)}
>
<DropdownMenuItem className="text-red-600 focus:text-red-600" onClick={() => handleDeleteClick(org)}>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
@@ -149,9 +138,7 @@ export default function OrganizationsPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Organizations</h1>
<p className="text-muted-foreground mt-1">
Manage project organizations system-wide
</p>
<p className="text-muted-foreground mt-1">Manage project organizations system-wide</p>
</div>
<Button
onClick={() => {
@@ -176,22 +163,18 @@ export default function OrganizationsPage() {
</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>
<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={organizations || []} />
)}
<OrganizationDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
organization={selectedOrganization}
/>
<OrganizationDialog open={dialogOpen} onOpenChange={setDialogOpen} organization={selectedOrganization} />
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
@@ -205,11 +188,8 @@ export default function OrganizationsPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteOrg.isPending ? "Deleting..." : "Delete Organization"}
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
{deleteOrg.isPending ? 'Deleting...' : 'Delete Organization'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,7 +1,7 @@
"use client";
'use client';
import { RbacMatrix } from "@/components/admin/security/rbac-matrix";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RbacMatrix } from '@/components/admin/security/rbac-matrix';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function RolesPage() {
return (
@@ -9,9 +9,7 @@ export default function RolesPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Roles & Permissions</h1>
<p className="text-muted-foreground">
Manage system roles and their assigned permissions
</p>
<p className="text-muted-foreground">Manage system roles and their assigned permissions</p>
</div>
</div>
@@ -1,29 +1,23 @@
"use client";
'use client';
import { useUsers, useDeleteUser } from "@/hooks/use-users";
import { useOrganizations } from "@/hooks/use-master-data";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/common/data-table";
import { Plus, MoreHorizontal, Pencil, Trash, Search } from "lucide-react";
import { useState } from "react";
import { UserDialog } from "@/components/admin/user-dialog";
import { useUsers, useDeleteUser } from '@/hooks/use-users';
import { useOrganizations } from '@/hooks/use-master-data';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { Plus, MoreHorizontal, Pencil, Trash, Search } from 'lucide-react';
import { useState } from 'react';
import { UserDialog } from '@/components/admin/user-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { ColumnDef } from "@tanstack/react-table";
import { User } from "@/types/user";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { ColumnDef } from '@tanstack/react-table';
import { User } from '@/types/user';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
@@ -33,17 +27,22 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { Organization } from "@/types/organization";
import { getApiErrorMessage } from "@/types/api-error";
import { _Organization } from '@/types/organization';
import { getApiErrorMessage } from '@/types/api-error';
export default function UsersPage() {
const [search, setSearch] = useState("");
const [search, setSearch] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const { data: users, isLoading, isError, error } = useUsers({
const {
data: users,
isLoading,
isError,
error,
} = useUsers({
search: search || undefined,
primaryOrganizationId: selectedOrgId ?? undefined,
});
@@ -69,45 +68,46 @@ export default function UsersPage() {
if (userToDelete) {
deleteMutation.mutate(userToDelete.uuid, {
onSuccess: () => {
setDeleteDialogOpen(false);
setUserToDelete(null);
setDeleteDialogOpen(false);
setUserToDelete(null);
},
});
}
};
const columns: ColumnDef<User>[] = [
{
accessorKey: "username",
header: "Username",
cell: ({ row }) => <span className="font-semibold">{row.original.username}</span>
accessorKey: 'username',
header: 'Username',
cell: ({ row }) => <span className="font-semibold">{row.original.username}</span>,
},
{
accessorKey: "email",
header: "Email",
accessorKey: 'email',
header: 'Email',
},
{
id: "name",
header: "Name",
id: 'name',
header: 'Name',
cell: ({ row }) => `${row.original.firstName} ${row.original.lastName}`,
},
{
id: "organization",
header: "Organization",
id: 'organization',
header: 'Organization',
cell: ({ row }) => {
const orgId = row.original.primaryOrganizationId;
if (!orgId) {
return "All Organizations";
return 'All Organizations';
}
const org = organizationList.find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString());
return org ? org.organizationCode : "All Organizations";
const org = organizationList.find(
(o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString()
);
return org ? org.organizationCode : 'All Organizations';
},
},
{
id: "roles",
header: "Roles",
id: 'roles',
header: 'Roles',
cell: ({ row }) => {
const roles = row.original.roles || [];
return (
@@ -119,20 +119,20 @@ export default function UsersPage() {
))}
</div>
);
}
},
},
{
accessorKey: "isActive",
header: "Status",
accessorKey: 'isActive',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.isActive ? "default" : "secondary"}>
{row.original.isActive ? "Active" : "Inactive"}
<Badge variant={row.original.isActive ? 'default' : 'secondary'}>
{row.original.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
id: "actions",
header: "Actions",
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const user = row.original;
return (
@@ -144,20 +144,22 @@ export default function UsersPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setSelectedUser(user); setDialogOpen(true); }}>
<DropdownMenuItem
onClick={() => {
setSelectedUser(user);
setDialogOpen(true);
}}
>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={() => handleDeleteClick(user)}
>
<DropdownMenuItem className="text-red-600 focus:text-red-600" onClick={() => handleDeleteClick(user)}>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
}
);
},
},
];
return (
@@ -167,64 +169,62 @@ export default function UsersPage() {
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground mt-1">Manage system users and roles</p>
</div>
<Button onClick={() => { setSelectedUser(null); setDialogOpen(true); }}>
<Button
onClick={() => {
setSelectedUser(null);
setDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" /> Add User
</Button>
</div>
<div className="flex gap-4 items-center 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 users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 bg-background"
/>
</div>
<div className="w-[250px]">
<Select
value={selectedOrgId || "all"}
onValueChange={(val) => setSelectedOrgId(val === "all" ? null : val)}
>
<SelectTrigger className="bg-background">
<SelectValue placeholder="All Organizations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Organizations</SelectItem>
{organizationList.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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 users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 bg-background"
/>
</div>
<div className="w-[250px]">
<Select value={selectedOrgId || 'all'} onValueChange={(val) => setSelectedOrgId(val === 'all' ? null : val)}>
<SelectTrigger className="bg-background">
<SelectValue placeholder="All Organizations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Organizations</SelectItem>
{organizationList.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{getApiErrorMessage(error, "Failed to load users")}
{getApiErrorMessage(error, 'Failed to load users')}
</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>
<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={userList} />
<DataTable columns={columns} data={userList} />
)}
<UserDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
user={selectedUser}
/>
<UserDialog open={dialogOpen} onOpenChange={setDialogOpen} user={selectedUser} />
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
@@ -238,11 +238,8 @@ export default function UsersPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteMutation.isPending ? "Deleting..." : "Delete User"}
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
{deleteMutation.isPending ? 'Deleting...' : 'Delete User'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,40 +1,28 @@
"use client";
'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 { 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 { 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";
} 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,
@@ -44,36 +32,36 @@ import {
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";
} 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 {
interface _Project {
id: string; // ADR-019: uuid exposed as 'id'
projectCode: string;
projectName: string;
}
interface Contract {
id: string; // ADR-019: uuid exposed as 'id'
contractCode: string;
contractName: string;
projectId: number;
description?: string;
startDate?: string;
endDate?: string;
project?: {
id: string; // ADR-019: project uuid exposed as 'id'
projectCode: string;
projectName: string;
}
id: string; // ADR-019: uuid exposed as 'id'
contractCode: string;
contractName: string;
projectId: number;
description?: string;
startDate?: string;
endDate?: string;
project?: {
id: string; // ADR-019: project uuid exposed as 'id'
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"),
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(),
@@ -82,53 +70,57 @@ const contractSchema = z.object({
type ContractFormData = z.infer<typeof contractSchema>;
const useContracts = (params?: SearchContractDto) => {
return useQuery({
queryKey: ['contracts', params],
queryFn: () => contractService.getAll(params),
});
return useQuery({
queryKey: ['contracts', params],
queryFn: () => contractService.getAll(params),
});
};
const useProjectsList = () => {
return useQuery({
queryKey: ['projects-list'],
queryFn: () => projectService.getAll(),
});
return useQuery({
queryKey: ['projects-list'],
queryFn: () => projectService.getAll(),
});
};
export default function ContractsPage() {
const [search, setSearch] = useState("");
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")
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: ({ uuid, data }: { uuid: string, data: UpdateContractDto }) => apiClient.patch(`/contracts/${uuid}`, 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")
mutationFn: ({ uuid, data }: { uuid: string; data: UpdateContractDto }) =>
apiClient.patch(`/contracts/${uuid}`, 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: (uuid: string) => apiClient.delete(`/contracts/${uuid}`).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")
mutationFn: (uuid: string) => apiClient.delete(`/contracts/${uuid}`).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);
@@ -155,104 +147,104 @@ export default function ContractsPage() {
};
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<ContractFormData>({
resolver: zodResolver(contractSchema),
defaultValues: {
contractCode: "",
contractName: "",
projectId: "",
description: "",
},
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: 'contractCode',
header: 'Code',
cell: ({ row }) => <span className="font-medium">{row.original.contractCode}</span>,
},
{ accessorKey: "contractName", header: "Name" },
{ accessorKey: 'contractName', header: 'Name' },
{
accessorKey: "project.projectCode",
header: "Project",
cell: ({ row }) => row.original.project?.projectCode || "-"
accessorKey: 'project.projectCode',
header: 'Project',
cell: ({ row }) => row.original.project?.projectCode || '-',
},
{ accessorKey: "startDate", header: "Start Date" },
{ accessorKey: "endDate", header: "End Date" },
{ 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>
)
}
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) => {
setEditingUuid(contract.id);
// ADR-019: project.id is the project's UUID (exposed via @Expose)
const pId = contract.project?.id || '';
reset({
contractCode: contract.contractCode,
contractName: contract.contractName,
projectId: pId,
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);
setEditingUuid(contract.id);
// ADR-019: project.id is the project's UUID (exposed via @Expose)
const pId = contract.project?.id || '';
reset({
contractCode: contract.contractCode,
contractName: contract.contractName,
projectId: pId,
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 = () => {
setEditingUuid(null);
reset({
contractCode: "",
contractName: "",
projectId: "",
description: "",
startDate: "",
endDate: "",
});
setDialogOpen(true);
setEditingUuid(null);
reset({
contractCode: '',
contractName: '',
projectId: '',
description: '',
startDate: '',
endDate: '',
});
setDialogOpen(true);
};
const onSubmit = (data: ContractFormData) => {
// ADR-019: Resolve projectId (ID or UUID)
// ADR-019: projectId is now a UUID string — backend resolveProjectId handles both
const submitData = {
...data,
projectId: data.projectId,
};
// ADR-019: Resolve projectId (ID or UUID)
// ADR-019: projectId is now a UUID string — backend resolveProjectId handles both
const submitData = {
...data,
projectId: data.projectId,
};
if (editingUuid) {
updateContract.mutate({ uuid: editingUuid, data: submitData });
} else {
createContract.mutate(submitData);
}
if (editingUuid) {
updateContract.mutate({ uuid: editingUuid, data: submitData });
} else {
createContract.mutate(submitData);
}
};
return (
@@ -263,104 +255,87 @@ export default function ContractsPage() {
<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
<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>
<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) => (
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center space-x-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
))}
))}
</div>
) : (
<DataTable columns={columns} data={contracts || []} />
<DataTable columns={columns} data={contracts || []} />
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingUuid ? "Edit Contract" : "New Contract"}</DialogTitle>
<DialogTitle>{editingUuid ? '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 { id?: number; uuid?: string; projectCode: string; projectName: string }[])?.map((p) => (
<SelectItem key={p.uuid || p.id} value={String(p.id || p.uuid)}>
{p.projectCode} - {p.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.projectId && (
<p className="text-sm text-red-500">{errors.projectId.message}</p>
)}
<Label>Project *</Label>
<Select value={watch('projectId')} onValueChange={(value) => setValue('projectId', value)}>
<SelectTrigger>
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent>
{(projects as { id?: number; uuid?: string; projectCode: string; projectName: string }[])?.map(
(p) => (
<SelectItem key={p.uuid || p.id} value={String(p.id || p.uuid)}>
{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 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 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")}
/>
<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 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>
@@ -368,7 +343,7 @@ export default function ContractsPage() {
Cancel
</Button>
<Button type="submit" disabled={createContract.isPending || updateContract.isPending}>
{editingUuid ? "Save Changes" : "Create Contract"}
{editingUuid ? 'Save Changes' : 'Create Contract'}
</Button>
</DialogFooter>
</form>
@@ -387,11 +362,8 @@ export default function ContractsPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteContract.isPending ? "Deleting..." : "Delete Contract"}
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
{deleteContract.isPending ? 'Deleting...' : 'Delete Contract'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -58,10 +58,7 @@ export default function ContractCategoriesPage() {
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId ?? ''}
onValueChange={(v) => setSelectedProjectId(v || undefined)}
>
<Select value={selectedProjectId ?? ''} onValueChange={(v) => setSelectedProjectId(v || undefined)}>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -106,7 +103,12 @@ export default function ContractCategoriesPage() {
const data = await drawingMasterDataService.getContractCategories(selectedProjectId);
return data;
}}
createFn={(data: Record<string, unknown>) => drawingMasterDataService.createContractCategory({ ...(data as unknown as CreateContractCategoryDto), projectId: selectedProjectId })}
createFn={(data: Record<string, unknown>) =>
drawingMasterDataService.createContractCategory({
...(data as unknown as CreateContractCategoryDto),
projectId: selectedProjectId,
})
}
updateFn={(id, data) => drawingMasterDataService.updateContractCategory(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractCategory(id)}
columns={columns}
@@ -198,7 +200,7 @@ function ManageMappings({ projectId }: { projectId: string }) {
const { data: rawMappings, isLoading: isLoadingMappings } = useQuery({
queryKey: ['contract-mappings', String(projectId), selectedCat],
queryFn: () =>
drawingMasterDataService.getContractMappings(projectId, selectedCat ? parseInt(selectedCat) : undefined),
drawingMasterDataService.getContractMappings(projectId, selectedCat ? Number(selectedCat) : undefined),
enabled: !!selectedCat,
});
@@ -231,8 +233,8 @@ function ManageMappings({ projectId }: { projectId: string }) {
if (!selectedCat || !selectedSubCat) return;
createMutation.mutate({
projectId,
categoryId: parseInt(selectedCat),
subCategoryId: parseInt(selectedSubCat),
categoryId: Number(selectedCat),
subCategoryId: Number(selectedSubCat),
});
};
@@ -50,10 +50,7 @@ export default function ContractSubCategoriesPage() {
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId ?? ''}
onValueChange={(v) => setSelectedProjectId(v || undefined)}
>
<Select value={selectedProjectId ?? ''} onValueChange={(v) => setSelectedProjectId(v || undefined)}>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -99,7 +96,10 @@ export default function ContractSubCategoriesPage() {
return data;
}}
createFn={(data: Record<string, unknown>) =>
drawingMasterDataService.createContractSubCategory({ ...(data as unknown as CreateContractSubCategoryDto), projectId: selectedProjectId })
drawingMasterDataService.createContractSubCategory({
...(data as unknown as CreateContractSubCategoryDto),
projectId: selectedProjectId,
})
}
updateFn={(id, data) => drawingMasterDataService.updateContractSubCategory(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractSubCategory(id)}
@@ -1,19 +1,13 @@
"use client";
'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";
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;
@@ -29,43 +23,34 @@ export default function ContractVolumesPage() {
const columns: ColumnDef<Volume>[] = [
{
accessorKey: "volumeCode",
header: "Code",
accessorKey: 'volumeCode',
header: 'Code',
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("volumeCode")}
{row.getValue('volumeCode')}
</Badge>
),
},
{
accessorKey: "volumeName",
header: "Volume Name",
accessorKey: 'volumeName',
header: 'Volume Name',
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
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>
),
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 ?? ''}
onValueChange={(v) => setSelectedProjectId(v || undefined)}
>
<Select value={selectedProjectId ?? ''} onValueChange={(v) => setSelectedProjectId(v || undefined)}>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -89,9 +74,7 @@ export default function ContractVolumesPage() {
<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>
<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">
@@ -107,17 +90,22 @@ export default function ContractVolumesPage() {
entityName="Volume"
title="Contract Drawing Volumes"
description="Manage drawing volumes (เล่ม) for contract drawings"
queryKey={["contract-drawing-volumes", String(selectedProjectId)]}
queryKey={['contract-drawing-volumes', String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getContractVolumes(selectedProjectId)}
createFn={(data: Record<string, unknown>) => drawingMasterDataService.createContractVolume({ ...(data as unknown as Parameters<typeof drawingMasterDataService.createContractVolume>[0]), projectId: selectedProjectId })}
createFn={(data: Record<string, unknown>) =>
drawingMasterDataService.createContractVolume({
...(data as unknown as Parameters<typeof drawingMasterDataService.createContractVolume>[0]),
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 },
{ 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}
/>
@@ -1,47 +1,41 @@
"use client";
'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";
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",
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",
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",
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",
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",
title: 'Sub-categories',
description: 'Manage sub-categories (หมวดหมู่ย่อย)',
href: '/admin/drawings/shop/sub-categories',
icon: Layers,
},
];
@@ -51,9 +45,7 @@ export default function DrawingsAdminPage() {
<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>
<p className="text-muted-foreground mt-1">Manage categories and volumes for Contract and Shop Drawings</p>
</div>
{/* Contract Drawings Section */}
@@ -67,15 +59,11 @@ export default function DrawingsAdminPage() {
<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>
<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>
<p className="text-xs text-muted-foreground">{item.description}</p>
</CardContent>
</Card>
</Link>
@@ -94,15 +82,11 @@ export default function DrawingsAdminPage() {
<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>
<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>
<p className="text-xs text-muted-foreground">{item.description}</p>
</CardContent>
</Card>
</Link>
@@ -61,10 +61,7 @@ export default function ShopMainCategoriesPage() {
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId ?? ''}
onValueChange={(v) => setSelectedProjectId(v || undefined)}
>
<Select value={selectedProjectId ?? ''} onValueChange={(v) => setSelectedProjectId(v || undefined)}>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -61,10 +61,7 @@ export default function ShopSubCategoriesPage() {
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId ?? ''}
onValueChange={(v) => setSelectedProjectId(v || undefined)}
>
<Select value={selectedProjectId ?? ''} onValueChange={(v) => setSelectedProjectId(v || undefined)}>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { TemplateEditor } from '@/components/numbering/template-editor';
import { SequenceViewer } from '@/components/numbering/sequence-viewer';
import { numberingApi } from '@/lib/api/numbering';
import { numberingApi, SaveTemplateDto } from '@/lib/api/numbering';
import { NumberingTemplate } from '@/lib/api/numbering';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
@@ -29,18 +29,18 @@ export default function EditTemplatePage() {
const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName =
projects.find((p: { id?: number; uuid?: string; projectCode: string; projectName: string }) => p.id === projectId)?.projectName ||
'LCBP3';
projects.find((p: { id?: number; uuid?: string; projectCode: string; projectName: string }) => p.id === projectId)
?.projectName || 'LCBP3';
useEffect(() => {
const fetchTemplate = async () => {
setLoading(true);
try {
const data = await numberingApi.getTemplate(parseInt(id));
const data = await numberingApi.getTemplate(Number(id));
if (data) {
setTemplate(data);
}
} catch (error) {
} catch {
toast.error('Failed to load template');
} finally {
setLoading(false);
@@ -51,16 +51,28 @@ export default function EditTemplatePage() {
}, [id]);
const handleSave = async (data: Partial<NumberingTemplate>) => {
if (!template) return;
try {
await numberingApi.saveTemplate({ ...data, id: parseInt(id) });
// Map to SaveTemplateDto ensuring all required fields are present
const payload: SaveTemplateDto = {
id: Number(id),
projectId: data.projectId ?? template.projectId,
correspondenceTypeId: data.correspondenceTypeId ?? template.correspondenceTypeId,
formatTemplate: data.formatTemplate ?? template.formatTemplate,
disciplineId: data.disciplineId ?? template.disciplineId,
description: data.description ?? template.description,
resetSequenceYearly: data.resetSequenceYearly ?? template.resetSequenceYearly,
isActive: data.isActive ?? template.isActive,
};
await numberingApi.saveTemplate(payload);
router.push('/admin/doc-control/numbering');
} catch (error) {
} catch {
toast.error('Failed to update template');
}
};
const handleCancel = () => {
router.push("/admin/numbering");
router.push('/admin/doc-control/numbering');
};
if (loading) {
@@ -1,10 +1,10 @@
"use client";
'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";
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';
import { toast } from 'sonner';
export default function NewTemplatePage() {
@@ -24,8 +24,8 @@ export default function NewTemplatePage() {
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate(data);
router.push("/admin/numbering");
} catch (error) {
router.push('/admin/numbering');
} catch (_error) {
toast.error('Failed to create template');
}
};
@@ -58,7 +58,7 @@ export default function NumberingPage() {
const contractId = firstContract?.uuid ?? firstContract?.id;
const { data: disciplines = [] } = useDisciplines(contractId);
const { data: templateResponse, isLoading: isLoadingTemplates } = useTemplates();
const { data: templateResponse, isLoading: _isLoadingTemplates } = useTemplates();
const saveTemplateMutation = useSaveTemplate();
// Extract templates array from response
@@ -144,7 +144,12 @@ export default function NumberingPage() {
<div className="lg:col-span-2 space-y-4">
<div className="grid gap-4">
{templates
.filter((t) => !t.projectId || String(t.project?.id ?? t.project?.uuid) === selectedProjectId || t.project?.uuid === selectedProjectId)
.filter(
(t) =>
!t.projectId ||
String(t.project?.id ?? t.project?.uuid) === selectedProjectId ||
t.project?.uuid === selectedProjectId
)
.map((template) => (
<Card key={template.id} className="p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
@@ -1,36 +1,25 @@
"use client";
'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 { 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";
} 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,
@@ -40,8 +29,8 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
interface Project {
uuid: string;
@@ -54,15 +43,15 @@ interface Project {
}
const projectSchema = z.object({
projectCode: z.string().min(1, "Project Code is required"),
projectName: z.string().min(1, "Project Name is required"),
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 [search, setSearch] = useState('');
const { data: projects, isLoading } = useProjects({ search: search || undefined });
const createProject = useCreateProject();
@@ -102,16 +91,16 @@ export default function ProjectsPage() {
} = useForm<ProjectFormData>({
resolver: zodResolver(projectSchema),
defaultValues: {
projectCode: "",
projectName: "",
projectCode: '',
projectName: '',
isActive: true,
},
});
const columns: ColumnDef<Project>[] = [
{
accessorKey: "projectCode",
header: "Code",
accessorKey: 'projectCode',
header: 'Code',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-500" />
@@ -119,19 +108,19 @@ export default function ProjectsPage() {
</div>
),
},
{ accessorKey: "projectName", header: "Project Name" },
{ accessorKey: 'projectName', header: 'Project Name' },
{
accessorKey: "isActive",
header: "Status",
accessorKey: 'isActive',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.isActive ? "default" : "secondary"}>
{row.original.isActive ? "Active" : "Inactive"}
<Badge variant={row.original.isActive ? 'default' : 'secondary'}>
{row.original.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
id: "actions",
header: "Actions",
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -169,8 +158,8 @@ export default function ProjectsPage() {
const handleCreate = () => {
setEditingUuid(null);
reset({
projectCode: "",
projectName: "",
projectCode: '',
projectName: '',
isActive: true,
});
setDialogOpen(true);
@@ -196,9 +185,7 @@ export default function ProjectsPage() {
<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>
<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
@@ -207,81 +194,65 @@ export default function ProjectsPage() {
<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"
/>
<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>
<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 || []} />
<DataTable columns={columns} data={projects || []} />
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingUuid ? "Edit Project" : "New Project"}
</DialogTitle>
<DialogTitle>{editingUuid ? '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")}
{...register('projectCode')}
disabled={!!editingUuid} // Code is immutable after creation usually
/>
{errors.projectCode && (
<p className="text-sm text-red-500">{errors.projectCode.message}</p>
)}
{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>
)}
<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)}
checked={watch('isActive')}
onCheckedChange={(checked) => setValue('isActive', checked)}
/>
<Label htmlFor="active">Active Status</Label>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={createProject.isPending || updateProject.isPending}
>
{editingUuid ? "Save Changes" : "Create Project"}
<Button type="submit" disabled={createProject.isPending || updateProject.isPending}>
{editingUuid ? 'Save Changes' : 'Create Project'}
</Button>
</DialogFooter>
</form>
@@ -300,11 +271,8 @@ export default function ProjectsPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteProject.isPending ? "Deleting..." : "Delete Project"}
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
{deleteProject.isPending ? 'Deleting...' : 'Delete Project'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,38 +1,34 @@
"use client";
'use client';
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { ColumnDef } from "@tanstack/react-table";
import { 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>[] = [
const columns: ColumnDef<unknown>[] = [
{
accessorKey: "typeCode",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("typeCode")}</span>
),
accessorKey: 'typeCode',
header: 'Code',
cell: ({ row }) => <span className="font-mono font-bold">{row.getValue('typeCode')}</span>,
},
{
accessorKey: "typeName",
header: "Name",
accessorKey: 'typeName',
header: 'Name',
},
{
accessorKey: "sortOrder",
header: "Sort Order",
accessorKey: 'sortOrder',
header: 'Sort Order',
},
{
accessorKey: "isActive",
header: "Status",
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') ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{row.getValue("isActive") ? "Active" : "Inactive"}
{row.getValue('isActive') ? 'Active' : 'Inactive'}
</span>
),
},
@@ -44,17 +40,21 @@ export default function CorrespondenceTypesPage() {
entityName="Correspondence Type"
title="Correspondence Types Management"
description="Manage global correspondence types (e.g., LETTER, TRANSMITTAL)"
queryKey={["correspondence-types"]}
queryKey={['correspondence-types']}
fetchFn={() => masterDataService.getCorrespondenceTypes()}
createFn={(data: Record<string, unknown>) => masterDataService.createCorrespondenceType(data as unknown as Parameters<typeof masterDataService.createCorrespondenceType>[0])}
createFn={(data: Record<string, unknown>) =>
masterDataService.createCorrespondenceType(
data as unknown as Parameters<typeof masterDataService.createCorrespondenceType>[0]
)
}
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" },
{ 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>
@@ -14,7 +14,7 @@ export default function DisciplinesPage() {
// Ensure we consistently use an array
const contracts = Array.isArray(contractsData) ? contractsData : [];
const columns: ColumnDef<any>[] = [
const columns: ColumnDef<unknown>[] = [
{
accessorKey: 'disciplineCode',
header: 'Code',
@@ -43,7 +43,7 @@ export default function DisciplinesPage() {
},
];
const contractOptions = contracts.map((c: any) => ({
const contractOptions = contracts.map((c: unknown) => ({
label: `${c.contractName} (${c.contractCode})`,
value: String(c.id),
}));
@@ -66,8 +66,12 @@ export default function DisciplinesPage() {
};
});
}}
createFn={(data) => masterDataService.createDiscipline(data as unknown as Parameters<typeof masterDataService.createDiscipline>[0])}
updateFn={(id, data) => Promise.reject('Not implemented yet')}
createFn={(data) =>
masterDataService.createDiscipline(
data as unknown as Parameters<typeof masterDataService.createDiscipline>[0]
)
}
updateFn={(_id, _data) => Promise.reject('Not implemented yet')}
deleteFn={(id) => masterDataService.deleteDiscipline(id)}
columns={columns}
filters={
@@ -81,7 +85,7 @@ export default function DisciplinesPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c: any) => (
{contracts.map((c: unknown) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.contractName} ({c.contractCode})
</SelectItem>
@@ -1,28 +1,24 @@
"use client";
'use client';
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { ColumnDef } from "@tanstack/react-table";
import { 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>[] = [
const columns: ColumnDef<unknown>[] = [
{
accessorKey: "subTypeCode",
header: "Code",
cell: ({ row }) => (
<span className="font-mono font-bold">{row.getValue("subTypeCode")}</span>
),
accessorKey: 'subTypeCode',
header: 'Code',
cell: ({ row }) => <span className="font-mono font-bold">{row.getValue('subTypeCode')}</span>,
},
{
accessorKey: "subTypeName",
header: "Name",
accessorKey: 'subTypeName',
header: 'Name',
},
{
accessorKey: "subTypeNumber",
header: "Running Code",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("subTypeNumber") || "-"}</span>
),
accessorKey: 'subTypeNumber',
header: 'Running Code',
cell: ({ row }) => <span className="font-mono">{row.getValue('subTypeNumber') || '-'}</span>,
},
];
@@ -32,16 +28,22 @@ export default function DrawingCategoriesPage() {
entityName="Drawing Category (Sub-Type)"
title="Drawing Categories Management"
description="Manage drawing sub-types and categories"
queryKey={["drawing-categories"]}
queryKey={['drawing-categories']}
fetchFn={() => masterDataService.getSubTypes(1)} // Default contract ID 1
createFn={(data: Record<string, unknown>) => masterDataService.createSubType({ ...(data as unknown as Parameters<typeof masterDataService.createSubType>[0]), 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
createFn={(data: Record<string, unknown>) =>
masterDataService.createSubType({
...(data as unknown as Parameters<typeof masterDataService.createSubType>[0]),
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" },
{ name: 'subTypeCode', label: 'Code', type: 'text', required: true },
{ name: 'subTypeName', label: 'Name', type: 'text', required: true },
{ name: 'subTypeNumber', label: 'Running Code', type: 'text' },
]}
/>
</div>
@@ -14,7 +14,7 @@ export default function RfaTypesPage() {
// Ensure we consistently use an array
const contracts = Array.isArray(contractsData) ? contractsData : [];
const columns: ColumnDef<any>[] = [
const columns: ColumnDef<unknown>[] = [
{
accessorKey: 'typeCode',
header: 'Code',
@@ -47,7 +47,7 @@ export default function RfaTypesPage() {
},
];
const contractOptions = contracts.map((c: any) => ({
const contractOptions = contracts.map((c: unknown) => ({
label: `${c.contractName} (${c.contractCode})`,
value: String(c.id),
}));
@@ -69,7 +69,9 @@ export default function RfaTypesPage() {
};
});
}}
createFn={(data) => masterDataService.createRfaType(data as unknown as Parameters<typeof masterDataService.createRfaType>[0])}
createFn={(data) =>
masterDataService.createRfaType(data as unknown as Parameters<typeof masterDataService.createRfaType>[0])
}
updateFn={(id, data) => masterDataService.updateRfaType(id, data)}
deleteFn={(id) => masterDataService.deleteRfaType(id)}
columns={columns}
@@ -84,7 +86,7 @@ export default function RfaTypesPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c: any) => (
{contracts.map((c: unknown) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.contractName} ({c.contractCode})
</SelectItem>
@@ -1,20 +1,20 @@
"use client";
'use client';
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { masterDataService } from "@/lib/services/master-data.service";
import { projectService } from "@/lib/services/project.service";
import { CreateTagDto } from "@/types/dto/master/tag.dto";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import { GenericCrudTable } from '@/components/admin/reference/generic-crud-table';
import { masterDataService } from '@/lib/services/master-data.service';
import { projectService } from '@/lib/services/project.service';
import { CreateTagDto } from '@/types/dto/master/tag.dto';
import { ColumnDef } from '@tanstack/react-table';
import { useQuery } from '@tanstack/react-query';
export default function TagsPage() {
const { data: projectsData } = useQuery({
queryKey: ["projects"],
queryKey: ['projects'],
queryFn: () => projectService.getAll(),
});
const projectOptions = [
{ label: "Global (All Projects)", value: "__none__" },
{ label: 'Global (All Projects)', value: '__none__' },
...(projectsData || []).map((p: Record<string, unknown>) => ({
label: (p.projectName || p.projectCode || p.project_name || p.project_code || `Project ${p.id}`) as string,
value: String(p.id), // p.id = UUID string via serialization
@@ -23,8 +23,8 @@ export default function TagsPage() {
const columns: ColumnDef<Record<string, unknown>>[] = [
{
accessorKey: "project_id",
header: "Project",
accessorKey: 'project_id',
header: 'Project',
cell: ({ row }) => {
const item = row.original as Record<string, unknown>;
const project = item.project as Record<string, unknown> | null;
@@ -33,8 +33,8 @@ export default function TagsPage() {
},
},
{
accessorKey: "tag_name",
header: "Tag Name",
accessorKey: 'tag_name',
header: 'Tag Name',
cell: ({ row }) => {
const color = String(row.original.color_code || 'default');
const isHex = color.startsWith('#');
@@ -42,24 +42,24 @@ export default function TagsPage() {
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full border border-border"
style={{ backgroundColor: isHex ? color : (color === 'default' ? '#e2e8f0' : color) }}
style={{ backgroundColor: isHex ? color : color === 'default' ? '#e2e8f0' : color }}
/>
{String(row.original.tag_name)}
</div>
);
}
},
},
{
accessorKey: "description",
header: "Description",
accessorKey: 'description',
header: 'Description',
},
];
const formatPayload = (data: Record<string, unknown>) => {
const payload = { ...data };
// ADR-019: project_id is now a UUID string or '__none__' for global
if (!payload.project_id || payload.project_id === "__none__") {
payload.project_id = null;
if (!payload.project_id || payload.project_id === '__none__') {
payload.project_id = null;
}
return payload;
};
@@ -69,7 +69,7 @@ export default function TagsPage() {
title="Tags"
description="Manage system tags, multi-tenant capable."
entityName="Tag"
queryKey={["tags"]}
queryKey={['tags']}
fetchFn={async () => {
const items = await masterDataService.getTags();
// ADR-019: Map project_id INT → project UUID for edit mode select matching
@@ -81,34 +81,36 @@ export default function TagsPage() {
};
});
}}
createFn={(data: Record<string, unknown>) => masterDataService.createTag(formatPayload(data) as unknown as CreateTagDto)}
createFn={(data: Record<string, unknown>) =>
masterDataService.createTag(formatPayload(data) as unknown as CreateTagDto)
}
updateFn={(id, data) => masterDataService.updateTag(id, formatPayload(data))}
deleteFn={(id) => masterDataService.deleteTag(id)}
columns={columns}
fields={[
{
name: "project_id",
label: "Project Scope",
type: "select",
name: 'project_id',
label: 'Project Scope',
type: 'select',
options: projectOptions,
required: false,
},
{
name: "tag_name",
label: "Tag Name",
type: "text",
name: 'tag_name',
label: 'Tag Name',
type: 'text',
required: true,
},
{
name: "color_code",
label: "Color Code (Hex or Name)",
type: "text",
name: 'color_code',
label: 'Color Code (Hex or Name)',
type: 'text',
required: false,
},
{
name: "description",
label: "Description",
type: "textarea",
name: 'description',
label: 'Description',
type: 'textarea',
required: false,
},
]}
@@ -21,7 +21,7 @@ import Link from 'next/link';
export default function WorkflowEditPage() {
const params = useParams();
const router = useRouter();
const id = params?.id === 'new' ? null : params?.id as string;
const id = params?.id === 'new' ? null : (params?.id as string);
const [workflowData, setWorkflowData] = useState<Partial<Workflow>>({
workflowName: '',
@@ -69,7 +69,7 @@ export default function WorkflowEditPage() {
toast.success('Workflow created successfully');
router.push('/admin/doc-control/workflows');
}
} catch (error) {
} catch (_error) {
toast.error('Failed to save workflow');
}
};
@@ -31,7 +31,7 @@ export default function NewWorkflowPage() {
try {
await workflowApi.createWorkflow(workflowData);
router.push('/admin/doc-control/workflows');
} catch (error) {
} catch (_error) {
toast.error('Failed to create workflow');
} finally {
setSaving(false);
@@ -1,23 +1,16 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { migrationService } from "@/lib/services/migration.service";
import { MigrationErrorItem } from "@/types/migration";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { format } from "date-fns";
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getApiErrorMessage } from "@/types/api-error";
import { useEffect, useState } from 'react';
import { migrationService } from '@/lib/services/migration.service';
import { MigrationErrorItem } from '@/types/migration';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { format } from 'date-fns';
import { ArrowLeftIcon } from 'lucide-react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getApiErrorMessage } from '@/types/api-error';
export default function MigrationErrorsPage() {
const [items, setItems] = useState<MigrationErrorItem[]>([]);
@@ -36,7 +29,7 @@ export default function MigrationErrorsPage() {
setItems(Array.isArray(res.items) ? res.items : []);
} catch (error: unknown) {
setItems([]);
setErrorMessage(getApiErrorMessage(error, "Failed to load errors"));
setErrorMessage(getApiErrorMessage(error, 'Failed to load errors'));
} finally {
setLoading(false);
}
@@ -87,17 +80,17 @@ export default function MigrationErrorsPage() {
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-sm">{item.batchId || "-"}</TableCell>
<TableCell className="font-medium">{item.documentNumber || "-"}</TableCell>
<TableCell className="font-mono text-sm">{item.batchId || '-'}</TableCell>
<TableCell className="font-medium">{item.documentNumber || '-'}</TableCell>
<TableCell>
<Badge variant="destructive">{item.errorType || "UNKNOWN"}</Badge>
<Badge variant="destructive">{item.errorType || 'UNKNOWN'}</Badge>
</TableCell>
<TableCell className="max-w-md break-words">
<span className="text-sm text-muted-foreground line-clamp-2" title={item.errorMessage}>
{item.errorMessage || "-"}
{item.errorMessage || '-'}
</span>
</TableCell>
<TableCell>{format(new Date(item.createdAt), "dd MMM yyyy, HH:mm")}</TableCell>
<TableCell>{format(new Date(item.createdAt), 'dd MMM yyyy, HH:mm')}</TableCell>
</TableRow>
))}
</TableBody>
+50 -60
View File
@@ -1,56 +1,49 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { migrationService } from "@/lib/services/migration.service";
import { MigrationReviewQueueItem, MigrationReviewStatus } from "@/types/migration";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { format } from "date-fns";
import { EyeIcon, FileXIcon, CheckSquareIcon } from "lucide-react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getApiErrorMessage } from "@/types/api-error";
import { useEffect, useState, useCallback } from 'react';
import { migrationService } from '@/lib/services/migration.service';
import { MigrationReviewQueueItem, MigrationReviewStatus } from '@/types/migration';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { format } from 'date-fns';
import { EyeIcon, FileXIcon, CheckSquareIcon } from 'lucide-react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getApiErrorMessage } from '@/types/api-error';
export default function MigrationReviewQueuePage() {
const [items, setItems] = useState<MigrationReviewQueueItem[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [statusFilter, setStatusFilter] = useState<string>("PENDING");
const [statusFilter, setStatusFilter] = useState<string>('PENDING');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, [statusFilter]);
const fetchData = async () => {
const fetchData = useCallback(async () => {
try {
setLoading(true);
setErrorMessage(null);
const res = await migrationService.getReviewQueue({
status: statusFilter === "ALL" ? undefined : (statusFilter as MigrationReviewStatus),
status: statusFilter === 'ALL' ? undefined : (statusFilter as MigrationReviewStatus),
limit: 50,
});
setItems(Array.isArray(res.items) ? res.items : []);
setSelectedIds([]); // reset selection on fetch
} catch (error: unknown) {
setItems([]);
setErrorMessage(getApiErrorMessage(error, "Failed to load queue"));
setErrorMessage(getApiErrorMessage(error, 'Failed to load queue'));
} finally {
setLoading(false);
}
};
}, [statusFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleToggleSelectAll = () => {
if (selectedIds.length === items.length) {
@@ -61,9 +54,7 @@ export default function MigrationReviewQueuePage() {
};
const handleToggleSelect = (id: number) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]));
};
const handleBatchApprove = async () => {
@@ -89,20 +80,17 @@ export default function MigrationReviewQueuePage() {
sender_id: item.senderOrganizationId,
receiver_id: item.receiverOrganizationId,
details: {
tags: item.extractedTags
}
}
tags: item.extractedTags,
},
},
}));
const batchId = `BATCH_UI_${Date.now()}`;
await migrationService.commitBatch(
{ items: batchItems, batchId },
batchId
);
await migrationService.commitBatch({ items: batchItems, batchId }, batchId);
fetchData();
} catch (error) {
toast.error("Batch commit failed.");
} catch (_error) {
toast.error('Batch commit failed.');
} finally {
setSubmitting(false);
}
@@ -113,19 +101,13 @@ export default function MigrationReviewQueuePage() {
<div className="flex justify-between flex-wrap gap-4 items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Migration Review Queue</h1>
<p className="text-muted-foreground mt-1">
Review and correct documents that AI flagged as low confidence.
</p>
<p className="text-muted-foreground mt-1">Review and correct documents that AI flagged as low confidence.</p>
</div>
<div className="flex items-center gap-4">
{selectedIds.length > 0 && (
<Button
variant="default"
onClick={handleBatchApprove}
disabled={submitting}
>
<Button variant="default" onClick={handleBatchApprove} disabled={submitting}>
<CheckSquareIcon className="mr-2 h-4 w-4" />
{submitting ? "Processing..." : `Batch Approve (${selectedIds.length})`}
{submitting ? 'Processing...' : `Batch Approve (${selectedIds.length})`}
</Button>
)}
<Link href="/admin/migration/errors">
@@ -192,28 +174,36 @@ export default function MigrationReviewQueuePage() {
/>
</TableCell>
<TableCell className="font-medium">{item.documentNumber}</TableCell>
<TableCell>{item.aiSuggestedCategory || "Unknown"}</TableCell>
<TableCell>{item.aiSuggestedCategory || 'Unknown'}</TableCell>
<TableCell>
<Badge
variant={
!item.aiConfidence
? "destructive"
? 'destructive'
: item.aiConfidence > 0.8
? "default"
? 'default'
: item.aiConfidence > 0.5
? "secondary"
: "destructive"
? 'secondary'
: 'destructive'
}
>
{item.aiConfidence ? (item.aiConfidence * 100).toFixed(1) + "%" : "N/A"}
{item.aiConfidence ? (item.aiConfidence * 100).toFixed(1) + '%' : 'N/A'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={item.status === 'PENDING' ? 'outline' : item.status === 'APPROVED' ? 'default' : 'destructive'}>
<Badge
variant={
item.status === 'PENDING'
? 'outline'
: item.status === 'APPROVED'
? 'default'
: 'destructive'
}
>
{item.status}
</Badge>
</TableCell>
<TableCell>{format(new Date(item.createdAt), "dd MMM yyyy, HH:mm")}</TableCell>
<TableCell>{format(new Date(item.createdAt), 'dd MMM yyyy, HH:mm')}</TableCell>
<TableCell className="text-right">
<Link href={`/admin/migration/review/${item.id}`}>
<Button size="sm" variant="ghost">
@@ -1,34 +1,37 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { migrationService } from "@/lib/services/migration.service";
import { MigrationReviewQueueItem } from "@/types/migration";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ArrowLeftIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { useEffect, useState, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { migrationService } from '@/lib/services/migration.service';
import { MigrationReviewQueueItem } from '@/types/migration';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription as _FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeftIcon, CheckCircleIcon, XCircleIcon } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'sonner';
import { Card, CardContent } from '@/components/ui/card';
interface MigrationAiIssues {
document_date?: string;
issued_date?: string;
received_date?: string;
sender_id?: string | number;
discipline_id?: string | number;
source_file_path?: string;
key_points?: string[];
validation_results?: Array<{ message: string; severity: string }>;
}
const reviewFormSchema = z.object({
document_number: z.string().min(1, "Document number is required"),
subject: z.string().min(1, "Subject is required"),
category: z.string().min(1, "Category is required"),
document_number: z.string().min(1, 'Document number is required'),
subject: z.string().min(1, 'Subject is required'),
category: z.string().min(1, 'Category is required'),
document_date: z.string().optional(),
issued_date: z.string().optional(),
received_date: z.string().optional(),
@@ -50,48 +53,51 @@ export default function MigrationReviewPage() {
const form = useForm<ReviewFormValues>({
resolver: zodResolver(reviewFormSchema),
defaultValues: {
document_number: "",
subject: "",
category: "",
document_date: "",
issued_date: "",
received_date: "",
sender_id: "",
discipline_id: "",
document_number: '',
subject: '',
category: '',
document_date: '',
issued_date: '',
received_date: '',
sender_id: '',
discipline_id: '',
},
});
const fetchItem = useCallback(
async (itemId: number) => {
try {
setLoading(true);
const res = await migrationService.getQueueItem(itemId);
setItem(res);
if (res) {
// Pre-fill form from database item and aiIssues payload
const issues = (res.aiIssues || {}) as MigrationAiIssues;
form.reset({
document_number: res.documentNumber || '',
subject: res.title || res.originalTitle || '',
category: res.aiSuggestedCategory || '',
document_date: issues.document_date || '',
issued_date: issues.issued_date || '',
received_date: issues.received_date || '',
sender_id: issues.sender_id ? String(issues.sender_id) : '',
discipline_id: issues.discipline_id ? String(issues.discipline_id) : '',
});
}
} catch (_error) {
toast.error('Failed to load queue item');
} finally {
setLoading(false);
}
},
[form]
);
useEffect(() => {
if (!id) return;
fetchItem(id);
}, [id]);
const fetchItem = async (itemId: number) => {
try {
setLoading(true);
const res = await migrationService.getQueueItem(itemId);
setItem(res);
if (res) {
// Pre-fill form from database item and aiIssues payload
const issues = res.aiIssues || {};
form.reset({
document_number: res.documentNumber || "",
subject: res.title || res.originalTitle || "",
category: res.aiSuggestedCategory || "",
document_date: issues.document_date || "",
issued_date: issues.issued_date || "",
received_date: issues.received_date || "",
sender_id: issues.sender_id ? String(issues.sender_id) : "",
discipline_id: issues.discipline_id ? String(issues.discipline_id) : "",
});
}
} catch (error) {
toast.error("Failed to load queue item");
} finally {
setLoading(false);
}
};
}, [id, fetchItem]);
const onSubmit = async (values: ReviewFormValues) => {
if (!item) return;
@@ -104,9 +110,9 @@ export default function MigrationReviewPage() {
document_number: values.document_number,
subject: values.subject,
category: values.category,
source_file_path: issues.source_file_path || "",
migrated_by: "SYSTEM_IMPORT",
batch_id: "MANUAL_REVIEW_BATCH",
source_file_path: issues.source_file_path || '',
migrated_by: 'SYSTEM_IMPORT',
batch_id: 'MANUAL_REVIEW_BATCH',
project_id: 1, // Assumption or pulled from store
document_date: values.document_date,
issued_date: values.issued_date,
@@ -116,33 +122,33 @@ export default function MigrationReviewPage() {
details: {
tags: issues.tags || [],
ai_confidence: item.aiConfidence,
}
},
};
// Mock idempotency key based on timestamp to ensure uniqueness per approval retry
const idempotencyKey = `review-${item.id}-${Date.now()}`;
await migrationService.approveQueueItem(item.id, payload, idempotencyKey);
toast.success("Document approved and imported successfully");
router.push("/admin/migration");
toast.success('Document approved and imported successfully');
router.push('/admin/migration');
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } };
toast.error(err?.response?.data?.message || "Failed to approve and import");
toast.error(err?.response?.data?.message || 'Failed to approve and import');
} finally {
setSubmitting(false);
}
};
const onReject = async () => {
if (!item || !confirm("Are you sure you want to REJECT this document? It will not be imported.")) return;
if (!item || !confirm('Are you sure you want to REJECT this document? It will not be imported.')) return;
try {
setSubmitting(true);
await migrationService.rejectQueueItem(item.id);
toast.success("Document rejected");
router.push("/admin/migration");
} catch (error: unknown) {
toast.error("Failed to reject document");
toast.success('Document rejected');
router.push('/admin/migration');
} catch (_error: unknown) {
toast.error('Failed to reject document');
} finally {
setSubmitting(false);
}
@@ -156,8 +162,8 @@ export default function MigrationReviewPage() {
return <div className="py-10 text-center text-red-500">Document not found</div>;
}
const pdfUrl = item.aiIssues?.source_file_path
? migrationService.getStagingFileUrl(item.aiIssues.source_file_path)
const pdfUrl = (item.aiIssues as MigrationAiIssues)?.source_file_path
? migrationService.getStagingFileUrl((item.aiIssues as MigrationAiIssues).source_file_path!)
: null;
return (
@@ -173,7 +179,10 @@ export default function MigrationReviewPage() {
<h1 className="text-2xl font-bold tracking-tight">Review Document: {item.documentNumber}</h1>
<p className="text-sm text-muted-foreground flex items-center gap-2">
Status: <span className="font-semibold text-primary">{item.status}</span>
{' | '} Confidence: <span className={item.aiConfidence && item.aiConfidence < 0.8 ? "text-red-500" : "text-green-500"}>{item.aiConfidence ? (item.aiConfidence * 100).toFixed(1) + "%" : "N/A"}</span>
{' | '} Confidence:{' '}
<span className={item.aiConfidence && item.aiConfidence < 0.8 ? 'text-red-500' : 'text-green-500'}>
{item.aiConfidence ? (item.aiConfidence * 100).toFixed(1) + '%' : 'N/A'}
</span>
</p>
</div>
</div>
@@ -200,9 +209,7 @@ export default function MigrationReviewPage() {
{/* Right Side: Form */}
<Card className="w-full md:w-[450px] lg:w-[500px] flex-shrink-0 flex flex-col overflow-hidden border-2 border-primary/10 shadow-md">
<div className="p-4 border-b bg-muted/30">
<h2 className="font-semibold text-lg flex items-center gap-2">
Extracted Information
</h2>
<h2 className="font-semibold text-lg flex items-center gap-2">Extracted Information</h2>
{item.reviewReason && (
<p className="text-sm text-red-500 mt-1 font-medium bg-red-50 p-2 rounded border border-red-100">
Reason: {item.reviewReason}
@@ -319,11 +326,11 @@ export default function MigrationReviewPage() {
)}
/>
{item.aiIssues?.key_points && item.aiIssues.key_points.length > 0 && (
{(item.aiIssues as MigrationAiIssues)?.key_points && (item.aiIssues as MigrationAiIssues).key_points!.length > 0 && (
<div className="mt-6 border-t pt-4">
<h3 className="font-semibold text-sm mb-2 text-muted-foreground">AI Extracted Key Points</h3>
<ul className="text-sm space-y-1 list-disc pl-4 text-muted-foreground">
{item.aiIssues.key_points.map((point: string, i: number) => (
{(item.aiIssues as MigrationAiIssues).key_points!.map((point: string, i: number) => (
<li key={i}>{point}</li>
))}
</ul>
@@ -347,7 +354,7 @@ export default function MigrationReviewPage() {
disabled={submitting || item.status !== 'PENDING'}
>
<CheckCircleIcon className="w-4 h-4 mr-2" />
{submitting ? "Processing..." : "Approve & Import"}
{submitting ? 'Processing...' : 'Approve & Import'}
</Button>
</div>
</form>
@@ -1,10 +1,10 @@
"use client";
'use client';
import { useAuditLogs } from "@/hooks/use-audit-logs";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { formatDistanceToNow } from "date-fns";
import { Loader2 } from "lucide-react";
import { useAuditLogs } from '@/hooks/use-audit-logs';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
import { Loader2 } from 'lucide-react';
export default function AuditLogsPage() {
const { data: logs, isLoading } = useAuditLogs();
@@ -18,43 +18,48 @@ export default function AuditLogsPage() {
{isLoading ? (
<div className="flex justify-center p-8">
<Loader2 className="animate-spin" />
<Loader2 className="animate-spin" />
</div>
) : (
<div className="space-y-2">
{!logs || logs.length === 0 ? (
<div className="text-center text-muted-foreground py-10">No logs found</div>
) : (
logs.map((log: import("@/lib/services/audit-log.service").AuditLog) => (
<Card key={log.auditId} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium text-sm">
{log.user?.fullName || log.user?.username || `User #${log.userId || 'System'}`}
</span>
<Badge variant={log.severity === 'ERROR' ? 'destructive' : 'outline'} className="uppercase text-[10px]">
{log.action}
</Badge>
<Badge variant="secondary" className="uppercase text-[10px]">{log.entityType || 'General'}</Badge>
</div>
<p className="text-sm text-foreground">
{typeof log.detailsJson === 'string' ? log.detailsJson : JSON.stringify(log.detailsJson || {})}
</p>
<p className="text-xs text-muted-foreground mt-2">
{log.createdAt && formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</p>
{!logs || logs.length === 0 ? (
<div className="text-center text-muted-foreground py-10">No logs found</div>
) : (
logs.map((log: import('@/lib/services/audit-log.service').AuditLog) => (
<Card key={log.auditId} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium text-sm">
{log.user?.fullName || log.user?.username || `User #${log.userId || 'System'}`}
</span>
<Badge
variant={log.severity === 'ERROR' ? 'destructive' : 'outline'}
className="uppercase text-[10px]"
>
{log.action}
</Badge>
<Badge variant="secondary" className="uppercase text-[10px]">
{log.entityType || 'General'}
</Badge>
</div>
{/* Only show IP if available */}
{log.ipAddress && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-1 rounded hidden md:inline-block">
{log.ipAddress}
</span>
)}
</div>
</Card>
))
)}
<p className="text-sm text-foreground">
{typeof log.detailsJson === 'string' ? log.detailsJson : JSON.stringify(log.detailsJson || {})}
</p>
<p className="text-xs text-muted-foreground mt-2">
{log.createdAt && formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</p>
</div>
{/* Only show IP if available */}
{log.ipAddress && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-1 rounded hidden md:inline-block">
{log.ipAddress}
</span>
)}
</div>
</Card>
))
)}
</div>
)}
</div>
@@ -50,7 +50,11 @@ export default function SessionManagementPage() {
}
if (error) {
return <div className="p-8 text-center text-red-500">{getApiErrorMessage(error, 'Failed to load sessions. Please try again.')}</div>;
return (
<div className="p-8 text-center text-red-500">
{getApiErrorMessage(error, 'Failed to load sessions. Please try again.')}
</div>
);
}
return (
@@ -1,12 +1,12 @@
"use client";
'use client';
import { useQuery } from "@tanstack/react-query";
import apiClient from "@/lib/api/client";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { RefreshCw } from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { useQuery } from '@tanstack/react-query';
import apiClient from '@/lib/api/client';
import { DataTable } from '@/components/common/data-table';
import { ColumnDef } from '@tanstack/react-table';
import { RefreshCw } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
interface NumberingError {
id: number;
@@ -14,41 +14,45 @@ interface NumberingError {
errorMessage: string;
stackTrace?: string;
createdAt: string;
context?: any;
context?: unknown;
}
const logService = {
getNumberingErrors: async () => {
const response = await apiClient.get("/document-numbering/logs/errors");
const response = await apiClient.get('/document-numbering/logs/errors');
return response.data.data || response.data;
},
};
export default function NumberingLogsPage() {
const { data: errors = [], isLoading, refetch } = useQuery<NumberingError[]>({
queryKey: ["numbering-errors"],
const {
data: errors = [],
isLoading,
refetch,
} = useQuery<NumberingError[]>({
queryKey: ['numbering-errors'],
queryFn: logService.getNumberingErrors,
});
const columns: ColumnDef<NumberingError>[] = [
{
accessorKey: "createdAt",
header: "Timestamp",
cell: ({ row }) => format(new Date(row.original.createdAt), "dd MMM yyyy, HH:mm:ss"),
accessorKey: 'createdAt',
header: 'Timestamp',
cell: ({ row }) => format(new Date(row.original.createdAt), 'dd MMM yyyy, HH:mm:ss'),
},
{
accessorKey: "context.projectId", // Accessing nested property
header: "Project ID",
accessorKey: 'context.projectId', // Accessing nested property
header: 'Project ID',
cell: ({ row }) => <span className="font-mono">{row.original.context?.projectId || 'N/A'}</span>,
},
{
accessorKey: "errorMessage",
header: "Message",
accessorKey: 'errorMessage',
header: 'Message',
cell: ({ row }) => <span className="text-destructive font-medium">{row.original.errorMessage}</span>,
},
{
accessorKey: "stackTrace",
header: "Details",
accessorKey: 'stackTrace',
header: 'Details',
cell: ({ row }) => (
<div className="max-w-[400px] truncate text-xs text-muted-foreground font-mono" title={row.original.stackTrace}>
{row.original.stackTrace}
@@ -65,7 +69,7 @@ export default function NumberingLogsPage() {
<p className="text-muted-foreground">Diagnostics for document numbering issues</p>
</div>
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
+7 -11
View File
@@ -1,9 +1,9 @@
"use client";
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
export default function SettingsPage() {
return (
@@ -32,9 +32,7 @@ export default function SettingsPage() {
<div className="flex items-center justify-between space-x-2">
<div className="flex flex-col space-y-1">
<Label htmlFor="audit-logging">Enhanced Audit Logging</Label>
<span className="text-sm text-muted-foreground">
Log detailed request/response data for debugging
</span>
<span className="text-sm text-muted-foreground">Log detailed request/response data for debugging</span>
</div>
<Switch id="audit-logging" defaultChecked />
</div>
@@ -50,9 +48,7 @@ export default function SettingsPage() {
<div className="flex items-center justify-between space-x-2">
<div className="flex flex-col space-y-1">
<Label htmlFor="email-notif">Email Notifications</Label>
<span className="text-sm text-muted-foreground">
Enable or disable all outbound emails
</span>
<span className="text-sm text-muted-foreground">Enable or disable all outbound emails</span>
</div>
<Switch id="email-notif" defaultChecked />
</div>
+3 -11
View File
@@ -5,15 +5,9 @@ import { Button } from '@/components/ui/button';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import Link from 'next/link';
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
export default function AdminError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[Admin Error Boundary]', error);
// // console.error('[Admin Error Boundary]', error); /* TODO: Remove before prod */
}, [error]);
return (
@@ -24,9 +18,7 @@ export default function AdminError({
<p className="text-muted-foreground mt-1 text-sm max-w-md">
{error.message || 'An error occurred in the admin panel.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
)}
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
</div>
<div className="flex gap-3">
<Button onClick={reset} variant="outline" size="sm">
+2 -6
View File
@@ -3,13 +3,9 @@
// Force dynamic rendering for all pages under (auth) route group.
// QNAP overlayfs cannot handle the .segments/!<base64> directories
// that Next.js 16 creates during static page generation.
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen w-full items-center justify-center bg-muted/40 p-4">
{/* Container หลักจัดกึ่งกลาง */}
+36 -57
View File
@@ -1,31 +1,24 @@
// File: app/(auth)/login/page.tsx
"use client";
'use client';
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Eye, EyeOff, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
// กำหนด Schema สำหรับตรวจสอบข้อมูลฟอร์ม
const loginSchema = z.object({
username: z.string().min(1, "กรุณาระบุชื่อผู้ใช้งาน"),
password: z.string().min(1, "กรุณาระบุรหัสผ่าน"),
username: z.string().min(1, 'กรุณาระบุชื่อผู้ใช้งาน'),
password: z.string().min(1, 'กรุณาระบุรหัสผ่าน'),
});
type LoginValues = z.infer<typeof loginSchema>;
@@ -44,8 +37,8 @@ export default function LoginPage() {
} = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
username: '',
password: '',
},
});
@@ -55,7 +48,7 @@ export default function LoginPage() {
try {
// เรียกใช้ NextAuth signIn (Credential Provider)
const result = await signIn("credentials", {
const result = await signIn('credentials', {
username: data.username,
password: data.password,
redirect: false, // เราจะจัดการ Redirect เอง
@@ -63,21 +56,21 @@ export default function LoginPage() {
if (result?.error) {
// กรณี Login ไม่สำเร็จ
toast.error("เข้าสู่ระบบไม่สำเร็จ", {
description: "ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่",
toast.error('เข้าสู่ระบบไม่สำเร็จ', {
description: 'ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่',
});
return;
}
// Login สำเร็จ -> ไปหน้า Dashboard
toast.success("เข้าสู่ระบบสำเร็จ", {
description: "กำลังพาท่านไปยังหน้า Dashboard...",
toast.success('เข้าสู่ระบบสำเร็จ', {
description: 'กำลังพาท่านไปยังหน้า Dashboard...',
});
router.push("/dashboard");
router.push('/dashboard');
router.refresh(); // Refresh เพื่อให้ Server Component รับรู้ Session ใหม่
} catch (error) {
toast.error("เกิดข้อผิดพลาด", {
description: "ระบบขัดข้อง กรุณาลองใหม่อีกครั้ง หรือติดต่อผู้ดูแลระบบ",
} catch (_error) {
toast.error('เกิดข้อผิดพลาด', {
description: 'ระบบขัดข้อง กรุณาลองใหม่อีกครั้ง หรือติดต่อผู้ดูแลระบบ',
});
} finally {
setIsLoading(false);
@@ -87,12 +80,8 @@ export default function LoginPage() {
return (
<Card className="w-full max-w-sm shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold text-primary">
LCBP3 DMS
</CardTitle>
<CardDescription>
</CardDescription>
<CardTitle className="text-2xl font-bold text-primary">LCBP3 DMS</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
@@ -108,14 +97,10 @@ export default function LoginPage() {
autoComplete="username"
autoCorrect="off"
disabled={isLoading}
className={errors.username ? "border-destructive" : ""}
{...register("username")}
className={errors.username ? 'border-destructive' : ''}
{...register('username')}
/>
{errors.username && (
<p className="text-xs text-destructive">
{errors.username.message}
</p>
)}
{errors.username && <p className="text-xs text-destructive">{errors.username.message}</p>}
</div>
{/* Password Field */}
@@ -125,11 +110,11 @@ export default function LoginPage() {
<Input
id="password"
placeholder="••••••••"
type={showPassword ? "text" : "password"}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
disabled={isLoading}
className={errors.password ? "border-destructive pr-10" : "pr-10"}
{...register("password")}
className={errors.password ? 'border-destructive pr-10' : 'pr-10'}
{...register('password')}
/>
{/* ปุ่ม Show/Hide Password */}
<Button
@@ -145,16 +130,10 @@ export default function LoginPage() {
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">
{showPassword ? "Hide password" : "Show password"}
</span>
<span className="sr-only">{showPassword ? 'Hide password' : 'Show password'}</span>
</Button>
</div>
{errors.password && (
<p className="text-xs text-destructive">
{errors.password.message}
</p>
)}
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
</div>
</CardContent>
@@ -1,42 +1,42 @@
"use client";
'use client';
import { useParams } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { circulationService } from "@/lib/services/circulation.service";
import { Circulation, UpdateCirculationRoutingDto } from "@/types/circulation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ArrowLeft, RefreshCw, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { circulationService } from '@/lib/services/circulation.service';
import { Circulation, UpdateCirculationRoutingDto } from '@/types/circulation';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowLeft, RefreshCw, CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
import { toast } from 'sonner';
/**
* Get initials from name
*/
function getInitials(firstName?: string, lastName?: string): string {
const first = firstName?.charAt(0) || "";
const last = lastName?.charAt(0) || "";
return (first + last).toUpperCase() || "?";
const first = firstName?.charAt(0) || '';
const last = lastName?.charAt(0) || '';
return (first + last).toUpperCase() || '?';
}
/**
* Get status badge variant
*/
function getStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status?.toUpperCase()) {
case "PENDING":
return "outline";
case "IN_PROGRESS":
return "default";
case "COMPLETED":
return "secondary";
case "REJECTED":
return "destructive";
case 'PENDING':
return 'outline';
case 'IN_PROGRESS':
return 'default';
case 'COMPLETED':
return 'secondary';
case 'REJECTED':
return 'destructive';
default:
return "outline";
return 'outline';
}
}
@@ -45,8 +45,12 @@ export default function CirculationDetailPage() {
const queryClient = useQueryClient();
const uuid = params.uuid as string;
const { data: circulation, isLoading, error } = useQuery<Circulation>({
queryKey: ["circulation", uuid],
const {
data: circulation,
isLoading,
error,
} = useQuery<Circulation>({
queryKey: ['circulation', uuid],
queryFn: () => circulationService.getByUuid(uuid),
enabled: !!uuid,
});
@@ -55,18 +59,18 @@ export default function CirculationDetailPage() {
mutationFn: ({ routingId, data }: { routingId: number; data: UpdateCirculationRoutingDto }) =>
circulationService.updateRouting(routingId, data),
onSuccess: () => {
toast.success("Task completed successfully");
queryClient.invalidateQueries({ queryKey: ["circulation", uuid] });
toast.success('Task completed successfully');
queryClient.invalidateQueries({ queryKey: ['circulation', uuid] });
},
onError: () => {
toast.error("Failed to update task status");
toast.error('Failed to update task status');
},
});
const handleComplete = (routingId: number) => {
completeMutation.mutate({
routingId,
data: { status: "COMPLETED", comments: "Completed via UI" },
data: { status: 'COMPLETED', comments: 'Completed via UI' },
});
};
@@ -109,9 +113,7 @@ export default function CirculationDetailPage() {
<p className="text-muted-foreground">{circulation.subject}</p>
</div>
</div>
<Badge variant={getStatusVariant(circulation.statusCode)}>
{circulation.statusCode}
</Badge>
<Badge variant={getStatusVariant(circulation.statusCode)}>{circulation.statusCode}</Badge>
</div>
{/* Info Card */}
@@ -122,24 +124,20 @@ export default function CirculationDetailPage() {
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Organization</p>
<p className="font-medium">
{circulation.organization?.organization_name || "-"}
</p>
<p className="font-medium">{circulation.organization?.organization_name || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Created By</p>
<p className="font-medium">
{circulation.creator
? `${circulation.creator.first_name || ""} ${circulation.creator.last_name || ""}`.trim() ||
? `${circulation.creator.first_name || ''} ${circulation.creator.last_name || ''}`.trim() ||
circulation.creator.username
: "-"}
: '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Created At</p>
<p className="font-medium">
{format(new Date(circulation.createdAt), "dd MMM yyyy, HH:mm")}
</p>
<p className="font-medium">{format(new Date(circulation.createdAt), 'dd MMM yyyy, HH:mm')}</p>
</div>
{circulation.correspondence && (
<div>
@@ -164,25 +162,19 @@ export default function CirculationDetailPage() {
{circulation.routings && circulation.routings.length > 0 ? (
<div className="space-y-3">
{circulation.routings.map((routing) => (
<div
key={routing.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div key={routing.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{getInitials(
routing.assignee?.first_name,
routing.assignee?.last_name
)}
{getInitials(routing.assignee?.first_name, routing.assignee?.last_name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{routing.assignee
? `${routing.assignee.first_name || ""} ${routing.assignee.last_name || ""}`.trim() ||
? `${routing.assignee.first_name || ''} ${routing.assignee.last_name || ''}`.trim() ||
routing.assignee.username
: "Unassigned"}
: 'Unassigned'}
</p>
<p className="text-sm text-muted-foreground">
Step {routing.stepNumber}
@@ -191,10 +183,8 @@ export default function CirculationDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={getStatusVariant(routing.status)}>
{routing.status}
</Badge>
{routing.status === "PENDING" && (
<Badge variant={getStatusVariant(routing.status)}>{routing.status}</Badge>
{routing.status === 'PENDING' && (
<Button
size="sm"
onClick={() => handleComplete(routing.id)}
+53 -100
View File
@@ -1,47 +1,29 @@
"use client";
'use client';
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { circulationService } from "@/lib/services/circulation.service";
import { userService } from "@/lib/services/user.service";
import { correspondenceService } from "@/lib/services/correspondence.service";
import { CreateCirculationDto } from "@/types/circulation";
import { circulationService } from '@/lib/services/circulation.service';
import { userService } from '@/lib/services/user.service';
import { correspondenceService } from '@/lib/services/correspondence.service';
import { CreateCirculationDto } from '@/types/circulation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Check, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Check, ChevronsUpDown, X } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -51,9 +33,9 @@ export const fetchCache = 'force-no-store';
// Form validation schema
const formSchema = z.object({
correspondenceId: z.string().min(1, "Please select a document"),
subject: z.string().min(1, "Subject is required"),
assigneeIds: z.array(z.string()).min(1, "At least one assignee is required"),
correspondenceId: z.string().min(1, 'Please select a document'),
subject: z.string().min(1, 'Subject is required'),
assigneeIds: z.array(z.string()).min(1, 'At least one assignee is required'),
remarks: z.string().optional(),
});
@@ -67,32 +49,32 @@ export default function CreateCirculationPage() {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
subject: "",
subject: '',
assigneeIds: [],
remarks: "",
remarks: '',
},
});
// Fetch users for assignee selection
const { data: users = [] } = useQuery({
queryKey: ["users"],
queryKey: ['users'],
queryFn: () => userService.getAll(),
});
// Fetch correspondences for document selection
const { data: correspondences } = useQuery({
queryKey: ["correspondences-dropdown"],
queryKey: ['correspondences-dropdown'],
queryFn: () => correspondenceService.getAll({ limit: 100 }),
});
const createMutation = useMutation({
mutationFn: (data: CreateCirculationDto) => circulationService.create(data),
onSuccess: (result) => {
toast.success("Circulation created successfully");
toast.success('Circulation created successfully');
router.push(`/circulation/${result.uuid}`);
},
onError: () => {
toast.error("Failed to create circulation");
toast.error('Failed to create circulation');
},
});
@@ -100,22 +82,20 @@ export default function CreateCirculationPage() {
createMutation.mutate(data);
};
const selectedAssignees = form.watch("assigneeIds");
const selectedDocId = form.watch("correspondenceId");
const selectedAssignees = form.watch('assigneeIds');
const selectedDocId = form.watch('correspondenceId');
const selectedDoc = correspondences?.data?.find(
(c: { uuid: string }) => c.uuid === selectedDocId
);
const selectedDoc = correspondences?.data?.find((c: { uuid: string }) => c.uuid === selectedDocId);
const toggleAssignee = (userUuid: string) => {
const current = form.getValues("assigneeIds");
const current = form.getValues('assigneeIds');
if (current.includes(userUuid)) {
form.setValue(
"assigneeIds",
'assigneeIds',
current.filter((id) => id !== userUuid)
);
} else {
form.setValue("assigneeIds", [...current, userUuid]);
form.setValue('assigneeIds', [...current, userUuid]);
}
};
@@ -130,9 +110,7 @@ export default function CreateCirculationPage() {
</Link>
<div>
<h1 className="text-2xl font-bold">Create Circulation</h1>
<p className="text-muted-foreground">
Create a new internal document circulation
</p>
<p className="text-muted-foreground">Create a new internal document circulation</p>
</div>
</div>
@@ -156,14 +134,9 @@ export default function CreateCirculationPage() {
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value && "text-muted-foreground"
)}
className={cn('justify-between', !field.value && 'text-muted-foreground')}
>
{selectedDoc
? selectedDoc.correspondenceNumber
: "Select document..."}
{selectedDoc ? selectedDoc.correspondenceNumber : 'Select document...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
@@ -179,16 +152,14 @@ export default function CreateCirculationPage() {
key={doc.uuid}
value={doc.correspondenceNumber}
onSelect={() => {
form.setValue("correspondenceId", doc.uuid);
form.setValue('correspondenceId', doc.uuid);
setDocOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
doc.uuid === field.value
? "opacity-100"
: "opacity-0"
'mr-2 h-4 w-4',
doc.uuid === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
{doc.correspondenceNumber}
@@ -223,29 +194,19 @@ export default function CreateCirculationPage() {
<FormField
control={form.control}
name="assigneeIds"
render={({ field }) => (
render={({ _field }) => (
<FormItem className="flex flex-col">
<FormLabel>Assignees</FormLabel>
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="justify-between h-auto min-h-10"
>
<Button variant="outline" role="combobox" className="justify-between h-auto min-h-10">
{selectedAssignees.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedAssignees.map((userUuid) => {
const user = users.find(
(u) => u.uuid === userUuid
);
const user = users.find((u) => u.uuid === userUuid);
return user ? (
<Badge
key={userUuid}
variant="secondary"
className="mr-1"
>
<Badge key={userUuid} variant="secondary" className="mr-1">
{user.firstName || user.username}
<X
className="ml-1 h-3 w-3 cursor-pointer"
@@ -259,9 +220,7 @@ export default function CreateCirculationPage() {
})}
</div>
) : (
<span className="text-muted-foreground">
Select assignees...
</span>
<span className="text-muted-foreground">Select assignees...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -281,10 +240,8 @@ export default function CreateCirculationPage() {
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedAssignees.includes(user.uuid)
? "opacity-100"
: "opacity-0"
'mr-2 h-4 w-4',
selectedAssignees.includes(user.uuid) ? 'opacity-100' : 'opacity-0'
)}
/>
{user.firstName && user.lastName
@@ -310,11 +267,7 @@ export default function CreateCirculationPage() {
<FormItem>
<FormLabel>Remarks (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Additional notes..."
className="resize-none"
{...field}
/>
<Textarea placeholder="Additional notes..." className="resize-none" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -329,7 +282,7 @@ export default function CreateCirculationPage() {
</Button>
</Link>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating..." : "Create Circulation"}
{createMutation.isPending ? 'Creating...' : 'Create Circulation'}
</Button>
</div>
</form>
+14 -29
View File
@@ -1,24 +1,19 @@
"use client";
'use client';
import { useQuery } from "@tanstack/react-query";
import { CirculationList } from "@/components/circulation/circulation-list";
import { circulationService } from "@/lib/services/circulation.service";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link";
import { CirculationListResponse } from "@/types/circulation";
import { useQuery } from '@tanstack/react-query';
import { CirculationList } from '@/components/circulation/circulation-list';
import { circulationService } from '@/lib/services/circulation.service';
import { Button } from '@/components/ui/button';
import { Plus, RefreshCw } from 'lucide-react';
import Link from 'next/link';
import { CirculationListResponse } from '@/types/circulation';
/**
* Circulation list page - displays circulations for the current user's organization
*/
export default function CirculationPage() {
const {
data,
isLoading,
error,
refetch,
} = useQuery<CirculationListResponse>({
queryKey: ["circulations"],
const { data, isLoading, error, refetch } = useQuery<CirculationListResponse>({
queryKey: ['circulations'],
queryFn: () => circulationService.getAll(),
});
@@ -27,19 +22,11 @@ export default function CirculationPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Circulation</h1>
<p className="text-muted-foreground">
Manage internal document circulation and assignments
</p>
<p className="text-muted-foreground">Manage internal document circulation and assignments</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
title="Refresh"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading} title="Refresh">
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Link href="/circulation/new">
<Button>
@@ -63,9 +50,7 @@ export default function CirculationPage() {
) : data ? (
<CirculationList data={data} />
) : (
<div className="text-center py-12 text-muted-foreground">
No circulations found
</div>
<div className="text-center py-12 text-muted-foreground">No circulations found</div>
)}
</section>
);
@@ -1,9 +1,9 @@
"use client";
'use client';
import { CorrespondenceDetail } from "@/components/correspondences/detail";
import { useCorrespondence } from "@/hooks/use-correspondence";
import { Loader2 } from "lucide-react";
import { useParams } from "next/navigation";
import { CorrespondenceDetail } from '@/components/correspondences/detail';
import { useCorrespondence } from '@/hooks/use-correspondence';
import { Loader2 } from 'lucide-react';
import { useParams } from 'next/navigation';
export default function CorrespondenceDetailPage() {
const params = useParams();
@@ -13,26 +13,26 @@ export default function CorrespondenceDetailPage() {
if (!uuid) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence UUID</h1>
</div>
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence UUID</h1>
</div>
);
}
if (isLoading) {
return (
<div className="flex bg-muted/20 min-h-screen justify-center items-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
<div className="flex bg-muted/20 min-h-screen justify-center items-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
if (isError || !correspondence) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
<p>Please try again later or verify the UUID.</p>
</div>
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
<p>Please try again later or verify the UUID.</p>
</div>
);
}
@@ -1,4 +1,4 @@
import { CorrespondenceForm } from "@/components/correspondences/form";
import { CorrespondenceForm } from '@/components/correspondences/form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -11,9 +11,7 @@ export default function NewCorrespondencePage() {
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">New Correspondence</h1>
<p className="text-muted-foreground mt-1">
Create a new official letter or communication record.
</p>
<p className="text-muted-foreground mt-1">Create a new official letter or communication record.</p>
</div>
<div className="bg-card border rounded-lg p-6 shadow-sm">
@@ -1,10 +1,10 @@
import { Suspense } from "react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Plus, Loader2 } from "lucide-react";
import { CorrespondencesContent } from "@/components/correspondences/correspondences-content";
import { Suspense } from 'react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { Plus, Loader2 } from 'lucide-react';
import { CorrespondencesContent } from '@/components/correspondences/correspondences-content';
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export default function CorrespondencesPage() {
return (
@@ -12,9 +12,7 @@ export default function CorrespondencesPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Correspondences</h1>
<p className="text-muted-foreground mt-1">
Manage official letters and communications
</p>
<p className="text-muted-foreground mt-1">Manage official letters and communications</p>
</div>
<Link href="/correspondences/new">
<Button>
@@ -24,7 +22,13 @@ export default function CorrespondencesPage() {
</Link>
</div>
<Suspense fallback={<div className="flex justify-center py-8"><Loader2 className="h-8 w-8 animate-spin" /></div>}>
<Suspense
fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}
>
<CorrespondencesContent />
</Suspense>
</div>
+7 -9
View File
@@ -1,10 +1,10 @@
"use client";
'use client';
import { StatsCards } from "@/components/dashboard/stats-cards";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { PendingTasks } from "@/components/dashboard/pending-tasks";
import { QuickActions } from "@/components/dashboard/quick-actions";
import { useDashboardStats, useRecentActivity, usePendingTasks } from "@/hooks/use-dashboard";
import { StatsCards } from '@/components/dashboard/stats-cards';
import { RecentActivity } from '@/components/dashboard/recent-activity';
import { PendingTasks } from '@/components/dashboard/pending-tasks';
import { QuickActions } from '@/components/dashboard/quick-actions';
import { useDashboardStats, useRecentActivity, usePendingTasks } from '@/hooks/use-dashboard';
export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useDashboardStats();
@@ -16,9 +16,7 @@ export default function DashboardPage() {
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground mt-1">
Welcome back! Here's an overview of your project status.
</p>
<p className="text-muted-foreground mt-1">Welcome back! Here's an overview of your project status.</p>
</div>
<QuickActions />
</div>
@@ -1,25 +1,25 @@
"use client";
'use client';
import { use, useState } from "react";
import { notFound, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ArrowLeft, Download, FileText, Loader2, Pencil, Upload, X } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { RevisionHistory } from "@/components/drawings/revision-history";
import { format } from "date-fns";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { contractDrawingService } from "@/lib/services/contract-drawing.service";
import { shopDrawingService } from "@/lib/services/shop-drawing.service";
import { asBuiltDrawingService } from "@/lib/services/asbuilt-drawing.service";
import { useUpdateContractDrawing, useUploadRevision } from "@/hooks/use-drawing";
import { use, useState } from 'react';
import { notFound, useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ArrowLeft, Download, FileText, Loader2, Pencil, Upload, X } from 'lucide-react';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { RevisionHistory } from '@/components/drawings/revision-history';
import { format } from 'date-fns';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service';
import { useUpdateContractDrawing, useUploadRevision } from '@/hooks/use-drawing';
type DrawingType = "CONTRACT" | "SHOP" | "AS_BUILT";
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT';
interface FetchedDrawing {
_type: DrawingType;
@@ -31,41 +31,56 @@ interface FetchedDrawing {
createdAt?: string;
updatedAt?: string;
currentRevision?: { title?: string; revisionNumber?: string; legacyDrawingNumber?: string };
revisions?: { revisionId?: number; uuid: string; revisionNumber: string; title?: string; legacyDrawingNumber?: string; revisionDate: string; revisionDescription?: string; revisedByName: string; fileUrl: string; isCurrent: boolean | null; createdBy?: number; updatedBy?: number }[];
revisions?: {
revisionId?: number;
uuid: string;
revisionNumber: string;
title?: string;
legacyDrawingNumber?: string;
revisionDate: string;
revisionDescription?: string;
revisedByName: string;
fileUrl: string;
isCurrent: boolean | null;
createdBy?: number;
updatedBy?: number;
}[];
}
async function fetchDrawingByUuid(uuid: string): Promise<FetchedDrawing | null> {
try {
const result = await contractDrawingService.getByUuid(uuid);
if (result?.data) return { ...result.data, _type: "CONTRACT" as const };
} catch { /* not found */ }
if (result?.data) return { ...result.data, _type: 'CONTRACT' as const };
} catch {
/* not found */
}
try {
const result = await shopDrawingService.getByUuid(uuid);
if (result?.data) return { ...result.data, _type: "SHOP" as const };
} catch { /* not found */ }
if (result?.data) return { ...result.data, _type: 'SHOP' as const };
} catch {
/* not found */
}
try {
const result = await asBuiltDrawingService.getByUuid(uuid);
if (result?.data) return { ...result.data, _type: "AS_BUILT" as const };
} catch { /* not found */ }
if (result?.data) return { ...result.data, _type: 'AS_BUILT' as const };
} catch {
/* not found */
}
return null;
}
export default function DrawingDetailPage({
params,
}: {
params: Promise<{ uuid: string }>;
}) {
export default function DrawingDetailPage({ params }: { params: Promise<{ uuid: string }> }) {
const { uuid } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const isEditMode = searchParams.get("edit") === "true";
const isUploadMode = searchParams.get("upload") === "true";
const isEditMode = searchParams.get('edit') === 'true';
const isUploadMode = searchParams.get('upload') === 'true';
const { data: drawing, isLoading } = useQuery({
queryKey: ["drawing-detail", uuid],
queryKey: ['drawing-detail', uuid],
queryFn: () => fetchDrawingByUuid(uuid),
enabled: !!uuid,
});
@@ -100,8 +115,8 @@ export default function DrawingDetailPage({
);
}
const drawingNumber = drawing.contractDrawingNo || drawing.drawingNumber || "N/A";
const title = drawing.title || drawing.currentRevision?.title || "Untitled";
const drawingNumber = drawing.contractDrawingNo || drawing.drawingNumber || 'N/A';
const title = drawing.title || drawing.currentRevision?.title || 'Untitled';
const revisions = drawing.revisions || [];
return (
@@ -129,7 +144,7 @@ export default function DrawingDetailPage({
Edit Detail
</Link>
</Button>
{drawing._type !== "CONTRACT" && (
{drawing._type !== 'CONTRACT' && (
<Button variant="outline" asChild>
<Link href={`/drawings/${uuid}?upload=true`}>
<Upload className="mr-2 h-4 w-4" />
@@ -155,12 +170,10 @@ export default function DrawingDetailPage({
</div>
{/* Edit Detail Form */}
{isEditMode && (
<EditDetailForm drawing={drawing} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />
)}
{isEditMode && <EditDetailForm drawing={drawing} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />}
{/* Upload Revision Form */}
{isUploadMode && drawing._type !== "CONTRACT" && (
{isUploadMode && drawing._type !== 'CONTRACT' && (
<UploadRevisionForm drawingType={drawing._type} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />
)}
@@ -194,7 +207,7 @@ export default function DrawingDetailPage({
<div>
<p className="text-sm font-medium text-muted-foreground">Created</p>
<p className="font-medium mt-1">
{drawing.createdAt ? format(new Date(drawing.createdAt), "dd MMM yyyy") : "N/A"}
{drawing.createdAt ? format(new Date(drawing.createdAt), 'dd MMM yyyy') : 'N/A'}
</p>
</div>
</div>
@@ -224,36 +237,28 @@ export default function DrawingDetailPage({
}
/* ─── Edit Detail Form ─── */
function EditDetailForm({
drawing,
uuid,
onDone,
}: {
drawing: FetchedDrawing;
uuid: string;
onDone: () => void;
}) {
function EditDetailForm({ drawing, uuid, onDone }: { drawing: FetchedDrawing; uuid: string; onDone: () => void }) {
const updateMutation = useUpdateContractDrawing();
const queryClient = useQueryClient();
const [formTitle, setFormTitle] = useState(drawing.title || drawing.currentRevision?.title || "");
const [formDrawingNo, setFormDrawingNo] = useState(drawing.contractDrawingNo || drawing.drawingNumber || "");
const [formVolumePage, setFormVolumePage] = useState(drawing.volumePage?.toString() || "");
const [formTitle, setFormTitle] = useState(drawing.title || drawing.currentRevision?.title || '');
const [formDrawingNo, setFormDrawingNo] = useState(drawing.contractDrawingNo || drawing.drawingNumber || '');
const [formVolumePage, setFormVolumePage] = useState(drawing.volumePage?.toString() || '');
const handleSave = () => {
if (drawing._type === "CONTRACT") {
if (drawing._type === 'CONTRACT') {
updateMutation.mutate(
{
uuid,
data: {
title: formTitle,
contractDrawingNo: formDrawingNo,
volumePage: formVolumePage ? parseInt(formVolumePage, 10) : undefined,
volumePage: formVolumePage ? Number(formVolumePage) : undefined,
},
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drawing-detail", uuid] });
queryClient.invalidateQueries({ queryKey: ['drawing-detail', uuid] });
onDone();
},
}
@@ -273,7 +278,7 @@ function EditDetailForm({
<Label>Title</Label>
<Input value={formTitle} onChange={(e) => setFormTitle(e.target.value)} />
</div>
{drawing._type === "CONTRACT" && (
{drawing._type === 'CONTRACT' && (
<div>
<Label>Volume Page</Label>
<Input type="number" value={formVolumePage} onChange={(e) => setFormVolumePage(e.target.value)} />
@@ -306,10 +311,10 @@ function UploadRevisionForm({
const uploadMutation = useUploadRevision(drawingType);
const queryClient = useQueryClient();
const [revisionLabel, setRevisionLabel] = useState("");
const [revTitle, setRevTitle] = useState("");
const [description, setDescription] = useState("");
const [legacyNo, setLegacyNo] = useState("");
const [revisionLabel, setRevisionLabel] = useState('');
const [revTitle, setRevTitle] = useState('');
const [description, setDescription] = useState('');
const [legacyNo, setLegacyNo] = useState('');
const handleUpload = () => {
if (!revisionLabel || !revTitle) return;
@@ -327,7 +332,7 @@ function UploadRevisionForm({
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drawing-detail", uuid] });
queryClient.invalidateQueries({ queryKey: ['drawing-detail', uuid] });
onDone();
},
}
@@ -341,7 +346,11 @@ function UploadRevisionForm({
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Revision Label *</Label>
<Input placeholder="e.g. A, B, 1, 2" value={revisionLabel} onChange={(e) => setRevisionLabel(e.target.value)} />
<Input
placeholder="e.g. A, B, 1, 2"
value={revisionLabel}
onChange={(e) => setRevisionLabel(e.target.value)}
/>
</div>
<div>
<Label>Legacy Drawing No.</Label>
@@ -354,7 +363,12 @@ function UploadRevisionForm({
</div>
<div>
<Label>Description</Label>
<Textarea placeholder="What changed in this revision?" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
<Textarea
placeholder="What changed in this revision?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="flex gap-3 pt-2">
<Button onClick={handleUpload} disabled={uploadMutation.isPending || !revisionLabel || !revTitle}>
+41 -53
View File
@@ -1,19 +1,13 @@
"use client";
'use client';
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DrawingList } from "@/components/drawings/list";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Upload, Loader2 } from "lucide-react";
import Link from "next/link";
import { useProjects } from "@/hooks/use-master-data";
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DrawingList } from '@/components/drawings/list';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Upload, Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useProjects } from '@/hooks/use-master-data';
export default function DrawingsPage() {
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string | undefined>(undefined);
@@ -24,9 +18,7 @@ export default function DrawingsPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Drawings</h1>
<p className="text-muted-foreground mt-1">
Manage contract, shop, and as-built drawings
</p>
<p className="text-muted-foreground mt-1">Manage contract, shop, and as-built drawings</p>
</div>
<Link href="/drawings/upload">
<Button>
@@ -39,10 +31,7 @@ export default function DrawingsPage() {
{/* Project Selector */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectUuid ?? ""}
onValueChange={(v) => setSelectedProjectUuid(v || undefined)}
>
<Select value={selectedProjectUuid ?? ''} onValueChange={(v) => setSelectedProjectUuid(v || undefined)}>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -72,43 +61,42 @@ export default function DrawingsPage() {
}
function DrawingTabs({ projectUuid }: { projectUuid: string }) {
const [search, setSearch] = useState("");
const [search, setSearch] = useState('');
// We can add more specific filters here (e.g. category) later
return (
<Tabs defaultValue="contract" className="w-full">
<div className="flex justify-between items-center mb-6">
<TabsList className="grid w-full grid-cols-3 max-w-[400px]">
<TabsTrigger value="contract">Contract</TabsTrigger>
<TabsTrigger value="shop">Shop</TabsTrigger>
<TabsTrigger value="asbuilt">As Built</TabsTrigger>
</TabsList>
<Tabs defaultValue="contract" className="w-full">
<div className="flex justify-between items-center mb-6">
<TabsList className="grid w-full grid-cols-3 max-w-[400px]">
<TabsTrigger value="contract">Contract</TabsTrigger>
<TabsTrigger value="shop">Shop</TabsTrigger>
<TabsTrigger value="asbuilt">As Built</TabsTrigger>
</TabsList>
<div className="flex gap-2">
<div className="relative">
<input
type="text"
placeholder="Search drawings..."
className="h-10 w-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2">
<div className="relative">
<input
type="text"
placeholder="Search drawings..."
className="h-10 w-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
</div>
<TabsContent value="contract" className="mt-0">
<DrawingList type="CONTRACT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="contract" className="mt-0">
<DrawingList type="CONTRACT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="shop" className="mt-0">
<DrawingList type="SHOP" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="shop" className="mt-0">
<DrawingList type="SHOP" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="asbuilt" className="mt-0">
<DrawingList type="AS_BUILT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
</Tabs>
)
<TabsContent value="asbuilt" className="mt-0">
<DrawingList type="AS_BUILT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
</Tabs>
);
}
@@ -1,4 +1,4 @@
import { DrawingUploadForm } from "@/components/drawings/upload-form";
import { DrawingUploadForm } from '@/components/drawings/upload-form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -11,9 +11,7 @@ export default function DrawingUploadPage() {
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">Upload Drawing</h1>
<p className="text-muted-foreground mt-1">
Upload a new contract or shop drawing revision.
</p>
<p className="text-muted-foreground mt-1">Upload a new contract or shop drawing revision.</p>
</div>
<DrawingUploadForm />
+3 -11
View File
@@ -5,15 +5,9 @@ import { Button } from '@/components/ui/button';
import { AlertCircle, RefreshCw } from 'lucide-react';
import Link from 'next/link';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[Dashboard Error Boundary]', error);
// // console.error('[Dashboard Error Boundary]', error); /* TODO: Remove before prod */
}, [error]);
return (
@@ -24,9 +18,7 @@ export default function DashboardError({
<p className="text-muted-foreground mt-1 text-sm max-w-md">
{error.message || 'An error occurred while loading this page.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
)}
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
</div>
<div className="flex gap-3">
<Button onClick={reset} variant="outline" size="sm">
+5 -11
View File
@@ -1,24 +1,18 @@
import { Header } from "@/components/layout/header";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
// Force dynamic rendering for all pages under (dashboard) route group.
// QNAP overlayfs cannot handle the .segments/!<base64> directories
// that Next.js 16 creates during static page generation.
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-background">
<Sidebar />
<div className="flex-1 flex flex-col min-h-screen overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6 bg-muted/10">
{children}
</main>
<main className="flex-1 overflow-y-auto p-6 bg-muted/10">{children}</main>
</div>
</div>
);
+43 -84
View File
@@ -1,29 +1,22 @@
// File: app/(dashboard)/profile/page.tsx
"use client";
'use client';
import { useState } from "react";
import { useSession } from "next-auth/react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Loader2, User, Shield, Bell } from "lucide-react";
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2, User, Shield, Bell } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Switch } from "@/components/ui/switch";
import apiClient from "@/lib/api/client";
import { toast } from "sonner";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Switch } from '@/components/ui/switch';
import apiClient from '@/lib/api/client';
import { toast } from 'sonner';
// -----------------------------------------------------------------------------
// Schemas
@@ -31,13 +24,13 @@ import { toast } from "sonner";
const passwordSchema = z
.object({
currentPassword: z.string().min(1, "กรุณาระบุรหัสผ่านปัจจุบัน"),
newPassword: z.string().min(8, "รหัสผ่านใหม่ต้องมีอย่างน้อย 8 ตัวอักษร"),
confirmPassword: z.string().min(1, "กรุณายืนยันรหัสผ่านใหม่"),
currentPassword: z.string().min(1, 'กรุณาระบุรหัสผ่านปัจจุบัน'),
newPassword: z.string().min(8, 'รหัสผ่านใหม่ต้องมีอย่างน้อย 8 ตัวอักษร'),
confirmPassword: z.string().min(1, 'กรุณายืนยันรหัสผ่านใหม่'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "รหัสผ่านใหม่ไม่ตรงกัน",
path: ["confirmPassword"],
message: 'รหัสผ่านใหม่ไม่ตรงกัน',
path: ['confirmPassword'],
});
type PasswordValues = z.infer<typeof passwordSchema>;
@@ -60,14 +53,14 @@ export default function ProfilePage() {
setIsLoading(true);
try {
// เรียก API เปลี่ยนรหัสผ่าน
await apiClient.put("/users/change-password", {
await apiClient.put('/users/change-password', {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
toast.success('เปลี่ยนรหัสผ่านสำเร็จ');
reset();
} catch (error) {
} catch (_error) {
toast.error('ไม่สามารถเปลี่ยนรหัสผ่านได้: รหัสผ่านปัจจุบันไม่ถูกต้อง');
// Password change failed - toast shown
} finally {
@@ -82,11 +75,11 @@ export default function ProfilePage() {
const [digestMode, setDigestMode] = useState(false);
// Helper to get initials
const userName = session?.user?.name || "User";
const userName = session?.user?.name || 'User';
const userInitials = userName
.split(" ")
.split(' ')
.map((n) => n[0])
.join("")
.join('')
.toUpperCase()
.substring(0, 2);
@@ -94,9 +87,7 @@ export default function ProfilePage() {
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Profile & Settings</h3>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground"></p>
</div>
<Tabs defaultValue="general" className="space-y-4">
@@ -120,21 +111,19 @@ export default function ProfilePage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={session?.user?.image || ""} />
<AvatarImage src={session?.user?.image || ''} />
<AvatarFallback className="text-lg">{userInitials}</AvatarFallback>
</Avatar>
<div>
<h4 className="text-lg font-semibold">{userName}</h4>
<p className="text-sm text-muted-foreground">{session?.user?.email}</p>
<div className="mt-2 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground hover:bg-primary/80">
{session?.user?.role || "Member"}
{session?.user?.role || 'Member'}
</div>
</div>
</div>
@@ -142,20 +131,20 @@ export default function ProfilePage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Input defaultValue={userName.split(" ")[0]} disabled />
<Input defaultValue={userName.split(' ')[0]} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input defaultValue={userName.split(" ")[1] || ""} disabled />
<Input defaultValue={userName.split(' ')[1] || ''} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input defaultValue={session?.user?.email || ""} disabled />
<Input defaultValue={session?.user?.email || ''} disabled />
</div>
<div className="space-y-2">
<Label> / </Label>
{/* ในอนาคตดึงจาก Organization ID */}
<Input defaultValue={`Organization ID: ${session?.user?.organizationId || "-"}`} disabled />
<Input defaultValue={`Organization ID: ${session?.user?.organizationId || '-'}`} disabled />
</div>
</div>
</CardContent>
@@ -171,41 +160,25 @@ export default function ProfilePage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
( 8 )
</CardDescription>
<CardDescription> ( 8 )</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onPasswordSubmit)}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword"></Label>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
/>
<Input id="currentPassword" type="password" {...register('currentPassword')} />
{errors.currentPassword && (
<p className="text-xs text-destructive">{errors.currentPassword.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<Input
id="newPassword"
type="password"
{...register("newPassword")}
/>
{errors.newPassword && (
<p className="text-xs text-destructive">{errors.newPassword.message}</p>
)}
<Input id="newPassword" type="password" {...register('newPassword')} />
{errors.newPassword && <p className="text-xs text-destructive">{errors.newPassword.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
<Input id="confirmPassword" type="password" {...register('confirmPassword')} />
{errors.confirmPassword && (
<p className="text-xs text-destructive">{errors.confirmPassword.message}</p>
)}
@@ -226,9 +199,7 @@ export default function ProfilePage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between space-x-2">
@@ -238,11 +209,7 @@ export default function ProfilePage() {
</span>
</Label>
<Switch
id="notify-email"
checked={notifyEmail}
onCheckedChange={setNotifyEmail}
/>
<Switch id="notify-email" checked={notifyEmail} onCheckedChange={setNotifyEmail} />
</div>
<div className="flex items-center justify-between space-x-2">
@@ -252,11 +219,7 @@ export default function ProfilePage() {
LINE Official Account
</span>
</Label>
<Switch
id="notify-line"
checked={notifyLine}
onCheckedChange={setNotifyLine}
/>
<Switch id="notify-line" checked={notifyLine} onCheckedChange={setNotifyLine} />
</div>
<div className="flex items-center justify-between space-x-2">
@@ -266,11 +229,7 @@ export default function ProfilePage() {
( Spam)
</span>
</Label>
<Switch
id="digest-mode"
checked={digestMode}
onCheckedChange={setDigestMode}
/>
<Switch id="digest-mode" checked={digestMode} onCheckedChange={setDigestMode} />
</div>
</CardContent>
<CardFooter>
+38 -80
View File
@@ -1,33 +1,20 @@
// File: app/(dashboard)/projects/new/page.tsx
"use client";
'use client';
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Loader2, ChevronLeft, Save } from "lucide-react";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2, ChevronLeft, Save } from 'lucide-react';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { toast } from "sonner";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -40,15 +27,12 @@ export const fetchCache = 'force-no-store';
const projectSchema = z.object({
projectCode: z
.string()
.min(1, "กรุณาระบุรหัสโครงการ")
.max(50, "รหัสโครงการต้องไม่เกิน 50 ตัวอักษร")
.regex(/^[A-Z0-9-]+$/, "รหัสโครงการควรประกอบด้วยตัวอักษรภาษาอังกฤษตัวใหญ่ ตัวเลข หรือขีด (-) เท่านั้น"),
projectName: z
.string()
.min(1, "กรุณาระบุชื่อโครงการ")
.max(255, "ชื่อโครงการต้องไม่เกิน 255 ตัวอักษร"),
.min(1, 'กรุณาระบุรหัสโครงการ')
.max(50, 'รหัสโครงการต้องไม่เกิน 50 ตัวอักษร')
.regex(/^[A-Z0-9-]+$/, 'รหัสโครงการควรประกอบด้วยตัวอักษรภาษาอังกฤษตัวใหญ่ ตัวเลข หรือขีด (-) เท่านั้น'),
projectName: z.string().min(1, 'กรุณาระบุชื่อโครงการ').max(255, 'ชื่อโครงการต้องไม่เกิน 255 ตัวอักษร'),
description: z.string().optional(),
status: z.enum(["Active", "Inactive", "On Hold"]),
status: z.enum(['Active', 'Inactive', 'On Hold']),
startDate: z.string().optional(),
endDate: z.string().optional(),
});
@@ -68,14 +52,14 @@ export default function CreateProjectPage() {
} = useForm<ProjectValues>({
resolver: zodResolver(projectSchema),
defaultValues: {
projectCode: "",
projectName: "",
status: "Active",
projectCode: '',
projectName: '',
status: 'Active',
},
});
// 3. ฟังก์ชัน Submit
async function onSubmit(data: ProjectValues) {
async function onSubmit(_data: ProjectValues) {
setIsLoading(true);
try {
// เรียก API สร้างโครงการ (Mockup URL)
@@ -89,7 +73,7 @@ export default function CreateProjectPage() {
toast.success('สร้างโครงการสำเร็จ');
router.push('/projects');
router.refresh();
} catch (error) {
} catch (_error) {
toast.error('เกิดข้อผิดพลาดในการสร้างโครงการ');
// Project creation failed - toast shown
} finally {
@@ -101,20 +85,13 @@ export default function CreateProjectPage() {
<div className="max-w-2xl mx-auto space-y-6">
{/* Header with Back Button */}
<div className="flex items-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => router.back()}
className="h-9 w-9"
>
<Button variant="outline" size="icon" onClick={() => router.back()} className="h-9 w-9">
<ChevronLeft className="h-5 w-5" />
<span className="sr-only">Back</span>
</Button>
<div>
<h2 className="text-2xl font-bold tracking-tight">Create New Project</h2>
<p className="text-muted-foreground">
</p>
<p className="text-muted-foreground"></p>
</div>
</div>
@@ -122,9 +99,7 @@ export default function CreateProjectPage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -136,19 +111,17 @@ export default function CreateProjectPage() {
<Input
id="project_code"
placeholder="e.g. LCBP3-C1"
className={errors.projectCode ? "border-destructive" : ""}
{...register("projectCode")}
className={errors.projectCode ? 'border-destructive' : ''}
{...register('projectCode')}
onChange={(e) => {
e.target.value = e.target.value.toUpperCase();
register("projectCode").onChange(e);
register('projectCode').onChange(e);
}}
/>
{errors.projectCode ? (
<p className="text-xs text-destructive">{errors.projectCode.message}</p>
) : (
<p className="text-xs text-muted-foreground">
(-)
</p>
<p className="text-xs text-muted-foreground"> (-) </p>
)}
</div>
@@ -160,12 +133,10 @@ export default function CreateProjectPage() {
<Input
id="project_name"
placeholder="ระบุชื่อโครงการฉบับเต็ม..."
className={errors.projectName ? "border-destructive" : ""}
{...register("projectName")}
className={errors.projectName ? 'border-destructive' : ''}
{...register('projectName')}
/>
{errors.projectName && (
<p className="text-xs text-destructive">{errors.projectName.message}</p>
)}
{errors.projectName && <p className="text-xs text-destructive">{errors.projectName.message}</p>}
</div>
{/* Description */}
@@ -175,7 +146,7 @@ export default function CreateProjectPage() {
id="description"
placeholder="คำอธิบายเกี่ยวกับขอบเขตงานของโครงการ..."
className="min-h-[100px]"
{...register("description")}
{...register('description')}
/>
</div>
@@ -183,19 +154,11 @@ export default function CreateProjectPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="start_date"></Label>
<Input
id="start_date"
type="date"
{...register("startDate")}
/>
<Input id="start_date" type="date" {...register('startDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="end_date"></Label>
<Input
id="end_date"
type="date"
{...register("endDate")}
/>
<Input id="end_date" type="date" {...register('endDate')} />
</div>
</div>
@@ -222,12 +185,7 @@ export default function CreateProjectPage() {
</CardContent>
<CardFooter className="flex justify-end gap-2 border-t p-4 bg-muted/50">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={isLoading}
>
<Button type="button" variant="ghost" onClick={() => router.back()} disabled={isLoading}>
</Button>
<Button type="submit" disabled={isLoading}>
+79 -71
View File
@@ -1,22 +1,15 @@
// File: app/(dashboard)/projects/page.tsx
"use client";
'use client';
import { useState } from "react";
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from 'react';
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,22 +17,15 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
} from '@/components/ui/dropdown-menu';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
// Type สำหรับข้อมูล Project (Mockup ตาม Data Dictionary)
interface Project {
id: number;
projectCode: string;
projectName: string;
status: "Active" | "Completed" | "On Hold";
status: 'Active' | 'Completed' | 'On Hold';
progress: number;
startDate: string;
endDate: string;
@@ -50,56 +36,61 @@ interface Project {
const mockProjects: Project[] = [
{
id: 1,
projectCode: "LCBP3",
projectName: "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)",
status: "Active",
projectCode: 'LCBP3',
projectName: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)',
status: 'Active',
progress: 45,
startDate: "2021-01-01",
endDate: "2025-12-31",
contractorName: "Multiple Contractors",
startDate: '2021-01-01',
endDate: '2025-12-31',
contractorName: 'Multiple Contractors',
},
{
id: 2,
projectCode: "LCBP3-C1",
projectName: "งานก่อสร้างงานทางทะเล (ส่วนที่ 1)",
status: "Active",
projectCode: 'LCBP3-C1',
projectName: 'งานก่อสร้างงานทางทะเล (ส่วนที่ 1)',
status: 'Active',
progress: 70,
startDate: "2021-06-01",
endDate: "2024-06-01",
contractorName: "CNNC",
startDate: '2021-06-01',
endDate: '2024-06-01',
contractorName: 'CNNC',
},
{
id: 3,
projectCode: "LCBP3-C2",
projectName: "งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)",
status: "Active",
projectCode: 'LCBP3-C2',
projectName: 'งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)',
status: 'Active',
progress: 15,
startDate: "2023-01-01",
endDate: "2026-01-01",
contractorName: "ITD-NWR Joint Venture",
startDate: '2023-01-01',
endDate: '2026-01-01',
contractorName: 'ITD-NWR Joint Venture',
},
];
export default function ProjectsPage() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [searchTerm, setSearchTerm] = useState('');
const filteredProjects = mockProjects.filter((project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
const filteredProjects = mockProjects.filter(
(project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusVariant = (status: string) => {
switch (status) {
case "Active": return "success"; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case "Completed": return "default";
case "On Hold": return "warning";
default: return "secondary";
case 'Active':
return 'success'; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case 'Completed':
return 'default';
case 'On Hold':
return 'warning';
default:
return 'secondary';
}
};
const handleCreateProject = () => {
router.push("/projects/new"); // อัปเดตเป็นลิงก์จริง
router.push('/projects/new'); // อัปเดตเป็นลิงก์จริง
};
const handleViewDetails = (id: number) => {
@@ -112,9 +103,7 @@ export default function ProjectsPage() {
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Projects</h2>
<p className="text-muted-foreground">
</p>
<p className="text-muted-foreground"> </p>
</div>
<Button onClick={handleCreateProject} className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" /> New Project
@@ -149,7 +138,11 @@ export default function ProjectsPage() {
</TableHeader>
<TableBody>
{filteredProjects.map((project) => (
<TableRow key={project.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleViewDetails(project.id)}>
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetails(project.id)}
>
<TableCell className="font-medium">{project.projectCode}</TableCell>
<TableCell>
<div className="flex flex-col">
@@ -161,16 +154,12 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>{project.contractorName}</TableCell>
<TableCell>
<Badge variant={getStatusVariant(project.status)}>
{project.status}
</Badge>
<Badge variant={getStatusVariant(project.status)}>{project.status}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={project.progress} className="h-2" />
<span className="text-xs text-muted-foreground w-[30px] text-right">
{project.progress}%
</span>
<span className="text-xs text-muted-foreground w-[30px] text-right">{project.progress}%</span>
</div>
</TableCell>
<TableCell className="text-right">
@@ -183,14 +172,29 @@ export default function ProjectsPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(project.id); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleViewDetails(project.id);
}}
>
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Manage Contracts for ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Manage Contracts for ${project.projectCode}`);
}}
>
Manage Contracts
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Edit ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Edit ${project.projectCode}`);
}}
>
Edit Project
</DropdownMenuItem>
</DropdownMenuContent>
@@ -205,14 +209,16 @@ export default function ProjectsPage() {
{/* Mobile View: Cards */}
<div className="grid gap-4 md:hidden">
{filteredProjects.map((project) => (
<Card key={project.id} onClick={() => handleViewDetails(project.id)} className="cursor-pointer active:bg-muted/50">
<Card
key={project.id}
onClick={() => handleViewDetails(project.id)}
className="cursor-pointer active:bg-muted/50"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-base font-bold">{project.projectCode}</CardTitle>
<CardDescription className="mt-1 line-clamp-2">
{project.projectName}
</CardDescription>
<CardDescription className="mt-1 line-clamp-2">{project.projectName}</CardDescription>
</div>
<Badge variant={getStatusVariant(project.status)} className="shrink-0">
{project.status}
@@ -226,7 +232,9 @@ export default function ProjectsPage() {
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>{project.startDate} - {project.endDate}</span>
<span>
{project.startDate} - {project.endDate}
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs">
@@ -1,22 +1,15 @@
// File: app/(dashboard)/projects/page.tsx
"use client";
'use client';
import { useState } from "react";
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from 'react';
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,22 +17,15 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
} from '@/components/ui/dropdown-menu';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
// Type สำหรับข้อมูล Project (Mockup ตาม Data Dictionary)
interface Project {
id: number;
projectCode: string;
projectName: string;
status: "Active" | "Completed" | "On Hold";
status: 'Active' | 'Completed' | 'On Hold';
progress: number;
startDate: string;
endDate: string;
@@ -50,56 +36,61 @@ interface Project {
const mockProjects: Project[] = [
{
id: 1,
projectCode: "LCBP3",
projectName: "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)",
status: "Active",
projectCode: 'LCBP3',
projectName: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)',
status: 'Active',
progress: 45,
startDate: "2021-01-01",
endDate: "2025-12-31",
contractorName: "Multiple Contractors",
startDate: '2021-01-01',
endDate: '2025-12-31',
contractorName: 'Multiple Contractors',
},
{
id: 2,
projectCode: "LCBP3-C1",
projectName: "งานก่อสร้างงานทางทะเล (ส่วนที่ 1)",
status: "Active",
projectCode: 'LCBP3-C1',
projectName: 'งานก่อสร้างงานทางทะเล (ส่วนที่ 1)',
status: 'Active',
progress: 70,
startDate: "2021-06-01",
endDate: "2024-06-01",
contractorName: "CNNC",
startDate: '2021-06-01',
endDate: '2024-06-01',
contractorName: 'CNNC',
},
{
id: 3,
projectCode: "LCBP3-C2",
projectName: "งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)",
status: "Active",
projectCode: 'LCBP3-C2',
projectName: 'งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)',
status: 'Active',
progress: 15,
startDate: "2023-01-01",
endDate: "2026-01-01",
contractorName: "ITD-NWR Joint Venture",
startDate: '2023-01-01',
endDate: '2026-01-01',
contractorName: 'ITD-NWR Joint Venture',
},
];
export default function ProjectsPage() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [searchTerm, setSearchTerm] = useState('');
const filteredProjects = mockProjects.filter((project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
const filteredProjects = mockProjects.filter(
(project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusVariant = (status: string) => {
switch (status) {
case "Active": return "success"; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case "Completed": return "default";
case "On Hold": return "warning";
default: return "secondary";
case 'Active':
return 'success'; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case 'Completed':
return 'default';
case 'On Hold':
return 'warning';
default:
return 'secondary';
}
};
const handleCreateProject = () => {
router.push("/projects/new"); // อัปเดตเป็นลิงก์จริง
router.push('/projects/new'); // อัปเดตเป็นลิงก์จริง
};
const handleViewDetails = (id: number) => {
@@ -112,9 +103,7 @@ export default function ProjectsPage() {
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Projects</h2>
<p className="text-muted-foreground">
</p>
<p className="text-muted-foreground"> </p>
</div>
<Button onClick={handleCreateProject} className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" /> New Project
@@ -149,7 +138,11 @@ export default function ProjectsPage() {
</TableHeader>
<TableBody>
{filteredProjects.map((project) => (
<TableRow key={project.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleViewDetails(project.id)}>
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetails(project.id)}
>
<TableCell className="font-medium">{project.projectCode}</TableCell>
<TableCell>
<div className="flex flex-col">
@@ -161,16 +154,12 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>{project.contractorName}</TableCell>
<TableCell>
<Badge variant={getStatusVariant(project.status)}>
{project.status}
</Badge>
<Badge variant={getStatusVariant(project.status)}>{project.status}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={project.progress} className="h-2" />
<span className="text-xs text-muted-foreground w-[30px] text-right">
{project.progress}%
</span>
<span className="text-xs text-muted-foreground w-[30px] text-right">{project.progress}%</span>
</div>
</TableCell>
<TableCell className="text-right">
@@ -183,14 +172,29 @@ export default function ProjectsPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(project.id); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleViewDetails(project.id);
}}
>
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Manage Contracts for ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Manage Contracts for ${project.projectCode}`);
}}
>
Manage Contracts
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Edit ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Edit ${project.projectCode}`);
}}
>
Edit Project
</DropdownMenuItem>
</DropdownMenuContent>
@@ -205,14 +209,16 @@ export default function ProjectsPage() {
{/* Mobile View: Cards */}
<div className="grid gap-4 md:hidden">
{filteredProjects.map((project) => (
<Card key={project.id} onClick={() => handleViewDetails(project.id)} className="cursor-pointer active:bg-muted/50">
<Card
key={project.id}
onClick={() => handleViewDetails(project.id)}
className="cursor-pointer active:bg-muted/50"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-base font-bold">{project.projectCode}</CardTitle>
<CardDescription className="mt-1 line-clamp-2">
{project.projectName}
</CardDescription>
<CardDescription className="mt-1 line-clamp-2">{project.projectName}</CardDescription>
</div>
<Badge variant={getStatusVariant(project.status)} className="shrink-0">
{project.status}
@@ -226,7 +232,9 @@ export default function ProjectsPage() {
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>{project.startDate} - {project.endDate}</span>
<span>
{project.startDate} - {project.endDate}
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs">
+6 -10
View File
@@ -1,9 +1,9 @@
"use client";
'use client';
import { RFADetail } from "@/components/rfas/detail";
import { notFound, useParams } from "next/navigation";
import { useRFA } from "@/hooks/use-rfa";
import { Loader2 } from "lucide-react";
import { RFADetail } from '@/components/rfas/detail';
import { notFound, useParams } from 'next/navigation';
import { useRFA } from '@/hooks/use-rfa';
import { Loader2 } from 'lucide-react';
export default function RFADetailPage() {
const { uuid } = useParams();
@@ -22,11 +22,7 @@ export default function RFADetailPage() {
if (isError || !rfa) {
// Check if error is 404
return (
<div className="text-center py-20 text-red-500">
RFA not found or failed to load.
</div>
);
return <div className="text-center py-20 text-red-500">RFA not found or failed to load.</div>;
}
return <RFADetail data={rfa} />;
+2 -4
View File
@@ -1,4 +1,4 @@
import { RFAForm } from "@/components/rfas/form";
import { RFAForm } from '@/components/rfas/form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -11,9 +11,7 @@ export default function NewRFAPage() {
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">New RFA</h1>
<p className="text-muted-foreground mt-1">
Create a new Request for Approval.
</p>
<p className="text-muted-foreground mt-1">Create a new Request for Approval.</p>
</div>
<RFAForm />
+23 -28
View File
@@ -1,4 +1,4 @@
"use client";
'use client';
import { RFAList } from '@/components/rfas/list';
import { Button } from '@/components/ui/button';
@@ -11,8 +11,8 @@ import { Suspense } from 'react';
function RFAsContent() {
const searchParams = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');
const statusId = searchParams.get('status') ? parseInt(searchParams.get('status')!) : undefined;
const page = Number(searchParams.get('page') || '1');
const statusId = searchParams.get('status') ? Number(searchParams.get('status')!) : undefined;
const search = searchParams.get('search') || undefined;
const projectId = searchParams.get('projectId') || undefined; // ADR-019: Pass UUID string directly
@@ -20,24 +20,21 @@ function RFAsContent() {
const { data, isLoading, isError } = useRFAs({ page, statusId, search, projectId, revisionStatus });
return (
<>
<div className="mb-4 flex gap-2">
{/* Simple Filter Buttons using standard Buttons for now, or use a Select if imported */}
<div className="flex gap-1 bg-muted p-1 rounded-md">
{['ALL', 'CURRENT', 'OLD'].map((status) => (
<Link key={status} href={`?${new URLSearchParams({...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1'}).toString()}`}>
<Button
variant={revisionStatus === status ? 'default' : 'ghost'}
size="sm"
className="text-xs px-3"
>
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
</Button>
</Link>
))}
{['ALL', 'CURRENT', 'OLD'].map((status) => (
<Link
key={status}
href={`?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1' }).toString()}`}
>
<Button variant={revisionStatus === status ? 'default' : 'ghost'} size="sm" className="text-xs px-3">
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
</Button>
</Link>
))}
</div>
</div>
@@ -46,13 +43,11 @@ function RFAsContent() {
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : isError ? (
<div className="text-red-500 text-center py-8">
Failed to load RFAs.
</div>
<div className="text-red-500 text-center py-8">Failed to load RFAs.</div>
) : (
<>
<RFAList data={data?.data || []} />
<div className="mt-4">
<div className="mt-4">
<Pagination
currentPage={data?.meta?.page || 1}
totalPages={data?.meta?.totalPages || 1}
@@ -71,9 +66,7 @@ export default function RFAsPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">RFAs (Request for Approval)</h1>
<p className="text-muted-foreground mt-1">
Manage approval requests and submissions
</p>
<p className="text-muted-foreground mt-1">Manage approval requests and submissions</p>
</div>
<Link href="/rfas/new">
<Button>
@@ -83,11 +76,13 @@ export default function RFAsPage() {
</Link>
</div>
<Suspense fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}>
<Suspense
fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}
>
<RFAsContent />
</Suspense>
</div>
+20 -19
View File
@@ -1,20 +1,20 @@
"use client";
'use client';
import { useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { SearchFilters } from "@/components/search/filters";
import { SearchResults } from "@/components/search/results";
import { SearchFilters as FilterType } from "@/types/search";
import { useSearch } from "@/hooks/use-search";
import { Loader2 } from "lucide-react";
import { useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { SearchFilters } from '@/components/search/filters';
import { SearchResults } from '@/components/search/results';
import { SearchFilters as FilterType } from '@/types/search';
import { useSearch } from '@/hooks/use-search';
import { Loader2 } from 'lucide-react';
function SearchContent() {
const searchParams = useSearchParams();
// URL Params state
const query = searchParams.get("q") || "";
const typeParam = searchParams.get("type");
const statusParam = searchParams.get("status");
const query = searchParams.get('q') || '';
const typeParam = searchParams.get('type');
const statusParam = searchParams.get('status');
// Local Filter State (synced with URL initially, but can be independent before apply)
// For simplicity, we'll keep filters in sync with valid search params or local state that pushes to URL
@@ -43,9 +43,8 @@ function SearchContent() {
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-muted-foreground mt-1">
{isLoading
? "Searching..."
: `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"`
}
? 'Searching...'
: `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"`}
</p>
</div>
@@ -69,11 +68,13 @@ function SearchContent() {
export default function SearchPage() {
return (
<div className="space-y-6">
<Suspense fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}>
<Suspense
fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}
>
<SearchContent />
</Suspense>
</div>
@@ -1,37 +1,34 @@
"use client";
'use client';
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { transmittalService } from "@/lib/services/transmittal.service";
import { Transmittal } from "@/types/transmittal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowLeft, RefreshCw, Printer } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { transmittalService } from '@/lib/services/transmittal.service';
import { Transmittal } from '@/types/transmittal';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { ArrowLeft, RefreshCw, Printer } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
import { toast } from 'sonner';
export default function TransmittalDetailPage() {
const params = useParams();
const uuid = params.uuid as string;
const { data: transmittal, isLoading, error } = useQuery<Transmittal>({
queryKey: ["transmittal", uuid],
const {
data: transmittal,
isLoading,
error,
} = useQuery<Transmittal>({
queryKey: ['transmittal', uuid],
queryFn: () => transmittalService.getByUuid(uuid),
enabled: !!uuid,
});
const handlePrint = () => {
toast.info("PDF Export is coming soon...");
toast.info('PDF Export is coming soon...');
// TODO: Implement PDF download
};
@@ -74,7 +71,7 @@ export default function TransmittalDetailPage() {
{transmittal.correspondence?.correspondenceNumber || transmittal.transmittalNo}
</h1>
<p className="text-muted-foreground">
{transmittal.correspondence?.revisions?.find(r => r.isCurrent)?.title || transmittal.subject}
{transmittal.correspondence?.revisions?.find((r) => r.isCurrent)?.title || transmittal.subject}
</p>
</div>
</div>
@@ -92,12 +89,12 @@ export default function TransmittalDetailPage() {
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Purpose</p>
<Badge variant="outline">{transmittal.purpose || "OTHER"}</Badge>
<Badge variant="outline">{transmittal.purpose || 'OTHER'}</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Date</p>
<p className="font-medium">
{format(new Date(transmittal.correspondence?.createdAt || transmittal.createdAt), "dd MMM yyyy")}
{format(new Date(transmittal.correspondence?.createdAt || transmittal.createdAt), 'dd MMM yyyy')}
</p>
</div>
<div>
@@ -116,9 +113,7 @@ export default function TransmittalDetailPage() {
{transmittal.remarks && (
<div className="col-span-2">
<p className="text-sm text-muted-foreground">Remarks</p>
<p className="font-medium whitespace-pre-wrap">
{transmittal.remarks}
</p>
<p className="font-medium whitespace-pre-wrap">{transmittal.remarks}</p>
</div>
)}
</CardContent>
@@ -144,10 +139,8 @@ export default function TransmittalDetailPage() {
<TableCell>
<Badge variant="secondary">{item.itemType}</Badge>
</TableCell>
<TableCell className="font-medium">
{item.documentNumber || `ID: ${item.itemId}`}
</TableCell>
<TableCell>{item.description || "-"}</TableCell>
<TableCell className="font-medium">{item.documentNumber || `ID: ${item.itemId}`}</TableCell>
<TableCell>{item.description || '-'}</TableCell>
</TableRow>
))}
{(!transmittal.items || transmittal.items.length === 0) && (
@@ -1,9 +1,9 @@
"use client";
'use client';
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { TransmittalForm } from "@/components/transmittal/transmittal-form";
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { TransmittalForm } from '@/components/transmittal/transmittal-form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -23,9 +23,7 @@ export default function CreateTransmittalPage() {
</Link>
<div>
<h1 className="text-2xl font-bold">Create Transmittal</h1>
<p className="text-muted-foreground">
Prepare a new document transmittal slip
</p>
<p className="text-muted-foreground">Prepare a new document transmittal slip</p>
</div>
</div>
+26 -45
View File
@@ -1,39 +1,28 @@
"use client";
'use client';
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { TransmittalList } from "@/components/transmittal/transmittal-list";
import { transmittalService } from "@/lib/services/transmittal.service";
import { projectService } from "@/lib/services/project.service";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link";
import { TransmittalListResponse } from "@/types/transmittal";
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { TransmittalList } from '@/components/transmittal/transmittal-list';
import { transmittalService } from '@/lib/services/transmittal.service';
import { projectService } from '@/lib/services/project.service';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, RefreshCw } from 'lucide-react';
import Link from 'next/link';
import { TransmittalListResponse } from '@/types/transmittal';
export default function TransmittalPage() {
// ADR-019: Dynamic project selection via UUID
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>("");
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>('');
const { data: projectsData } = useQuery({
queryKey: ["projects-for-transmittals"],
queryKey: ['projects-for-transmittals'],
queryFn: () => projectService.getAll(),
});
const projects = projectsData?.data || projectsData || [];
const {
data,
isLoading,
error,
refetch,
} = useQuery<TransmittalListResponse>({
queryKey: ["transmittals", selectedProjectUuid],
const { data, isLoading, error, refetch } = useQuery<TransmittalListResponse>({
queryKey: ['transmittals', selectedProjectUuid],
queryFn: () => transmittalService.getAll({ projectId: selectedProjectUuid }),
enabled: !!selectedProjectUuid,
});
@@ -43,19 +32,11 @@ export default function TransmittalPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Transmittals</h1>
<p className="text-muted-foreground">
Manage document transmittal slips
</p>
<p className="text-muted-foreground">Manage document transmittal slips</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
title="Refresh"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading} title="Refresh">
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Link href="/transmittals/new">
<Button>
@@ -74,19 +55,19 @@ export default function TransmittalPage() {
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{(Array.isArray(projects) ? projects : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.projectName || p.projectCode}
</SelectItem>
))}
{(Array.isArray(projects) ? projects : []).map(
(p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.projectName || p.projectCode}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
Failed to load transmittals.
</div>
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">Failed to load transmittals.</div>
)}
{isLoading ? (
+2 -2
View File
@@ -1,4 +1,4 @@
// File: app/api/auth/[...nextauth]/route.ts
import { GET, POST } from "@/lib/auth";
import { GET, POST } from '@/lib/auth';
export { GET, POST };
export { GET, POST };
+3 -11
View File
@@ -4,15 +4,9 @@ import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[App Error Boundary]', error);
// // console.error('[App Error Boundary]', error); /* TODO: Remove before prod */
}, [error]);
return (
@@ -23,9 +17,7 @@ export default function Error({
<p className="text-muted-foreground mt-1 text-sm max-w-md">
{error.message || 'An unexpected error occurred. Please try again.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
)}
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
</div>
<Button onClick={reset} variant="outline">
Try again
+4 -16
View File
@@ -4,15 +4,9 @@ import { useEffect } from 'react';
// global-error.tsx catches errors in the root layout.tsx itself.
// It MUST include its own <html> and <body> tags per Next.js spec.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[Global Error Boundary]', error);
// // console.error('[Global Error Boundary]', error); /* TODO: Remove before prod */
}, [error]);
return (
@@ -30,17 +24,11 @@ export default function GlobalError({
padding: '16px',
}}
>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
Application Error
</h2>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>Application Error</h2>
<p style={{ color: '#6b7280', fontSize: '0.875rem', maxWidth: '400px' }}>
{error.message || 'A critical error occurred. Please refresh the page.'}
</p>
{error.digest && (
<p style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
Error ID: {error.digest}
</p>
)}
{error.digest && <p style={{ fontSize: '0.75rem', color: '#9ca3af' }}>Error ID: {error.digest}</p>}
<button
onClick={reset}
style={{
-4
View File
@@ -8,8 +8,6 @@
}
}
@layer base {
:root {
--background: 0 0% 100%;
@@ -66,8 +64,6 @@
}
}
@layer base {
* {
@apply border-border;
+14 -17
View File
@@ -1,17 +1,17 @@
// File: app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import QueryProvider from "@/providers/query-provider";
import SessionProvider from "@/providers/session-provider"; // ✅ Import เข้ามา
import { Toaster } from "@/components/ui/sonner";
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';
import QueryProvider from '@/providers/query-provider';
import SessionProvider from '@/providers/session-provider'; // ✅ Import เข้ามา
import { Toaster } from '@/components/ui/sonner';
const inter = Inter({ subsets: ["latin"] });
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: "LCBP3-DMS",
description: "Document Management System for Laem Chabang Port Phase 3",
title: 'LCBP3-DMS',
description: 'Document Management System for Laem Chabang Port Phase 3',
};
interface RootLayoutProps {
@@ -22,13 +22,10 @@ export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
inter.className
)}
>
<SessionProvider> {/* ✅ หุ้มด้วย SessionProvider เป็นชั้นนอกสุด หรือใน body */}
<body className={cn('min-h-screen bg-background font-sans antialiased', inter.className)}>
<SessionProvider>
{' '}
{/* ✅ หุ้มด้วย SessionProvider เป็นชั้นนอกสุด หรือใน body */}
<QueryProvider>
{children}
<Toaster />
+3 -3
View File
@@ -1,8 +1,8 @@
// File: app/page.tsx
import { redirect } from "next/navigation";
import { redirect } from 'next/navigation';
export default function RootPage() {
// เมื่อเข้าหน้าแรก ให้ Redirect ไปที่ /dashboard ทันที
// ซึ่งถ้ายังไม่ Login -> Middleware จะดีดไปหน้า /login ให้เอง
redirect("/dashboard");
}
redirect('/dashboard');
}
@@ -1,45 +1,30 @@
"use client";
'use client';
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useCreateOrganization,
useUpdateOrganization,
} from "@/hooks/use-master-data";
import { useEffect } from "react";
import { Organization } from "@/types/organization";
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useCreateOrganization, useUpdateOrganization } from '@/hooks/use-master-data';
import { useEffect } from 'react';
import { Organization } from '@/types/organization';
// Organization role types matching database
const ORGANIZATION_ROLES = [
{ value: "1", label: "Owner" },
{ value: "2", label: "Designer" },
{ value: "3", label: "Consultant" },
{ value: "4", label: "Contractor" },
{ value: "5", label: "Third Party" },
{ value: '1', label: 'Owner' },
{ value: '2', label: 'Designer' },
{ value: '3', label: 'Consultant' },
{ value: '4', label: 'Contractor' },
{ value: '5', label: 'Third Party' },
] as const;
const organizationSchema = z.object({
organizationCode: z.string().min(1, "Organization Code is required"),
organizationName: z.string().min(1, "Organization Name is required"),
organizationCode: z.string().min(1, 'Organization Code is required'),
organizationName: z.string().min(1, 'Organization Name is required'),
roleId: z.string().optional(),
isActive: z.boolean().optional(),
});
@@ -52,11 +37,7 @@ interface OrganizationDialogProps {
organization?: Organization | null;
}
export function OrganizationDialog({
open,
onOpenChange,
organization,
}: OrganizationDialogProps) {
export function OrganizationDialog({ open, onOpenChange, organization }: OrganizationDialogProps) {
const createOrg = useCreateOrganization();
const updateOrg = useUpdateOrganization();
@@ -71,9 +52,9 @@ export function OrganizationDialog({
} = useForm<OrganizationFormData>({
resolver: zodResolver(organizationSchema),
defaultValues: {
organizationCode: "",
organizationName: "",
roleId: "",
organizationCode: '',
organizationName: '',
roleId: '',
isActive: true,
},
});
@@ -83,14 +64,14 @@ export function OrganizationDialog({
reset({
organizationCode: organization.organizationCode,
organizationName: organization.organizationName,
roleId: organization.roleId?.toString() || "",
roleId: organization.roleId?.toString() || '',
isActive: organization.isActive,
});
} else {
reset({
organizationCode: "",
organizationName: "",
roleId: "",
organizationCode: '',
organizationName: '',
roleId: '',
isActive: true,
});
}
@@ -99,14 +80,11 @@ export function OrganizationDialog({
const onSubmit = (data: OrganizationFormData) => {
const submitData = {
...data,
roleId: data.roleId ? parseInt(data.roleId) : undefined,
roleId: data.roleId ? Number(data.roleId) : undefined,
};
if (organization) {
updateOrg.mutate(
{ uuid: organization.uuid, data: submitData },
{ onSuccess: () => onOpenChange(false) }
);
updateOrg.mutate({ uuid: organization.uuid, data: submitData }, { onSuccess: () => onOpenChange(false) });
} else {
createOrg.mutate(submitData, {
onSuccess: () => onOpenChange(false),
@@ -118,31 +96,19 @@ export function OrganizationDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{organization ? "Edit Organization" : "New Organization"}
</DialogTitle>
<DialogTitle>{organization ? 'Edit Organization' : 'New Organization'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Organization Code *</Label>
<Input
placeholder="e.g. OWNER"
{...register("organizationCode")}
/>
{errors.organizationCode && (
<p className="text-sm text-red-500">
{errors.organizationCode.message}
</p>
)}
<Input placeholder="e.g. OWNER" {...register('organizationCode')} />
{errors.organizationCode && <p className="text-sm text-red-500">{errors.organizationCode.message}</p>}
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select
value={watch("roleId")}
onValueChange={(value) => setValue("roleId", value)}
>
<Select value={watch('roleId')} onValueChange={(value) => setValue('roleId', value)}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
@@ -159,49 +125,28 @@ export function OrganizationDialog({
<div className="space-y-2">
<Label>Organization Name *</Label>
<Input
placeholder="e.g. Project Owner Co., Ltd."
{...register("organizationName")}
/>
{errors.organizationName && (
<p className="text-sm text-red-500">
{errors.organizationName.message}
</p>
)}
<Input placeholder="e.g. Project Owner Co., Ltd." {...register('organizationName')} />
{errors.organizationName && <p className="text-sm text-red-500">{errors.organizationName.message}</p>}
</div>
<div className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<Label>Active Status</Label>
<p className="text-sm text-muted-foreground">
Enable or disable this organization
</p>
<p className="text-sm text-muted-foreground">Enable or disable this organization</p>
</div>
<Controller
control={control}
name="isActive"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={createOrg.isPending || updateOrg.isPending}
>
{organization ? "Save Changes" : "Create Organization"}
<Button type="submit" disabled={createOrg.isPending || updateOrg.isPending}>
{organization ? 'Save Changes' : 'Create Organization'}
</Button>
</DialogFooter>
</form>
@@ -1,34 +1,16 @@
"use client";
'use client';
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
flexRender,
getCoreRowModel,
useReactTable,
ColumnDef,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { flexRender, getCoreRowModel, useReactTable, ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { useForm } from 'react-hook-form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
@@ -38,21 +20,15 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
} from '@/components/ui/alert-dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
interface Field {
name: string;
label: string;
type: "text" | "number" | "checkbox" | "select" | "textarea";
type: 'text' | 'number' | 'checkbox' | 'select' | 'textarea';
required?: boolean;
options?: { label: string; value: string | number }[];
}
@@ -93,7 +69,11 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
const [editingItem, setEditingId] = useState<number | null>(null);
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
const { data: rawData, isLoading, refetch } = useQuery({
const {
data: rawData,
isLoading,
_refetch,
} = useQuery({
queryKey,
queryFn: fetchFn,
});
@@ -154,14 +134,10 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
columns: [
...columns,
{
id: "actions",
id: 'actions',
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(row.original)}
>
<Button variant="ghost" size="icon" onClick={() => handleEdit(row.original)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
@@ -183,7 +159,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
setEditingId(null);
reset();
fields.forEach((f) => {
if (f.type === "checkbox") setValue(f.name, true);
if (f.type === 'checkbox') setValue(f.name, true);
});
setIsDialogOpen(true);
};
@@ -192,11 +168,11 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
setEditingId(item.id as number);
reset(item as Record<string, unknown>);
// Ensure select values are strings for Shadcn Select
fields.forEach(f => {
const record = item as Record<string, unknown>;
if (f.type === 'select' && record[f.name]) {
setValue(f.name, String(record[f.name]));
}
fields.forEach((f) => {
const record = item as Record<string, unknown>;
if (f.type === 'select' && record[f.name]) {
setValue(f.name, String(record[f.name]));
}
});
setIsDialogOpen(true);
};
@@ -214,9 +190,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
{description && <p className="text-muted-foreground">{description}</p>}
</div>
<Button onClick={handleAdd}>
<Plus className="h-4 w-4 mr-2" /> Add {entityName}
@@ -232,12 +206,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
@@ -246,10 +215,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length + 1}
className="h-24 text-center"
>
<TableCell colSpan={columns.length + 1} className="h-24 text-center">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
@@ -258,10 +224,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length + 1}
className="h-24 text-center text-muted-foreground"
>
<TableCell colSpan={columns.length + 1} className="h-24 text-center text-muted-foreground">
No data found.
</TableCell>
</TableRow>
@@ -269,12 +232,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
@@ -286,17 +244,15 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingItem ? `Edit ${entityName}` : `Add New ${entityName}`}
</DialogTitle>
<DialogTitle>{editingItem ? `Edit ${entityName}` : `Add New ${entityName}`}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-4">
{fields.map((field) => (
<div key={field.name} className="space-y-2">
<Label htmlFor={field.name}>
{field.label} {field.required && "*"}
{field.label} {field.required && '*'}
</Label>
{field.type === "checkbox" ? (
{field.type === 'checkbox' ? (
<div className="flex items-center space-x-2">
<Checkbox
id={field.name}
@@ -310,11 +266,8 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
Active
</label>
</div>
) : field.type === "select" ? (
<Select
value={String(watch(field.name) || "")}
onValueChange={(val) => setValue(field.name, val)}
>
) : field.type === 'select' ? (
<Select value={String(watch(field.name) || '')} onValueChange={(val) => setValue(field.name, val)}>
<SelectTrigger>
<SelectValue placeholder={`Select ${field.label}...`} />
</SelectTrigger>
@@ -326,57 +279,43 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
))}
</SelectContent>
</Select>
) : field.type === "textarea" ? (
<Textarea
id={field.name}
{...register(field.name, { required: field.required })}
/>
) : field.type === 'textarea' ? (
<Textarea id={field.name} {...register(field.name, { required: field.required })} />
) : (
<Input
id={field.name}
type={field.type}
{...register(field.name, {
required: field.required,
valueAsNumber: field.type === "number",
valueAsNumber: field.type === 'number',
})}
/>
)}
{errors[field.name] && (
<p className="text-xs text-red-500 font-medium">
{field.label} is required
</p>
)}
{errors[field.name] && <p className="text-xs text-red-500 font-medium">{field.label} is required</p>}
</div>
))}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{(createMutation.isPending || updateMutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{editingItem ? "Save Changes" : `Add ${entityName}`}
{editingItem ? 'Save Changes' : `Add ${entityName}`}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={itemToDelete !== null}
onOpenChange={(open) => !open && setItemToDelete(null)}
>
<AlertDialog open={itemToDelete !== null} onOpenChange={(open) => !open && setItemToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete this{" "}
{entityName.toLowerCase()} and remove its data from our servers.
This action cannot be undone. This will permanently delete this {entityName.toLowerCase()} and remove its
data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -385,7 +324,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
onClick={() => itemToDelete && deleteMutation.mutate(itemToDelete)}
className="bg-red-600 hover:bg-red-700"
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,20 +1,13 @@
"use client";
'use client';
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { RefreshCw, Save } from "lucide-react";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import apiClient from "@/lib/api/client";
import { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { RefreshCw, Save } from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '@/lib/api/client';
interface Role {
roleId: number;
@@ -28,7 +21,7 @@ interface Permission {
description: string;
}
interface RbacMatrixProps {
interface _RbacMatrixProps {
roles: Role[];
permissions: Permission[];
rolePermissions: Record<number, number[]>; // roleId -> permissionIds[]
@@ -42,7 +35,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return [];
}
@@ -54,11 +47,11 @@ const extractArrayData = <T,>(value: unknown): T[] => {
const securityService = {
getRoles: async (): Promise<Role[]> => {
const response = await apiClient.get("/users/roles");
const response = await apiClient.get('/users/roles');
return extractArrayData<Role>(response.data);
},
getPermissions: async (): Promise<Permission[]> => {
const response = await apiClient.get("/users/permissions");
const response = await apiClient.get('/users/permissions');
return extractArrayData<Permission>(response.data);
},
updateRolePermissions: async (roleId: number, permissionIds: number[]) => {
@@ -73,12 +66,12 @@ export function RbacMatrix() {
const [pendingChanges, setPendingChanges] = useState<Record<number, number[]>>({});
const { data: roles = [], isLoading: rolesLoading } = useQuery<Role[]>({
queryKey: ["roles"],
queryKey: ['roles'],
queryFn: securityService.getRoles,
});
const { data: permissions = [], isLoading: permsLoading } = useQuery<Permission[]>({
queryKey: ["permissions"],
queryKey: ['permissions'],
queryFn: securityService.getPermissions,
});
@@ -92,16 +85,16 @@ export function RbacMatrix() {
const updateMutation = useMutation({
mutationFn: async (changes: Record<number, number[]>) => {
const promises = Object.entries(changes).map(([roleId, perms]) =>
securityService.updateRolePermissions(parseInt(roleId), perms)
securityService.updateRolePermissions(Number(roleId), perms)
);
return Promise.all(promises);
},
onSuccess: () => {
toast.success("Permissions updated successfully");
toast.success('Permissions updated successfully');
setPendingChanges({});
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ['roles'] });
},
onError: () => toast.error("Failed to update permissions"),
onError: () => toast.error('Failed to update permissions'),
});
const handleToggle = (roleId: number, permId: number, currentPerms: number[]) => {
+192 -223
View File
@@ -1,65 +1,63 @@
"use client";
'use client';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useCreateUser, useUpdateUser, useRoles } from "@/hooks/use-users";
import { useOrganizations } from "@/hooks/use-master-data";
import { useEffect, useState } from "react";
import { User } from "@/types/user";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Eye, EyeOff } from "lucide-react";
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { useCreateUser, useUpdateUser, useRoles } from '@/hooks/use-users';
import { useOrganizations } from '@/hooks/use-master-data';
import { useEffect, useState } from 'react';
import { User } from '@/types/user';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Eye, EyeOff } from 'lucide-react';
const ALL_ORGANIZATIONS_VALUE = "all";
const ALL_ORGANIZATIONS_VALUE = 'all';
// Update schema to include confirmPassword
const userSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
password: z.string().optional(),
confirmPassword: z.string().optional(),
isActive: z.boolean().optional(),
lineId: z.string().optional(),
primaryOrganizationId: z.string().optional(),
roleIds: z.array(z.number()).optional(),
}).refine((data) => {
// If password is provided (creating or resetting), confirmPassword must match
if (data.password && data.password !== data.confirmPassword) {
return false;
}
return true;
}, {
message: "Passwords do not match",
path: ["confirmPassword"],
}).refine((data) => {
// Password required for creation
// We can't easily check "isCreating" here without context, checking length if provided
if (data.password && data.password.length < 6) {
return false;
}
return true;
}, {
message: "Password must be at least 6 characters",
path: ["password"]
});
const userSchema = z
.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
password: z.string().optional(),
confirmPassword: z.string().optional(),
isActive: z.boolean().optional(),
lineId: z.string().optional(),
primaryOrganizationId: z.string().optional(),
roleIds: z.array(z.number()).optional(),
})
.refine(
(data) => {
// If password is provided (creating or resetting), confirmPassword must match
if (data.password && data.password !== data.confirmPassword) {
return false;
}
return true;
},
{
message: 'Passwords do not match',
path: ['confirmPassword'],
}
)
.refine(
(data) => {
// Password required for creation
// We can't easily check "isCreating" here without context, checking length if provided
if (data.password && data.password.length < 6) {
return false;
}
return true;
},
{
message: 'Password must be at least 6 characters',
path: ['password'],
}
);
type UserFormData = z.infer<typeof userSchema>;
@@ -87,16 +85,16 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
username: "",
email: "",
firstName: "",
lastName: "",
username: '',
email: '',
firstName: '',
lastName: '',
isActive: true,
roleIds: [],
lineId: "",
lineId: '',
primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
password: "",
confirmPassword: ""
password: '',
confirmPassword: '',
},
});
@@ -108,24 +106,24 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
firstName: user.firstName,
lastName: user.lastName,
isActive: user.isActive,
lineId: user.lineId || "",
lineId: user.lineId || '',
primaryOrganizationId: user.primaryOrganizationId?.toString() || ALL_ORGANIZATIONS_VALUE,
roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [],
password: "",
confirmPassword: ""
password: '',
confirmPassword: '',
});
} else {
reset({
username: "",
email: "",
firstName: "",
lastName: "",
username: '',
email: '',
firstName: '',
lastName: '',
isActive: true,
lineId: "",
lineId: '',
primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
roleIds: [],
password: "",
confirmPassword: ""
password: '',
confirmPassword: '',
});
}
// Also reset visibility
@@ -133,17 +131,17 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
setShowConfirmPassword(false);
}, [user, reset, open]);
const selectedRoleIds = watch("roleIds") || [];
const selectedRoleIds = watch('roleIds') || [];
const onSubmit = (data: UserFormData) => {
// Basic validation for create vs update
if (!user && !data.password) {
// This should be caught by schema ideally, but refined schema is tricky with conditional
// Force error via set error not possible easily here, rely on form state?
// Actually the refine check handles length check if provided, but for create it is mandatory.
// Let's rely on server side or manual check if schema misses it (zod optional() makes it pass if undefined)
// Adjusting schema to be strict string for create is hard with one schema.
// Let's trust Zod or add checks.
// This should be caught by schema ideally, but refined schema is tricky with conditional
// Force error via set error not possible easily here, rely on form state?
// Actually the refine check handles length check if provided, but for create it is mandatory.
// Let's rely on server side or manual check if schema misses it (zod optional() makes it pass if undefined)
// Adjusting schema to be strict string for create is hard with one schema.
// Let's trust Zod or add checks.
}
// Clean up data
@@ -155,27 +153,27 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
}
if (user) {
updateUser.mutate(
{ uuid: user.uuid, data: payload },
{ onSuccess: () => onOpenChange(false) }
);
updateUser.mutate({ uuid: user.uuid, data: payload }, { onSuccess: () => onOpenChange(false) });
} else {
// Create req: Password mandatory
if (!payload.password) return; // Should allow Zod to catch or show error
createUser.mutate({
username: payload.username,
email: payload.email,
firstName: payload.firstName,
lastName: payload.lastName,
password: payload.password,
isActive: payload.isActive ?? true,
lineId: payload.lineId,
primaryOrganizationId: payload.primaryOrganizationId,
roleIds: payload.roleIds ?? [],
}, {
onSuccess: () => onOpenChange(false),
});
createUser.mutate(
{
username: payload.username,
email: payload.email,
firstName: payload.firstName,
lastName: payload.lastName,
password: payload.password,
isActive: payload.isActive ?? true,
lineId: payload.lineId,
primaryOrganizationId: payload.primaryOrganizationId,
roleIds: payload.roleIds ?? [],
},
{
onSuccess: () => onOpenChange(false),
}
);
}
};
@@ -183,77 +181,61 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{user ? "Edit User" : "Create New User"}</DialogTitle>
<DialogTitle>{user ? 'Edit User' : 'Create New User'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Username *</Label>
<Input
{...register("username")}
disabled={!!user}
autoComplete="off"
/>
{errors.username && (
<p className="text-sm text-red-500">{errors.username.message}</p>
)}
<Input {...register('username')} disabled={!!user} autoComplete="off" />
{errors.username && <p className="text-sm text-red-500">{errors.username.message}</p>}
</div>
<div>
<Label>Email *</Label>
<Input type="email" {...register("email")} autoComplete="off" />
{errors.email && (
<p className="text-sm text-red-500">{errors.email.message}</p>
)}
<Input type="email" {...register('email')} autoComplete="off" />
{errors.email && <p className="text-sm text-red-500">{errors.email.message}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>First Name *</Label>
<Input {...register("firstName")} autoComplete="off" />
{errors.firstName && (
<p className="text-sm text-red-500">{errors.firstName.message}</p>
)}
<Input {...register('firstName')} autoComplete="off" />
{errors.firstName && <p className="text-sm text-red-500">{errors.firstName.message}</p>}
</div>
<div>
<Label>Last Name *</Label>
<Input {...register("lastName")} autoComplete="off" />
{errors.lastName && (
<p className="text-sm text-red-500">{errors.lastName.message}</p>
)}
<Input {...register('lastName')} autoComplete="off" />
{errors.lastName && <p className="text-sm text-red-500">{errors.lastName.message}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Line ID</Label>
<Input {...register("lineId")} autoComplete="off" />
<Input {...register('lineId')} autoComplete="off" />
</div>
<div>
<Label>Primary Organization</Label>
<Select
value={watch("primaryOrganizationId") || ALL_ORGANIZATIONS_VALUE}
onValueChange={(val) =>
setValue("primaryOrganizationId", val)
}
value={watch('primaryOrganizationId') || ALL_ORGANIZATIONS_VALUE}
onValueChange={(val) => setValue('primaryOrganizationId', val)}
>
<SelectTrigger>
<SelectValue placeholder="Select Organization" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_ORGANIZATIONS_VALUE}>All Organizations</SelectItem>
{Array.isArray(organizations) && organizations.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
<SelectItem
key={org.uuid}
value={org.uuid}
>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
{Array.isArray(organizations) &&
organizations.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -261,91 +243,88 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
{/* Password Section - Show for Create, or Optional for Edit */}
<div className="space-y-4 border p-4 rounded-md">
<h3 className="text-sm font-medium">{user ? "Change Password (Optional)" : "Password Setup"}</h3>
<h3 className="text-sm font-medium">{user ? 'Change Password (Optional)' : 'Password Setup'}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="relative">
<Label>Password {user ? '' : '*'}</Label>
<div className="relative">
<Label>Password {user ? "" : "*"}</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
{...register("password")}
autoComplete="new-password"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.password && (
<p className="text-sm text-red-500">{errors.password.message}</p>
)}
<Input
type={showPassword ? 'text' : 'password'}
{...register('password')}
autoComplete="new-password"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
</div>
<div className="relative">
<Label>Confirm Password {user ? "" : "*"}</Label>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
{...register("confirmPassword")}
autoComplete="new-password"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.confirmPassword && (
<p className="text-sm text-red-500">{errors.confirmPassword.message}</p>
)}
<div className="relative">
<Label>Confirm Password {user ? '' : '*'}</Label>
<div className="relative">
<Input
type={showConfirmPassword ? 'text' : 'password'}
{...register('confirmPassword')}
autoComplete="new-password"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{errors.confirmPassword && <p className="text-sm text-red-500">{errors.confirmPassword.message}</p>}
</div>
</div>
</div>
<div>
<Label className="mb-3 block">Roles</Label>
<div className="space-y-2 border p-3 rounded-md max-h-[200px] overflow-y-auto">
{Array.isArray(roles) && roles.length === 0 && <p className="text-sm text-muted-foreground">Loading roles...</p>}
{Array.isArray(roles) && roles.map((role: { roleId: number; roleName: string; description?: string }) => (
<div key={role.roleId} className="flex items-start space-x-2">
<Checkbox
id={`role-${role.roleId}`}
checked={selectedRoleIds.includes(role.roleId)}
onCheckedChange={(checked) => {
const current = selectedRoleIds;
if (checked) {
setValue("roleIds", [...current, role.roleId]);
} else {
setValue(
"roleIds",
current.filter((id) => id !== role.roleId)
);
}
}}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={`role-${role.roleId}`}
className="text-sm font-medium leading-none cursor-pointer"
>
{role.roleName}
</label>
<p className="text-xs text-muted-foreground">
{role.description}
</p>
{Array.isArray(roles) && roles.length === 0 && (
<p className="text-sm text-muted-foreground">Loading roles...</p>
)}
{Array.isArray(roles) &&
roles.map((role: { roleId: number; roleName: string; description?: string }) => (
<div key={role.roleId} className="flex items-start space-x-2">
<Checkbox
id={`role-${role.roleId}`}
checked={selectedRoleIds.includes(role.roleId)}
onCheckedChange={(checked) => {
const current = selectedRoleIds;
if (checked) {
setValue('roleIds', [...current, role.roleId]);
} else {
setValue(
'roleIds',
current.filter((id) => id !== role.roleId)
);
}
}}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={`role-${role.roleId}`}
className="text-sm font-medium leading-none cursor-pointer"
>
{role.roleName}
</label>
<p className="text-xs text-muted-foreground">{role.description}</p>
</div>
</div>
</div>
))}
))}
</div>
</div>
@@ -353,31 +332,21 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<div className="flex items-center space-x-2">
<Checkbox
id="is_active"
checked={watch("isActive")}
onCheckedChange={(chk) => setValue("isActive", chk === true)}
checked={watch('isActive')}
onCheckedChange={(chk) => setValue('isActive', chk === true)}
/>
<label
htmlFor="is_active"
className="text-sm font-medium leading-none cursor-pointer"
>
<label htmlFor="is_active" className="text-sm font-medium leading-none cursor-pointer">
Active User
</label>
</div>
)}
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={createUser.isPending || updateUser.isPending}
>
{user ? "Update User" : "Create User"}
<Button type="submit" disabled={createUser.isPending || updateUser.isPending}>
{user ? 'Update User' : 'Create User'}
</Button>
</div>
</form>
+1 -1
View File
@@ -35,7 +35,7 @@ export function AuthSync() {
firstName: user.firstName || '',
lastName: user.lastName || '',
role: user.role || 'User',
permissions: user.permissions
permissions: user.permissions,
},
(session as { accessToken?: string }).accessToken || ''
);
@@ -1,14 +1,14 @@
"use client";
'use client';
import { Circulation, CirculationListResponse } from "@/types/circulation";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button";
import { Eye, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { Circulation, CirculationListResponse } from '@/types/circulation';
import { DataTable } from '@/components/common/data-table';
import { ColumnDef } from '@tanstack/react-table';
import { _StatusBadge } from '@/components/common/status-badge';
import { Button } from '@/components/ui/button';
import { Eye, _CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
import { Badge } from '@/components/ui/badge';
interface CirculationListProps {
data: CirculationListResponse;
@@ -17,29 +17,27 @@ interface CirculationListProps {
/**
* Calculate progress of circulation routings
*/
function getProgress(routings?: Circulation["routings"]) {
function getProgress(routings?: Circulation['routings']) {
if (!routings || routings.length === 0) return { completed: 0, total: 0 };
const completed = routings.filter((r) => r.status === "COMPLETED").length;
const completed = routings.filter((r) => r.status === 'COMPLETED').length;
return { completed, total: routings.length };
}
/**
* Get status color variant for circulation status
*/
function getStatusVariant(
statusCode: string
): "default" | "secondary" | "destructive" | "outline" {
function getStatusVariant(statusCode: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (statusCode?.toUpperCase()) {
case "DRAFT":
return "outline";
case "ACTIVE":
case "IN_PROGRESS":
return "default";
case "COMPLETED":
case "CLOSED":
return "secondary";
case 'DRAFT':
return 'outline';
case 'ACTIVE':
case 'IN_PROGRESS':
return 'default';
case 'COMPLETED':
case 'CLOSED':
return 'secondary';
default:
return "outline";
return 'outline';
}
}
@@ -48,51 +46,46 @@ export function CirculationList({ data }: CirculationListProps) {
const columns: ColumnDef<Circulation>[] = [
{
accessorKey: "circulationNo",
header: "Circulation No.",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("circulationNo")}</span>
),
accessorKey: 'circulationNo',
header: 'Circulation No.',
cell: ({ row }) => <span className="font-medium">{row.getValue('circulationNo')}</span>,
},
{
accessorKey: "subject",
header: "Subject",
accessorKey: 'subject',
header: 'Subject',
cell: ({ row }) => (
<div className="max-w-[250px] truncate" title={row.getValue("subject")}>
{row.getValue("subject")}
<div className="max-w-[250px] truncate" title={row.getValue('subject')}>
{row.getValue('subject')}
</div>
),
},
{
accessorKey: "organization",
header: "Organization",
accessorKey: 'organization',
header: 'Organization',
cell: ({ row }) => {
const org = row.original.organization;
return org?.organization_name || "-";
return org?.organization_name || '-';
},
},
{
accessorKey: "statusCode",
header: "Status",
accessorKey: 'statusCode',
header: 'Status',
cell: ({ row }) => {
const status = row.getValue("statusCode") as string;
const status = row.getValue('statusCode') as string;
return <Badge variant={getStatusVariant(status)}>{status}</Badge>;
},
},
{
id: "progress",
header: "Progress",
id: 'progress',
header: 'Progress',
cell: ({ row }) => {
const { completed, total } = getProgress(row.original.routings);
if (total === 0) return "-";
if (total === 0) return '-';
const percent = Math.round((completed / total) * 100);
return (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${percent}%` }}
/>
<div className="h-full bg-primary transition-all" style={{ width: `${percent}%` }} />
</div>
<span className="text-xs text-muted-foreground">
{completed}/{total}
@@ -102,13 +95,12 @@ export function CirculationList({ data }: CirculationListProps) {
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) =>
format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
accessorKey: 'createdAt',
header: 'Created',
cell: ({ row }) => format(new Date(row.getValue('createdAt')), 'dd MMM yyyy'),
},
{
id: "actions",
id: 'actions',
cell: ({ row }) => {
const item = row.original;
return (
+1 -6
View File
@@ -16,12 +16,7 @@ interface CanProps {
// Common use case: <Can permission="x">
}
export function Can({
permission,
role,
children,
fallback = null,
}: CanProps) {
export function Can({ permission, role, children, fallback = null }: CanProps) {
const { hasPermission, hasRole } = useAuthStore();
let allowed = true;
@@ -1,4 +1,4 @@
"use client";
'use client';
import {
AlertDialog,
@@ -9,7 +9,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
} from '@/components/ui/alert-dialog';
interface ConfirmDialogProps {
open: boolean;
@@ -27,8 +27,8 @@ export function ConfirmDialog({
title,
description,
onConfirm,
confirmText = "Confirm",
cancelText = "Cancel",
confirmText = 'Confirm',
cancelText = 'Cancel',
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
@@ -39,9 +39,7 @@ export function ConfirmDialog({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{confirmText}
</AlertDialogAction>
<AlertDialogAction onClick={onConfirm}>{confirmText}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
+7 -11
View File
@@ -1,8 +1,8 @@
"use client";
'use client';
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface PaginationProps {
currentPage: number;
@@ -10,18 +10,14 @@ interface PaginationProps {
total: number;
}
export function Pagination({
currentPage,
totalPages,
total,
}: PaginationProps) {
export function Pagination({ currentPage, totalPages, total }: PaginationProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createPageURL = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
@@ -50,7 +46,7 @@ export function Pagination({
return (
<Button
key={pageNum}
variant={pageNum === currentPage ? "default" : "outline"}
variant={pageNum === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => router.push(createPageURL(pageNum))}
>
+29 -32
View File
@@ -1,5 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface StatusBadgeProps {
status: string;
@@ -7,56 +7,53 @@ interface StatusBadgeProps {
}
const statusConfig: Record<string, { label: string; variant: string }> = {
DRAFT: { label: "Draft", variant: "secondary" },
PENDING: { label: "Pending", variant: "warning" }, // Note: Shadcn/UI might not have 'warning' variant by default, may need custom CSS or use 'secondary'
IN_REVIEW: { label: "In Review", variant: "default" }, // Using 'default' (primary) for In Review
APPROVED: { label: "Approved", variant: "success" }, // Note: 'success' might need custom CSS
REJECTED: { label: "Rejected", variant: "destructive" },
CLOSED: { label: "Closed", variant: "outline" },
DRAFT: { label: 'Draft', variant: 'secondary' },
PENDING: { label: 'Pending', variant: 'warning' }, // Note: Shadcn/UI might not have 'warning' variant by default, may need custom CSS or use 'secondary'
IN_REVIEW: { label: 'In Review', variant: 'default' }, // Using 'default' (primary) for In Review
APPROVED: { label: 'Approved', variant: 'success' }, // Note: 'success' might need custom CSS
REJECTED: { label: 'Rejected', variant: 'destructive' },
CLOSED: { label: 'Closed', variant: 'outline' },
};
// Fallback for unknown statuses
const defaultStatus = { label: "Unknown", variant: "outline" };
const _defaultStatus = { label: 'Unknown', variant: 'outline' };
export function StatusBadge({ status, className }: StatusBadgeProps) {
const config = statusConfig[status] || { label: status, variant: "default" };
const config = statusConfig[status] || { label: status, variant: 'default' };
// Mapping custom variants to Shadcn Badge variants if needed
// For now, we'll assume standard variants or rely on className overrides for colors
let badgeVariant: "default" | "secondary" | "destructive" | "outline" = "default";
let customClass = "";
let badgeVariant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
let customClass = '';
switch (config.variant) {
case "secondary":
badgeVariant = "secondary";
case 'secondary':
badgeVariant = 'secondary';
break;
case "destructive":
badgeVariant = "destructive";
case 'destructive':
badgeVariant = 'destructive';
break;
case "outline":
badgeVariant = "outline";
case 'outline':
badgeVariant = 'outline';
break;
case "warning":
badgeVariant = "secondary"; // Fallback
customClass = "bg-yellow-500 hover:bg-yellow-600 text-white";
case 'warning':
badgeVariant = 'secondary'; // Fallback
customClass = 'bg-yellow-500 hover:bg-yellow-600 text-white';
break;
case "success":
badgeVariant = "default"; // Fallback
customClass = "bg-green-500 hover:bg-green-600 text-white";
case 'success':
badgeVariant = 'default'; // Fallback
customClass = 'bg-green-500 hover:bg-green-600 text-white';
break;
case "info":
badgeVariant = "default";
customClass = "bg-blue-500 hover:bg-blue-600 text-white";
case 'info':
badgeVariant = 'default';
customClass = 'bg-blue-500 hover:bg-blue-600 text-white';
break;
default:
badgeVariant = "default";
badgeVariant = 'default';
}
return (
<Badge
variant={badgeVariant}
className={cn("uppercase", customClass, className)}
>
<Badge variant={badgeVariant} className={cn('uppercase', customClass, className)}>
{config.label}
</Badge>
);
@@ -1,18 +1,18 @@
"use client";
'use client';
import { CorrespondenceList } from "@/components/correspondences/list";
import { Pagination } from "@/components/common/pagination";
import { useCorrespondences } from "@/hooks/use-correspondence";
import { useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { CorrespondenceList } from '@/components/correspondences/list';
import { Pagination } from '@/components/common/pagination';
import { useCorrespondences } from '@/hooks/use-correspondence';
import { useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
export function CorrespondencesContent() {
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const status = searchParams.get("status") || undefined;
const search = searchParams.get("search") || undefined;
const page = Number(searchParams.get('page') || '1');
const _status = searchParams.get('status') || undefined;
const search = searchParams.get('search') || undefined;
const revisionStatus = (searchParams.get('revisionStatus') as 'CURRENT' | 'ALL' | 'OLD') || 'CURRENT';
@@ -31,28 +31,23 @@ export function CorrespondencesContent() {
}
if (isError) {
return (
<div className="text-red-500 text-center py-8">
Failed to load correspondences.
</div>
);
return <div className="text-red-500 text-center py-8">Failed to load correspondences.</div>;
}
return (
<>
<div className="mb-4 flex gap-2">
<div className="flex gap-1 bg-muted p-1 rounded-md">
{['ALL', 'CURRENT', 'OLD'].map((status) => (
<Link key={status} href={`?${new URLSearchParams({...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1'}).toString()}`}>
<Button
variant={revisionStatus === status ? 'default' : 'ghost'}
size="sm"
className="text-xs px-3"
>
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
</Button>
</Link>
))}
<div className="flex gap-1 bg-muted p-1 rounded-md">
{['ALL', 'CURRENT', 'OLD'].map((status) => (
<Link
key={status}
href={`?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1' }).toString()}`}
>
<Button variant={revisionStatus === status ? 'default' : 'ghost'} size="sm" className="text-xs px-3">
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
</Button>
</Link>
))}
</div>
</div>
<CorrespondenceList data={data?.data || []} />
+92 -87
View File
@@ -1,16 +1,16 @@
"use client";
'use client';
import { Correspondence } from "@/types/correspondence";
import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { format } from "date-fns";
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle, Edit } from "lucide-react";
import Link from "next/link";
import { useSubmitCorrespondence, useProcessWorkflow } from "@/hooks/use-correspondence";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Correspondence } from '@/types/correspondence';
import { StatusBadge } from '@/components/common/status-badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns';
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle, Edit } from 'lucide-react';
import Link from 'next/link';
import { useSubmitCorrespondence, useProcessWorkflow } from '@/hooks/use-correspondence';
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
interface CorrespondenceDetailProps {
data: Correspondence;
@@ -19,26 +19,26 @@ interface CorrespondenceDetailProps {
export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
const submitMutation = useSubmitCorrespondence();
const processMutation = useProcessWorkflow();
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
const [comments, setComments] = useState("");
const [actionState, setActionState] = useState<'approve' | 'reject' | null>(null);
const [comments, setComments] = useState('');
if (!data) return <div>No data found</div>;
// Derive Current Revision Data
const currentRevision = data.revisions?.find(r => r.isCurrent) || data.revisions?.[0];
const subject = currentRevision?.subject || "-";
const description = currentRevision?.description || "-";
const status = currentRevision?.status?.statusCode || "UNKNOWN"; // e.g. DRAFT
const currentRevision = data.revisions?.find((r) => r.isCurrent) || data.revisions?.[0];
const subject = currentRevision?.subject || '-';
const description = currentRevision?.description || '-';
const status = currentRevision?.status?.statusCode || 'UNKNOWN'; // e.g. DRAFT
const attachments = currentRevision?.attachments || [];
// Note: Importance might be in details
const importance = currentRevision?.details?.importance || "NORMAL";
const importance = currentRevision?.details?.importance || 'NORMAL';
const handleSubmit = () => {
if (confirm("Are you sure you want to submit this correspondence?")) {
if (confirm('Are you sure you want to submit this correspondence?')) {
submitMutation.mutate({
uuid: data.uuid,
data: {}
data: {},
});
}
};
@@ -46,19 +46,22 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
const handleProcess = () => {
if (!actionState) return;
const action = actionState === "approve" ? "APPROVE" : "REJECT";
processMutation.mutate({
uuid: data.uuid,
data: {
action,
comments
const action = actionState === 'approve' ? 'APPROVE' : 'REJECT';
processMutation.mutate(
{
uuid: data.uuid,
data: {
action,
comments,
},
},
{
onSuccess: () => {
setActionState(null);
setComments('');
},
}
}, {
onSuccess: () => {
setActionState(null);
setComments("");
}
});
);
};
return (
@@ -74,40 +77,38 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
<div>
<h1 className="text-2xl font-bold">{data.correspondenceNumber}</h1>
<p className="text-muted-foreground">
Created on {data.createdAt ? format(new Date(data.createdAt), "dd MMM yyyy HH:mm") : '-'}
Created on {data.createdAt ? format(new Date(data.createdAt), 'dd MMM yyyy HH:mm') : '-'}
</p>
</div>
</div>
<div className="flex gap-2">
{/* EDIT BUTTON LOGIC: Show if DRAFT */}
{status === "DRAFT" && (
<Link href={`/correspondences/${data.uuid}/edit`}>
<Button variant="outline">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
{/* EDIT BUTTON LOGIC: Show if DRAFT */}
{status === 'DRAFT' && (
<Link href={`/correspondences/${data.uuid}/edit`}>
<Button variant="outline">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
)}
{status === "DRAFT" && (
{status === 'DRAFT' && (
<Button onClick={handleSubmit} disabled={submitMutation.isPending}>
{submitMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
{submitMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit for Review
</Button>
)}
{status === "IN_REVIEW" && (
{status === 'IN_REVIEW' && (
<>
<Button
variant="destructive"
onClick={() => setActionState("reject")}
>
<Button variant="destructive" onClick={() => setActionState('reject')}>
<XCircle className="mr-2 h-4 w-4" />
Reject
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => setActionState("approve")}
>
<Button className="bg-green-600 hover:bg-green-700" onClick={() => setActionState('approve')}>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</Button>
@@ -120,31 +121,33 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
{actionState && (
<Card className="border-primary">
<CardHeader>
<CardTitle className="text-lg">
{actionState === "approve" ? "Confirm Approval" : "Confirm Rejection"}
</CardTitle>
<CardTitle className="text-lg">
{actionState === 'approve' ? 'Confirm Approval' : 'Confirm Rejection'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Comments</Label>
<Textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Enter comments..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setActionState(null)}>Cancel</Button>
<Button
variant={actionState === "approve" ? "default" : "destructive"}
onClick={handleProcess}
disabled={processMutation.isPending}
className={actionState === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
>
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm {actionState === "approve" ? "Approve" : "Reject"}
</Button>
</div>
<div className="space-y-2">
<Label>Comments</Label>
<Textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Enter comments..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setActionState(null)}>
Cancel
</Button>
<Button
variant={actionState === 'approve' ? 'default' : 'destructive'}
onClick={handleProcess}
disabled={processMutation.isPending}
className={actionState === 'approve' ? 'bg-green-600 hover:bg-green-700' : ''}
>
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm {actionState === 'approve' ? 'Approve' : 'Reject'}
</Button>
</div>
</CardContent>
</Card>
)}
@@ -162,9 +165,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
<CardContent className="space-y-6">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-gray-700 whitespace-pre-wrap">
{description}
</p>
<p className="text-gray-700 whitespace-pre-wrap">{description}</p>
</div>
{currentRevision?.body && (
@@ -179,9 +180,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
{currentRevision?.remarks && (
<div>
<h3 className="font-semibold mb-2">Remarks</h3>
<p className="text-gray-600 italic">
{currentRevision.remarks}
</p>
<p className="text-gray-600 italic">{currentRevision.remarks}</p>
</div>
)}
@@ -226,10 +225,16 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
<div>
<p className="text-sm font-medium text-muted-foreground">Importance</p>
<div className="mt-1">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${importance === 'URGENT' ? 'bg-red-100 text-red-800' :
importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${
importance === 'URGENT'
? 'bg-red-100 text-red-800'
: importance === 'HIGH'
? 'bg-orange-100 text-orange-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{String(importance)}
</span>
</div>
@@ -243,7 +248,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
<p className="text-xs text-muted-foreground">{data.originator?.organizationCode || '-'}</p>
</div>
<div>
<div>
<p className="text-sm font-medium text-muted-foreground">Project</p>
<p className="font-medium mt-1">{data.project?.projectName || '-'}</p>
<p className="text-xs text-muted-foreground">{data.project?.projectCode || '-'}</p>
+209 -218
View File
@@ -1,36 +1,30 @@
"use client";
'use client';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FileUploadZone } from "@/components/custom/file-upload-zone";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence";
import { Organization } from "@/types/organization";
import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from "@/hooks/use-master-data";
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
import { useState, useEffect } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service";
import { numberingApi } from "@/lib/api/numbering";
import { useForm, Resolver } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { FileUploadZone } from '@/components/custom/file-upload-zone';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { useCreateCorrespondence, useUpdateCorrespondence } from '@/hooks/use-correspondence';
import { Organization } from '@/types/organization';
import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from '@/hooks/use-master-data';
import { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-correspondence.dto';
import { useState, useEffect } from 'react';
import { correspondenceService as _correspondenceService } from '@/lib/services/correspondence.service';
import { numberingApi } from '@/lib/api/numbering';
// Updated Zod Schema with all required fields
const correspondenceSchema = z.object({
projectId: z.string().min(1, "Please select a Project"),
documentTypeId: z.number().min(1, "Please select a Document Type"),
projectId: z.string().min(1, 'Please select a Project'),
documentTypeId: z.number().min(1, 'Please select a Document Type'),
disciplineId: z.number().optional(),
subject: z.string().min(5, "Subject must be at least 5 characters"),
subject: z.string().min(5, 'Subject must be at least 5 characters'),
description: z.string().optional(),
body: z.string().optional(),
remarks: z.string().optional(),
@@ -38,9 +32,9 @@ const correspondenceSchema = z.object({
documentDate: z.string().optional(),
issuedDate: z.string().optional(),
receivedDate: z.string().optional(),
fromOrganizationId: z.string().min(1, "Please select From Organization"),
toOrganizationId: z.string().min(1, "Please select To Organization"),
importance: z.enum(["NORMAL", "HIGH", "URGENT"]),
fromOrganizationId: z.string().min(1, 'Please select From Organization'),
toOrganizationId: z.string().min(1, 'Please select To Organization'),
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']),
attachments: z.array(z.instanceof(File)).optional(),
});
@@ -59,11 +53,37 @@ type CorrespondenceTypeOption = {
typeCode: string;
};
type DisciplineOption = {
interface DisciplineOption {
id: number;
disciplineCode: string;
codeNameEn?: string;
};
}
interface InitialCorrespondenceData {
projectId?: number | string;
project?: { uuid?: string };
correspondenceTypeId?: number;
disciplineId?: number;
revisions?: Array<{
isCurrent?: boolean;
subject?: string;
title?: string;
description?: string;
body?: string;
remarks?: string;
dueDate?: string;
documentDate?: string;
issuedDate?: string;
receivedDate?: string;
details?: { importance: 'NORMAL' | 'HIGH' | 'URGENT' };
}>;
originatorId?: number;
recipients?: Array<{
recipientType: string;
recipientOrganizationId: number;
}>;
correspondenceNumber?: string;
}
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
@@ -73,7 +93,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return [];
}
@@ -83,7 +103,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
return Array.isArray(current) ? (current as T[]) : [];
};
export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, uuid?: string }) {
export function CorrespondenceForm({ initialData, uuid }: { initialData?: InitialCorrespondenceData; uuid?: string }) {
const router = useRouter();
const createMutation = useCreateCorrespondence();
const updateMutation = useUpdateCorrespondence();
@@ -99,26 +119,26 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
const disciplines = extractArrayData<DisciplineOption>(disciplinesData);
// Extract initial values if editing
const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0];
const currentRev = initialData?.revisions?.find((r) => r.isCurrent) || initialData?.revisions?.[0];
const defaultValues: Partial<FormData> = {
projectId: initialData?.project?.uuid || (initialData?.projectId ? String(initialData.projectId) : undefined),
documentTypeId: initialData?.correspondenceTypeId || undefined,
disciplineId: initialData?.disciplineId || undefined,
subject: currentRev?.subject || currentRev?.title || "",
description: currentRev?.description || "",
body: currentRev?.body || "",
remarks: currentRev?.remarks || "",
subject: currentRev?.subject || currentRev?.title || '',
description: currentRev?.description || '',
body: currentRev?.body || '',
remarks: currentRev?.remarks || '',
dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined,
documentDate: currentRev?.documentDate ? new Date(currentRev.documentDate).toISOString().split('T')[0] : undefined,
issuedDate: currentRev?.issuedDate ? new Date(currentRev.issuedDate).toISOString().split('T')[0] : undefined,
receivedDate: currentRev?.receivedDate ? new Date(currentRev.receivedDate).toISOString().split('T')[0] : undefined,
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined,
// Map initial recipient (TO) - Simplified for now
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId
? String(initialData.recipients.find((r: any) => r.recipientType === 'TO').recipientOrganizationId)
toOrganizationId: initialData?.recipients?.find((r) => r.recipientType === 'TO')?.recipientOrganizationId
? String(initialData.recipients.find((r) => r.recipientType === 'TO')?.recipientOrganizationId)
: undefined,
importance: currentRev?.details?.importance || "NORMAL",
};
importance: currentRev?.details?.importance || 'NORMAL',
} as Partial<FormData>;
const {
register,
@@ -127,17 +147,17 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
watch,
formState: { errors },
} = useForm<FormData>({
// @ts-ignore: Zod version mismatch in monorepo
resolver: zodResolver(correspondenceSchema) as any,
// @ts-ignore: Zod version mismatch
resolver: zodResolver(correspondenceSchema) as unknown as Resolver<FormData>,
defaultValues: defaultValues as FormData,
});
// Watch for controlled inputs
const projectId = watch("projectId");
const documentTypeId = watch("documentTypeId");
const disciplineId = watch("disciplineId");
const fromOrgId = watch("fromOrganizationId");
const toOrgId = watch("toOrganizationId");
const projectId = watch('projectId');
const documentTypeId = watch('documentTypeId');
const disciplineId = watch('disciplineId');
const fromOrgId = watch('fromOrganizationId');
const toOrgId = watch('toOrganizationId');
const onSubmit = (data: FormData) => {
const payload: CreateCorrespondenceDto = {
@@ -153,24 +173,25 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
issuedDate: data.issuedDate ? new Date(data.issuedDate).toISOString() : undefined,
receivedDate: data.receivedDate ? new Date(data.receivedDate).toISOString() : undefined,
originatorId: data.fromOrganizationId,
recipients: [
{ organizationId: data.toOrganizationId, type: 'TO' }
],
recipients: [{ organizationId: data.toOrganizationId, type: 'TO' }],
details: {
importance: data.importance
importance: data.importance,
},
};
if (uuid && initialData) {
// UPDATE Mode
updateMutation.mutate({ uuid, data: payload }, {
onSuccess: () => router.push(`/correspondences/${uuid}`)
});
// UPDATE Mode
updateMutation.mutate(
{ uuid, data: payload },
{
onSuccess: () => router.push(`/correspondences/${uuid}`),
}
);
} else {
// CREATE Mode
createMutation.mutate(payload, {
onSuccess: () => router.push("/correspondences"),
});
// CREATE Mode
createMutation.mutate(payload, {
onSuccess: () => router.push('/correspondences'),
});
}
};
@@ -181,31 +202,29 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
useEffect(() => {
if (!projectId || !documentTypeId || !fromOrgId || !toOrgId) {
setPreview(null);
return;
setPreview(null);
return;
}
const fetchPreview = async () => {
try {
const res = await numberingApi.previewNumber({
projectId,
correspondenceTypeId: documentTypeId,
disciplineId,
originatorOrganizationId: fromOrgId,
recipientOrganizationId: toOrgId
});
setPreview({ number: res.previewNumber, isDefaultTemplate: res.isDefault });
} catch (err) {
setPreview(null);
}
try {
const res = await numberingApi.previewNumber({
projectId,
correspondenceTypeId: documentTypeId,
disciplineId,
originatorOrganizationId: fromOrgId,
recipientOrganizationId: toOrgId,
});
setPreview({ number: res.previewNumber, isDefaultTemplate: res.isDefault });
} catch (_err) {
setPreview(null);
}
};
const timer = setTimeout(fetchPreview, 500);
return () => clearTimeout(timer);
}, [projectId, documentTypeId, disciplineId, fromOrgId, toOrgId]);
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
{/* Existing Document Number (Read Only) */}
@@ -213,42 +232,49 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<div className="space-y-2">
<Label>Current Document Number</Label>
<div className="flex items-center gap-2">
<Input value={initialData.correspondenceNumber} disabled readOnly className="bg-muted font-mono font-bold text-lg w-full" />
{preview && preview.number !== initialData.correspondenceNumber && (
<span className="text-xs text-amber-600 font-semibold whitespace-nowrap px-2">
Start Change Detected
</span>
)}
<Input
value={initialData.correspondenceNumber}
disabled
readOnly
className="bg-muted font-mono font-bold text-lg w-full"
/>
{preview && preview.number !== initialData.correspondenceNumber && (
<span className="text-xs text-amber-600 font-semibold whitespace-nowrap px-2">Start Change Detected</span>
)}
</div>
</div>
)}
{/* Preview Section */}
{preview && (
<div className={`p-4 rounded-md border ${preview.number !== initialData?.correspondenceNumber ? 'bg-amber-50 border-amber-200' : 'bg-muted border-border'}`}>
<p className="text-sm font-semibold mb-1 flex items-center gap-2">
{initialData?.correspondenceNumber ? "New Document Number (Preview)" : "Document Number Preview"}
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<span className="text-[10px] bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full border border-amber-200">
Will Update
</span>
)}
</p>
<div className="flex items-center gap-3">
<span className={`text-xl font-bold font-mono tracking-wide ${preview.number !== initialData?.correspondenceNumber ? 'text-amber-700' : 'text-primary'}`}>
{preview.number}
</span>
{preview.isDefaultTemplate && (
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
Default Template
</span>
)}
</div>
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<p className="text-xs text-muted-foreground mt-2">
* The document number will be regenerated because critical fields were changed.
</p>
)}
<div
className={`p-4 rounded-md border ${preview.number !== initialData?.correspondenceNumber ? 'bg-amber-50 border-amber-200' : 'bg-muted border-border'}`}
>
<p className="text-sm font-semibold mb-1 flex items-center gap-2">
{initialData?.correspondenceNumber ? 'New Document Number (Preview)' : 'Document Number Preview'}
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<span className="text-[10px] bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full border border-amber-200">
Will Update
</span>
)}
</p>
<div className="flex items-center gap-3">
<span
className={`text-xl font-bold font-mono tracking-wide ${preview.number !== initialData?.correspondenceNumber ? 'text-amber-700' : 'text-primary'}`}
>
{preview.number}
</span>
{preview.isDefaultTemplate && (
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
Default Template
</span>
)}
</div>
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<p className="text-xs text-muted-foreground mt-2">
* The document number will be regenerated because critical fields were changed.
</p>
)}
</div>
)}
@@ -258,12 +284,12 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<div className="space-y-2">
<Label>Project *</Label>
<Select
onValueChange={(v) => setValue("projectId", v)}
onValueChange={(v) => setValue('projectId', v)}
value={projectId || undefined}
disabled={isLoadingProjects}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
<SelectValue placeholder={isLoadingProjects ? 'Loading...' : 'Select Project'} />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
@@ -273,21 +299,19 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
))}
</SelectContent>
</Select>
{errors.projectId && (
<p className="text-sm text-destructive">{errors.projectId.message}</p>
)}
{errors.projectId && <p className="text-sm text-destructive">{errors.projectId.message}</p>}
</div>
{/* Document Type Dropdown */}
<div className="space-y-2">
<Label>Document Type *</Label>
<Select
onValueChange={(v) => setValue("documentTypeId", parseInt(v))}
onValueChange={(v) => setValue('documentTypeId', Number(v))}
value={documentTypeId ? String(documentTypeId) : undefined}
disabled={isLoadingTypes}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} />
<SelectValue placeholder={isLoadingTypes ? 'Loading...' : 'Select Type'} />
</SelectTrigger>
<SelectContent>
{correspondenceTypes.map((t) => (
@@ -297,21 +321,19 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
))}
</SelectContent>
</Select>
{errors.documentTypeId && (
<p className="text-sm text-destructive">{errors.documentTypeId.message}</p>
)}
{errors.documentTypeId && <p className="text-sm text-destructive">{errors.documentTypeId.message}</p>}
</div>
{/* Discipline Dropdown (Optional) */}
<div className="space-y-2">
<Label>Discipline</Label>
<Select
onValueChange={(v) => setValue("disciplineId", v ? parseInt(v) : undefined)}
onValueChange={(v) => setValue('disciplineId', v ? Number(v) : undefined)}
value={disciplineId ? String(disciplineId) : undefined}
disabled={isLoadingDisciplines}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} />
<SelectValue placeholder={isLoadingDisciplines ? 'Loading...' : 'Select Discipline (Optional)'} />
</SelectTrigger>
<SelectContent>
{disciplines.map((d) => (
@@ -327,88 +349,76 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">Subject *</Label>
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
{errors.subject && (
<p className="text-sm text-destructive">{errors.subject.message}</p>
)}
<Input id="subject" {...register('subject')} placeholder="Enter subject" />
{errors.subject && <p className="text-sm text-destructive">{errors.subject.message}</p>}
</div>
{/* Body */}
<div className="space-y-2">
{/* Body */}
<div className="space-y-2">
<Label htmlFor="body">Body (Content)</Label>
<Textarea
id="body"
{...register("body")}
rows={6}
placeholder="Enter letter content..."
/>
<Textarea id="body" {...register('body')} rows={6} placeholder="Enter letter content..." />
</div>
{/* Date Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="documentDate">Document Date</Label>
<Input
id="documentDate"
type="date"
{...register("documentDate")}
onChange={(e) => {
const val = e.target.value;
setValue("documentDate", val, { shouldValidate: true, shouldDirty: true });
if (val) {
setValue("issuedDate", val, { shouldValidate: true, shouldDirty: true });
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuedDate">Issued Date</Label>
<Input id="issuedDate" type="date" {...register("issuedDate")} />
</div>
<div className="space-y-2">
<Label htmlFor="receivedDate">Received Date</Label>
<Input
id="receivedDate"
type="date"
{...register("receivedDate")}
onChange={(e) => {
const val = e.target.value;
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
if (val) {
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" {...register("dueDate")} />
</div>
<div className="space-y-2">
<Label htmlFor="documentDate">Document Date</Label>
<Input
id="documentDate"
type="date"
{...register('documentDate')}
onChange={(e) => {
const val = e.target.value;
setValue('documentDate', val, { shouldValidate: true, shouldDirty: true });
if (val) {
setValue('issuedDate', val, { shouldValidate: true, shouldDirty: true });
setValue('receivedDate', val, { shouldValidate: true, shouldDirty: true });
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue('dueDate', d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuedDate">Issued Date</Label>
<Input id="issuedDate" type="date" {...register('issuedDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="receivedDate">Received Date</Label>
<Input
id="receivedDate"
type="date"
{...register('receivedDate')}
onChange={(e) => {
const val = e.target.value;
setValue('receivedDate', val, { shouldValidate: true, shouldDirty: true });
if (val) {
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue('dueDate', d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" {...register('dueDate')} />
</div>
</div>
{/* Remarks */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label>
<Input id="remarks" {...register("remarks")} placeholder="Optional remarks" />
</div>
<div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label>
<Input id="remarks" {...register('remarks')} placeholder="Optional remarks" />
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description (Internal Note)</Label>
<Textarea
id="description"
{...register("description")}
rows={2}
placeholder="Enter description..."
/>
<Textarea id="description" {...register('description')} rows={2} placeholder="Enter description..." />
</div>
{/* Organizations */}
@@ -416,12 +426,12 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<div className="space-y-2">
<Label>From Organization *</Label>
<Select
onValueChange={(v) => setValue("fromOrganizationId", v)}
onValueChange={(v) => setValue('fromOrganizationId', v)}
value={fromOrgId || undefined}
disabled={isLoadingOrgs}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
<SelectValue placeholder={isLoadingOrgs ? 'Loading...' : 'Select Organization'} />
</SelectTrigger>
<SelectContent>
{organizationOptions.map((org) => (
@@ -431,20 +441,18 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
))}
</SelectContent>
</Select>
{errors.fromOrganizationId && (
<p className="text-sm text-destructive">{errors.fromOrganizationId.message}</p>
)}
{errors.fromOrganizationId && <p className="text-sm text-destructive">{errors.fromOrganizationId.message}</p>}
</div>
<div className="space-y-2">
<Label>To Organization *</Label>
<Select
onValueChange={(v) => setValue("toOrganizationId", v)}
onValueChange={(v) => setValue('toOrganizationId', v)}
value={toOrgId || undefined}
disabled={isLoadingOrgs}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
<SelectValue placeholder={isLoadingOrgs ? 'Loading...' : 'Select Organization'} />
</SelectTrigger>
<SelectContent>
{organizationOptions.map((org) => (
@@ -454,9 +462,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
))}
</SelectContent>
</Select>
{errors.toOrganizationId && (
<p className="text-sm text-destructive">{errors.toOrganizationId.message}</p>
)}
{errors.toOrganizationId && <p className="text-sm text-destructive">{errors.toOrganizationId.message}</p>}
</div>
</div>
@@ -465,30 +471,15 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<Label>Importance</Label>
<div className="flex gap-6 mt-2">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
value="NORMAL"
{...register("importance")}
className="accent-primary"
/>
<input type="radio" value="NORMAL" {...register('importance')} className="accent-primary" />
<span>Normal</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
value="HIGH"
{...register("importance")}
className="accent-primary"
/>
<input type="radio" value="HIGH" {...register('importance')} className="accent-primary" />
<span>High</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
value="URGENT"
{...register("importance")}
className="accent-primary"
/>
<input type="radio" value="URGENT" {...register('importance')} className="accent-primary" />
<span>Urgent</span>
</label>
</div>
@@ -499,9 +490,9 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<div className="space-y-2">
<Label>Attachments</Label>
<FileUploadZone
onFilesChanged={(files) => setValue("attachments", files)}
onFilesChanged={(files) => setValue('attachments', files)}
multiple
accept={[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".jpg", ".png"]}
accept={['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.jpg', '.png']}
/>
</div>
)}
@@ -513,7 +504,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{uuid ? "Update Correspondence" : "Create Correspondence"}
{uuid ? 'Update Correspondence' : 'Create Correspondence'}
</Button>
</div>
</form>
+43 -40
View File
@@ -1,13 +1,13 @@
"use client";
'use client';
import { CorrespondenceRevision } from "@/types/correspondence";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button";
import { Eye, Edit, FileText } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { CorrespondenceRevision } from '@/types/correspondence';
import { DataTable } from '@/components/common/data-table';
import { ColumnDef } from '@tanstack/react-table';
import { StatusBadge } from '@/components/common/status-badge';
import { Button } from '@/components/ui/button';
import { Eye, Edit, FileText } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
interface CorrespondenceListProps {
data: CorrespondenceRevision[];
@@ -16,22 +16,20 @@ interface CorrespondenceListProps {
export function CorrespondenceList({ data }: CorrespondenceListProps) {
const columns: ColumnDef<CorrespondenceRevision>[] = [
{
accessorKey: "correspondence.correspondenceNumber",
header: "Document No.",
cell: ({ row }) => (
<span className="font-medium">{row.original.correspondence?.correspondenceNumber}</span>
),
accessorKey: 'correspondence.correspondenceNumber',
header: 'Document No.',
cell: ({ row }) => <span className="font-medium">{row.original.correspondence?.correspondenceNumber}</span>,
},
{
accessorKey: "revisionLabel",
header: "Rev",
accessorKey: 'revisionLabel',
header: 'Rev',
cell: ({ row }) => (
<span className="font-medium">{row.original.revisionLabel || row.original.revisionNumber}</span>
),
},
{
accessorKey: "subject",
header: "Subject",
accessorKey: 'subject',
header: 'Subject',
cell: ({ row }) => (
<div className="max-w-[300px] truncate" title={row.original.subject}>
{row.original.subject}
@@ -39,24 +37,24 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
),
},
{
accessorKey: "correspondence.originator.organizationCode",
header: "From",
accessorKey: 'correspondence.originator.organizationCode',
header: 'From',
cell: ({ row }) => (
<span className="font-medium">{row.original.correspondence?.originator?.organizationCode || '-'}</span>
),
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
accessorKey: 'createdAt',
header: 'Created',
cell: ({ row }) => format(new Date(row.getValue('createdAt')), 'dd MMM yyyy'),
},
{
accessorKey: "status.statusName",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status?.statusCode || "UNKNOWN"} />,
accessorKey: 'status.statusName',
header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.status?.statusCode || 'UNKNOWN'} />,
},
{
id: "actions",
id: 'actions',
cell: ({ row }) => {
const item = row.original;
// Edit/View link goes to the DOCUMENT detail (correspondence.uuid)
@@ -72,23 +70,28 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
<Eye className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="icon" title="View File" onClick={() => {
<Button
variant="ghost"
size="icon"
title="View File"
onClick={() => {
const attachments = item.attachments; // Now we are on Revision, so attachments might be here if joined
if (attachments && attachments.length > 0 && attachments[0].url) {
window.open(attachments[0].url, '_blank');
window.open(attachments[0].url, '_blank');
} else {
// Fallback check if attachments are on details json inside revision
// or if we simply didn't join them yet.
// Current Backend join: leftJoinAndSelect('rev.status', 'status') doesn't join attachments explicitly but maybe relation exists?
// Wait, checking Entity... CorrespondenceRevision does NOT have attachments relation in code snippet provided earlier.
// It might be in 'details' JSON or implied.
// Just Alert for now as per previous logic.
alert("ไม่พบไฟล์แนบ (No file attached)");
// Fallback check if attachments are on details json inside revision
// or if we simply didn't join them yet.
// Current Backend join: leftJoinAndSelect('rev.status', 'status') doesn't join attachments explicitly but maybe relation exists?
// Wait, checking Entity... CorrespondenceRevision does NOT have attachments relation in code snippet provided earlier.
// It might be in 'details' JSON or implied.
// Just Alert for now as per previous logic.
alert('ไม่พบไฟล์แนบ (No file attached)');
}
}}>
<FileText className="h-4 w-4" />
</Button>
{statusCode === "DRAFT" && (
}}
>
<FileText className="h-4 w-4" />
</Button>
{statusCode === 'DRAFT' && (
<Link href={`/correspondences/${docUuid}/edit`}>
<Button variant="ghost" size="icon" title="Edit">
<Edit className="h-4 w-4" />
+37 -42
View File
@@ -1,13 +1,13 @@
// File: components/custom/file-upload-zone.tsx
"use client";
'use client';
import React, { useCallback, useState } from "react";
import { UploadCloud, File as FileIcon, X, AlertTriangle, CheckCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import React, { useCallback, useState } from 'react';
import { UploadCloud, File as FileIcon, X, AlertTriangle, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
export interface FileWithMeta extends File {
preview?: string;
@@ -32,10 +32,10 @@ interface FileUploadZoneProps {
* Helper: แปลง Bytes
*/
const formatBytes = (bytes: number, decimals = 2) => {
if (!+bytes) return "0 Bytes";
if (!Number(bytes)) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
@@ -46,7 +46,7 @@ const formatBytes = (bytes: number, decimals = 2) => {
*/
export function FileUploadZone({
onFilesChanged,
accept = [".pdf", ".dwg", ".docx", ".xlsx", ".zip"],
accept = ['.pdf', '.dwg', '.docx', '.xlsx', '.zip'],
maxSize = 50 * 1024 * 1024, // 50MB Default
multiple = true,
initialFiles = [],
@@ -56,18 +56,18 @@ export function FileUploadZone({
const [isDragging, setIsDragging] = useState(false);
// ตรวจสอบไฟล์
const validateFile = (file: File): string | undefined => {
const validateFile = useCallback((file: File): string | undefined => {
// 1. Check Size
if (file.size > maxSize) {
return `ขนาดไฟล์เกินกำหนด (${formatBytes(maxSize)})`;
}
// 2. Check Type (Extension based validation for simplicity on client)
const fileExtension = "." + file.name.split(".").pop()?.toLowerCase();
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (accept.length > 0 && !accept.includes(fileExtension)) {
return `ประเภทไฟล์ไม่รองรับ (อนุญาต: ${accept.join(", ")})`;
return `ประเภทไฟล์ไม่รองรับ (อนุญาต: ${accept.join(', ')})`;
}
return undefined;
};
}, [maxSize, accept]);
const handleFileSelect = useCallback(
(newFiles: File[]) => {
@@ -85,7 +85,7 @@ export function FileUploadZone({
return updated;
});
},
[maxSize, accept, multiple, onFilesChanged]
[multiple, onFilesChanged, validateFile]
);
// Drag Events
@@ -114,27 +114,25 @@ export function FileUploadZone({
};
return (
<div className={cn("w-full space-y-4", className)}>
<div className={cn('w-full space-y-4', className)}>
{/* Drop Zone */}
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer flex flex-col items-center justify-center gap-2",
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/25 hover:border-primary/50",
"h-48"
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer flex flex-col items-center justify-center gap-2',
isDragging ? 'border-primary bg-primary/10' : 'border-muted-foreground/25 hover:border-primary/50',
'h-48'
)}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => document.getElementById("file-input")?.click()}
onClick={() => document.getElementById('file-input')?.click()}
>
<input
id="file-input"
type="file"
className="hidden"
multiple={multiple}
accept={accept.join(",")}
accept={accept.join(',')}
onChange={(e) => {
if (e.target.files) handleFileSelect(Array.from(e.target.files));
}}
@@ -143,11 +141,9 @@ export function FileUploadZone({
<UploadCloud className="w-8 h-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">
</p>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground">
: {accept.join(", ")} ( {formatBytes(maxSize)})
: {accept.join(', ')} ( {formatBytes(maxSize)})
</p>
</div>
</div>
@@ -166,22 +162,21 @@ export function FileUploadZone({
<FileIcon className="w-5 h-5 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate max-w-[200px] sm:max-w-md">
{file.name}
</p>
<p className="text-sm font-medium truncate max-w-[200px] sm:max-w-md">{file.name}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatBytes(file.size)}
</span>
{file.validationError ? (
<Badge variant="destructive" className="text-[10px] px-1 h-5 flex gap-1">
<AlertTriangle className="w-3 h-3" /> {file.validationError}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] px-1 h-5 text-green-600 bg-green-50 border-green-200 flex gap-1">
<CheckCircle className="w-3 h-3" /> Ready
</Badge>
)}
<span className="text-xs text-muted-foreground">{formatBytes(file.size)}</span>
{file.validationError ? (
<Badge variant="destructive" className="text-[10px] px-1 h-5 flex gap-1">
<AlertTriangle className="w-3 h-3" /> {file.validationError}
</Badge>
) : (
<Badge
variant="outline"
className="text-[10px] px-1 h-5 text-green-600 bg-green-50 border-green-200 flex gap-1"
>
<CheckCircle className="w-3 h-3" /> Ready
</Badge>
)}
</div>
</div>
</div>
@@ -1,13 +1,13 @@
// File: components/custom/workflow-visualizer.tsx
import React from "react";
import { Check, Clock, XCircle, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import React from 'react';
import { Check, Clock, XCircle, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Workflow
*/
export type StepStatus = "completed" | "current" | "pending" | "rejected" | "skipped";
export type StepStatus = 'completed' | 'current' | 'pending' | 'rejected' | 'skipped';
export interface WorkflowStep {
id: string | number;
@@ -28,38 +28,38 @@ interface WorkflowVisualizerProps {
*/
export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps) {
return (
<div className={cn("w-full overflow-x-auto py-4 px-2", className)}>
<div className={cn('w-full overflow-x-auto py-4 px-2', className)}>
<div className="flex items-start min-w-max">
{steps.map((step, index) => {
const isLast = index === steps.length - 1;
// กำหนดสีตามสถานะ
let statusColor = "bg-muted text-muted-foreground border-muted"; // pending
let statusColor = 'bg-muted text-muted-foreground border-muted'; // pending
let icon = <span className="text-xs">{index + 1}</span>;
let lineColor = "bg-muted";
let lineColor = 'bg-muted';
switch (step.status) {
case "completed":
statusColor = "bg-green-600 text-white border-green-600";
case 'completed':
statusColor = 'bg-green-600 text-white border-green-600';
icon = <Check className="w-4 h-4" />;
lineColor = "bg-green-600";
lineColor = 'bg-green-600';
break;
case "current":
statusColor = "bg-blue-600 text-white border-blue-600 ring-4 ring-blue-100";
case 'current':
statusColor = 'bg-blue-600 text-white border-blue-600 ring-4 ring-blue-100';
icon = <Clock className="w-4 h-4 animate-pulse" />;
lineColor = "bg-muted"; // เส้นต่อไปยังเป็นสีเทา
lineColor = 'bg-muted'; // เส้นต่อไปยังเป็นสีเทา
break;
case "rejected":
statusColor = "bg-destructive text-destructive-foreground border-destructive";
case 'rejected':
statusColor = 'bg-destructive text-destructive-foreground border-destructive';
icon = <XCircle className="w-4 h-4" />;
lineColor = "bg-destructive";
lineColor = 'bg-destructive';
break;
case "skipped":
statusColor = "bg-orange-400 text-white border-orange-400";
icon = <AlertCircle className="w-4 h-4" />;
lineColor = "bg-orange-400";
break;
case "pending":
case 'skipped':
statusColor = 'bg-orange-400 text-white border-orange-400';
icon = <AlertCircle className="w-4 h-4" />;
lineColor = 'bg-orange-400';
break;
case 'pending':
default:
// ใช้ default
break;
@@ -69,17 +69,37 @@ export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps
<div key={step.id} className="relative flex flex-col items-center flex-1 group">
{/* Connector Line (Left & Right) */}
<div className="flex items-center w-full absolute top-4 left-0 -z-10">
{/* Left Half Line (Previous step connection) */}
<div className={cn("h-1 w-1/2", index === 0 ? "bg-transparent" : (steps[index-1].status === 'completed' || steps[index-1].status === 'skipped' ? lineColor : (steps[index].status === 'completed' ? lineColor : 'bg-muted')))} />
{/* Right Half Line (Next step connection) */}
<div className={cn("h-1 w-1/2", isLast ? "bg-transparent" : (step.status === 'completed' || step.status === 'skipped' ? lineColor : 'bg-muted'))} />
{/* Left Half Line (Previous step connection) */}
<div
className={cn(
'h-1 w-1/2',
index === 0
? 'bg-transparent'
: steps[index - 1].status === 'completed' || steps[index - 1].status === 'skipped'
? lineColor
: steps[index].status === 'completed'
? lineColor
: 'bg-muted'
)}
/>
{/* Right Half Line (Next step connection) */}
<div
className={cn(
'h-1 w-1/2',
isLast
? 'bg-transparent'
: step.status === 'completed' || step.status === 'skipped'
? lineColor
: 'bg-muted'
)}
/>
</div>
{/* Step Circle */}
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300",
'w-8 h-8 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300',
statusColor
)}
>
@@ -88,8 +108,13 @@ export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps
{/* Step Label */}
<div className="mt-3 text-center space-y-1 max-w-[120px]">
<p className={cn("text-sm font-semibold", step.status === 'current' ? 'text-blue-700' : 'text-foreground')}>
{step.label}
<p
className={cn(
'text-sm font-semibold',
step.status === 'current' ? 'text-blue-700' : 'text-foreground'
)}
>
{step.label}
</p>
{step.subLabel && (
<p className="text-xs text-muted-foreground truncate" title={step.subLabel}>
@@ -108,4 +133,4 @@ export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps
</div>
</div>
);
}
}
+31 -29
View File
@@ -1,10 +1,10 @@
"use client";
'use client';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { PendingTask } from "@/types/dashboard";
import { AlertCircle, ArrowRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import { PendingTask } from '@/types/dashboard';
import { _AlertCircle, ArrowRight } from 'lucide-react';
interface PendingTasksProps {
tasks: PendingTask[] | undefined;
@@ -13,18 +13,20 @@ interface PendingTasksProps {
export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
if (isLoading) {
return (
<Card className="h-full">
<CardHeader><CardTitle className="text-lg">Pending Tasks</CardTitle></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
))}
</div>
</CardContent>
</Card>
)
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-lg">Pending Tasks</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
if (!tasks) tasks = [];
@@ -35,7 +37,10 @@ export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
<CardTitle className="text-lg flex items-center gap-2">
Pending Tasks
{tasks.length > 0 && (
<Badge variant="destructive" className="rounded-full h-5 w-5 p-0 flex items-center justify-center text-[10px]">
<Badge
variant="destructive"
className="rounded-full h-5 w-5 p-0 flex items-center justify-center text-[10px]"
>
{tasks.length}
</Badge>
)}
@@ -44,9 +49,7 @@ export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
<CardContent>
<div className="space-y-3">
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No pending tasks. Good job!
</p>
<p className="text-sm text-muted-foreground text-center py-4">No pending tasks. Good job!</p>
) : (
tasks.map((task) => (
<Link
@@ -55,22 +58,21 @@ export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
className="block p-3 bg-muted/40 rounded-lg border hover:bg-muted/60 transition-colors group"
>
<div className="flex items-start justify-between mb-1">
<span className="text-sm font-medium group-hover:text-primary transition-colors">
{task.title}
</span>
<span className="text-sm font-medium group-hover:text-primary transition-colors">{task.title}</span>
{task.daysOverdue > 0 ? (
<Badge variant="destructive" className="text-[10px] h-5 px-1.5">
{task.daysOverdue}d overdue
</Badge>
) : (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 bg-yellow-50 text-yellow-700 border-yellow-200">
<Badge
variant="outline"
className="text-[10px] h-5 px-1.5 bg-yellow-50 text-yellow-700 border-yellow-200"
>
Due Soon
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">
{task.description}
</p>
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">{task.description}</p>
<div className="flex items-center text-xs text-primary font-medium">
View Details <ArrowRight className="ml-1 h-3 w-3" />
</div>
@@ -1,8 +1,8 @@
"use client";
'use client';
import { Button } from "@/components/ui/button";
import { PlusCircle, Upload, FileText } from "lucide-react";
import Link from "next/link";
import { Button } from '@/components/ui/button';
import { PlusCircle, Upload, FileText } from 'lucide-react';
import Link from 'next/link';
export function QuickActions() {
return (
@@ -1,11 +1,11 @@
"use client";
'use client';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { formatDistanceToNow } from "date-fns";
import { ActivityLog } from "@/types/dashboard";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
import { ActivityLog } from '@/types/dashboard';
import Link from 'next/link';
interface RecentActivityProps {
activities: ActivityLog[] | undefined;
@@ -14,29 +14,31 @@ interface RecentActivityProps {
export function RecentActivity({ activities, isLoading }: RecentActivityProps) {
if (isLoading) {
return (
<Card className="h-full">
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
<CardContent>
<div className="space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded-md" />
))}
</div>
</CardContent>
</Card>
)
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-lg">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
if (!activities || activities.length === 0) {
return (
<Card className="h-full">
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
<CardContent className="text-muted-foreground text-sm text-center py-8">
No recent activity.
</CardContent>
</Card>
);
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-lg">Recent Activity</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm text-center py-8">No recent activity.</CardContent>
</Card>
);
}
return (
<Card className="h-full">
@@ -46,10 +48,7 @@ export function RecentActivity({ activities, isLoading }: RecentActivityProps) {
<CardContent>
<div className="space-y-6">
{activities.map((activity) => (
<div
key={activity.id}
className="flex gap-4 pb-4 border-b last:border-0 last:pb-0"
>
<div key={activity.id} className="flex gap-4 pb-4 border-b last:border-0 last:pb-0">
<Avatar className="h-10 w-10 border">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{activity.user.initials}
+16 -16
View File
@@ -1,8 +1,8 @@
"use client";
'use client';
import { Card } from "@/components/ui/card";
import { FileText, Clipboard, CheckCircle, Clock } from "lucide-react";
import { DashboardStats } from "@/types/dashboard";
import { Card } from '@/components/ui/card';
import { FileText, Clipboard, CheckCircle, Clock } from 'lucide-react';
import { DashboardStats } from '@/types/dashboard';
export interface StatsCardsProps {
stats: DashboardStats | undefined;
@@ -21,32 +21,32 @@ export function StatsCards({ stats, isLoading }: StatsCardsProps) {
}
const cards = [
{
title: "Total Correspondences",
title: 'Total Correspondences',
value: stats.totalDocuments,
icon: FileText,
color: "text-blue-600",
bgColor: "bg-blue-50",
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
{
title: "Active RFAs",
title: 'Active RFAs',
value: stats.totalRfas,
icon: Clipboard,
color: "text-purple-600",
bgColor: "bg-purple-50",
color: 'text-purple-600',
bgColor: 'bg-purple-50',
},
{
title: "Approved Documents",
title: 'Approved Documents',
value: stats.approved,
icon: CheckCircle,
color: "text-green-600",
bgColor: "bg-green-50",
color: 'text-green-600',
bgColor: 'bg-green-50',
},
{
title: "Pending Approvals",
title: 'Pending Approvals',
value: stats.pendingApprovals,
icon: Clock,
color: "text-orange-600",
bgColor: "bg-orange-50",
color: 'text-orange-600',
bgColor: 'bg-orange-50',
},
];
@@ -8,7 +8,7 @@ import {
useReactTable,
PaginationState,
SortingState,
getPaginationRowModel,
_getPaginationRowModel,
OnChangeFn,
} from '@tanstack/react-table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+26 -24
View File
@@ -1,12 +1,12 @@
"use client";
'use client';
import { Drawing } from "@/types/drawing";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileText, Download, Eye, GitCompare } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { Drawing } from '@/types/drawing';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { FileText, Download, Eye, GitCompare } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
export function DrawingCard({ drawing }: { drawing: Drawing }) {
return (
@@ -21,39 +21,41 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber || "No Number"}>
{drawing.drawingNumber || "No Number"}
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber || 'No Number'}>
{drawing.drawingNumber || 'No Number'}
</h3>
<p className="text-sm text-muted-foreground truncate" title={drawing.title || "No Title"}>
{drawing.title || "No Title"}
<p className="text-sm text-muted-foreground truncate" title={drawing.title || 'No Title'}>
{drawing.title || 'No Title'}
</p>
</div>
<Badge variant="outline">{typeof drawing.discipline === 'object' ? drawing.discipline?.disciplineCode : drawing.discipline}</Badge>
<Badge variant="outline">
{typeof drawing.discipline === 'object' ? drawing.discipline?.disciplineCode : drawing.discipline}
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
<div>
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber || "-"}
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber || '-'}
</div>
<div>
<span className="font-medium text-foreground">Rev:</span> {drawing.revision || "0"}
<span className="font-medium text-foreground">Rev:</span> {drawing.revision || '0'}
</div>
{drawing.legacyDrawingNumber && (
<div className="col-span-2">
<span className="font-medium text-foreground">Legacy:</span> {drawing.legacyDrawingNumber}
</div>
<div className="col-span-2">
<span className="font-medium text-foreground">Legacy:</span> {drawing.legacyDrawingNumber}
</div>
)}
{drawing.volumePage !== undefined && (
<div>
<span className="font-medium text-foreground">Page:</span> {drawing.volumePage}
</div>
<div>
<span className="font-medium text-foreground">Page:</span> {drawing.volumePage}
</div>
)}
<div>
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || 'N/A'}
</div>
<div>
<span className="font-medium text-foreground">Date:</span>{" "}
{drawing.issueDate && format(new Date(drawing.issueDate), "dd/MM/yyyy")}
<span className="font-medium text-foreground">Date:</span>{' '}
{drawing.issueDate && format(new Date(drawing.issueDate), 'dd/MM/yyyy')}
</div>
</div>
+1 -3
View File
@@ -68,9 +68,7 @@ export const columns: ColumnDef<Drawing>[] = [
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/drawings/${drawing.uuid}`}>
View Details
</Link>
<Link href={`/drawings/${drawing.uuid}`}>View Details</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/drawings/${drawing.uuid}?edit=true`}>
@@ -1,11 +1,11 @@
"use client";
'use client';
import { DrawingRevision } from "@/types/drawing";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Download, FileText } from "lucide-react";
import { format } from "date-fns";
import { DrawingRevision } from '@/types/drawing';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Download, _FileText } from 'lucide-react';
import { format } from 'date-fns';
export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] }) {
return (
@@ -14,15 +14,10 @@ export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] })
<div className="space-y-3">
{revisions.map((rev) => (
<div
key={rev.revisionId}
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border"
>
<div key={rev.revisionId} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<Badge variant={rev.isCurrent ? "default" : "outline"}>
Rev. {rev.revisionNumber}
</Badge>
<Badge variant={rev.isCurrent ? 'default' : 'outline'}>Rev. {rev.revisionNumber}</Badge>
{rev.isCurrent && (
<span className="text-xs text-green-600 font-medium flex items-center gap-1">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
@@ -30,12 +25,9 @@ export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] })
</span>
)}
</div>
<p className="text-sm text-foreground font-medium">
{rev.revisionDescription || "No description"}
</p>
<p className="text-sm text-foreground font-medium">{rev.revisionDescription || 'No description'}</p>
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(rev.revisionDate), "dd MMM yyyy")} by{" "}
{rev.revisedByName}
{format(new Date(rev.revisionDate), 'dd MMM yyyy')} by {rev.revisedByName}
</p>
</div>
+231 -222
View File
@@ -1,74 +1,72 @@
"use client";
'use client';
import { useForm, FieldError } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm, FieldError } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card } from '@/components/ui/card';
import { useRouter } from 'next/navigation';
import { useCreateDrawing } from '@/hooks/use-drawing';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card } from "@/components/ui/card";
import { useRouter } from "next/navigation";
import { useCreateDrawing } from "@/hooks/use-drawing";
import { useContractDrawingCategories, useShopMainCategories, useShopSubCategories, useProjects } from "@/hooks/use-master-data";
import { useState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
useContractDrawingCategories,
useShopMainCategories,
useShopSubCategories,
useProjects,
} from '@/hooks/use-master-data';
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
// Base Schema
const baseSchema = z.object({
drawingType: z.enum(["CONTRACT", "SHOP", "AS_BUILT"]),
projectId: z.string().min(1, "Project is required"),
file: z.instanceof(File, { message: "File is required" }),
drawingType: z.enum(['CONTRACT', 'SHOP', 'AS_BUILT']),
projectId: z.string().min(1, 'Project is required'),
file: z.instanceof(File, { message: 'File is required' }),
});
// Contract Schema
const contractSchema = baseSchema.extend({
drawingType: z.literal("CONTRACT"),
contractDrawingNo: z.string().min(1, "Drawing Number is required"),
title: z.string().min(3, "Title is required"),
drawingType: z.literal('CONTRACT'),
contractDrawingNo: z.string().min(1, 'Drawing Number is required'),
title: z.string().min(3, 'Title is required'),
volumeId: z.string().optional(), // Select input returns string usually (changed to string for input compatibility)
volumePage: z.string().transform(val => parseInt(val, 10)).optional(), // Input type number returns string
mapCatId: z.string().min(1, "Category is required"),
volumePage: z
.string()
.transform((val) => Number(val))
.optional(), // Input type number returns string
mapCatId: z.string().min(1, 'Category is required'),
});
// Shop Schema
const shopSchema = baseSchema.extend({
drawingType: z.literal("SHOP"),
drawingNumber: z.string().min(1, "Drawing Number is required"),
mainCategoryId: z.string().min(1, "Main Category is required"),
subCategoryId: z.string().min(1, "Sub Category is required"),
drawingType: z.literal('SHOP'),
drawingNumber: z.string().min(1, 'Drawing Number is required'),
mainCategoryId: z.string().min(1, 'Main Category is required'),
subCategoryId: z.string().min(1, 'Sub Category is required'),
// Revision Fields
revisionLabel: z.string().default("0"),
title: z.string().min(3, "Revision Title is required"),
revisionLabel: z.string().default('0'),
title: z.string().min(3, 'Revision Title is required'),
legacyDrawingNumber: z.string().optional(),
description: z.string().optional(),
});
// As Built Schema
const asBuiltSchema = baseSchema.extend({
drawingType: z.literal("AS_BUILT"),
drawingNumber: z.string().min(1, "Drawing Number is required"),
mainCategoryId: z.string().min(1, "Main Category is required"),
subCategoryId: z.string().min(1, "Sub Category is required"),
drawingType: z.literal('AS_BUILT'),
drawingNumber: z.string().min(1, 'Drawing Number is required'),
mainCategoryId: z.string().min(1, 'Main Category is required'),
subCategoryId: z.string().min(1, 'Sub Category is required'),
// Revision Fields
revisionLabel: z.string().default("0"),
title: z.string().min(1, "Title is required"),
revisionLabel: z.string().default('0'),
title: z.string().min(1, 'Title is required'),
legacyDrawingNumber: z.string().optional(),
description: z.string().optional(),
});
const formSchema = z.discriminatedUnion("drawingType", [
contractSchema,
shopSchema,
asBuiltSchema,
]);
const formSchema = z.discriminatedUnion('drawingType', [contractSchema, shopSchema, asBuiltSchema]);
type DrawingFormData = z.infer<typeof formSchema>;
@@ -97,15 +95,15 @@ export function DrawingUploadForm() {
} = useForm<DrawingFormData>({
resolver: zodResolver(formSchema),
defaultValues: {
drawingType: "CONTRACT",
} as DrawingFormData
drawingType: 'CONTRACT',
} as DrawingFormData,
});
// Type-safe error access for discriminated union fields
const formErrors = errors as Record<string, FieldError | undefined>;
const drawingType = watch("drawingType");
const watchedProjectId = watch("projectId");
const drawingType = watch('drawingType');
const watchedProjectId = watch('projectId');
const createMutation = useCreateDrawing(drawingType);
// When project changes, update selectedProjectId for category hooks
@@ -115,7 +113,9 @@ export function DrawingUploadForm() {
return;
}
// Try to resolve UUID→INT from projects list, or pass UUID directly
const project = projects.find((p: { id: string; uuid?: string }) => p.id === watchedProjectId || p.uuid === watchedProjectId) as { id: string; uuid?: string } | undefined;
const project = projects.find(
(p: { id: string; uuid?: string }) => p.id === watchedProjectId || p.uuid === watchedProjectId
) as { id: string; uuid?: string } | undefined;
setSelectedProjectId(project?.id ?? watchedProjectId);
}, [watchedProjectId, projects]);
@@ -153,8 +153,8 @@ export function DrawingUploadForm() {
createMutation.mutate(formData, {
onSuccess: () => {
router.push("/drawings");
}
router.push('/drawings');
},
});
};
@@ -167,9 +167,7 @@ export function DrawingUploadForm() {
{/* Project Selector */}
<div>
<Label>Project *</Label>
<Select
onValueChange={(v) => setValue("projectId", v)}
>
<Select onValueChange={(v) => setValue('projectId', v)}>
<SelectTrigger>
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -185,16 +183,14 @@ export function DrawingUploadForm() {
))}
</SelectContent>
</Select>
{errors.projectId && (
<p className="text-sm text-destructive">{errors.projectId.message}</p>
)}
{errors.projectId && <p className="text-sm text-destructive">{errors.projectId.message}</p>}
</div>
<div>
<Label>Drawing Type *</Label>
<Select
onValueChange={(v) => {
setValue("drawingType", v as DrawingFormData["drawingType"]);
setValue('drawingType', v as DrawingFormData['drawingType']);
// Reset errors or fields if needed
}}
defaultValue="CONTRACT"
@@ -216,188 +212,204 @@ export function DrawingUploadForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Contract Drawing No *</Label>
<Input {...register("contractDrawingNo")} placeholder="e.g. CD-001" />
<Input {...register('contractDrawingNo')} placeholder="e.g. CD-001" />
{formErrors.contractDrawingNo && (
<p className="text-sm text-destructive">{formErrors.contractDrawingNo.message}</p>
)}
</div>
<div>
<Label>Title *</Label>
<Input {...register("title")} placeholder="Drawing Title" />
{formErrors.title && (
<p className="text-sm text-destructive">{formErrors.title.message}</p>
)}
<Label>Title *</Label>
<Input {...register('title')} placeholder="Drawing Title" />
{formErrors.title && <p className="text-sm text-destructive">{formErrors.title.message}</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Category *</Label>
<Select onValueChange={(v) => setValue("mapCatId", v)}>
<SelectTrigger>
<SelectValue placeholder="Select Category" />
</SelectTrigger>
<SelectContent>
{contractCategories?.map((c: { id: number; catName?: string; catCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.catName || c.catCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.mapCatId && (
<p className="text-sm text-destructive">{formErrors.mapCatId.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Volume ID</Label>
<Input {...register("volumeId")} placeholder="Vol. 1" />
</div>
<div>
<Label>Page No.</Label>
<Input {...register("volumePage")} type="number" placeholder="1" />
</div>
</div>
<div>
<Label>Category *</Label>
<Select onValueChange={(v) => setValue('mapCatId', v)}>
<SelectTrigger>
<SelectValue placeholder="Select Category" />
</SelectTrigger>
<SelectContent>
{contractCategories?.map(
(c: { id: number; catName?: string; catCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.catName || c.catCode || c.name}
</SelectItem>
)
)}
</SelectContent>
</Select>
{formErrors.mapCatId && <p className="text-sm text-destructive">{formErrors.mapCatId.message}</p>}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Volume ID</Label>
<Input {...register('volumeId')} placeholder="Vol. 1" />
</div>
<div>
<Label>Page No.</Label>
<Input {...register('volumePage')} type="number" placeholder="1" />
</div>
</div>
</div>
</>
)}
{/* SHOP FIELDS */}
{drawingType === 'SHOP' && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Shop Drawing No *</Label>
<Input {...register("drawingNumber")} placeholder="e.g. SD-101" />
{formErrors.drawingNumber && (
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
)}
</div>
<div>
<Label>Legacy Number</Label>
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
</div>
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Shop Drawing No *</Label>
<Input {...register('drawingNumber')} placeholder="e.g. SD-101" />
{formErrors.drawingNumber && (
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
)}
</div>
<div>
<Label>Legacy Number</Label>
<Input {...register('legacyDrawingNumber')} placeholder="Legacy No." />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Main Category *</Label>
<Select onValueChange={(v) => {
setValue("mainCategoryId", v);
setSelectedShopMainCat(v ? parseInt(v) : undefined);
}}>
<SelectTrigger>
<SelectValue placeholder="Select Main Category" />
</SelectTrigger>
<SelectContent>
{shopMainCats?.map((c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.mainCategoryName || c.mainCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.mainCategoryId && (
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
)}
</div>
<div>
<Label>Sub Category *</Label>
<Select onValueChange={(v) => setValue("subCategoryId", v)}>
<SelectTrigger>
<SelectValue placeholder="Select Sub Category" />
</SelectTrigger>
<SelectContent>
{shopSubCats?.map((c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.subCategoryName || c.subCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.subCategoryId && (
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Main Category *</Label>
<Select
onValueChange={(v) => {
setValue('mainCategoryId', v);
setSelectedShopMainCat(v ? Number(v) : undefined);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select Main Category" />
</SelectTrigger>
<SelectContent>
{shopMainCats?.map(
(c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.mainCategoryName || c.mainCategoryCode || c.name}
</SelectItem>
)
)}
</SelectContent>
</Select>
{formErrors.mainCategoryId && (
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
)}
</div>
<div>
<Label>Sub Category *</Label>
<Select onValueChange={(v) => setValue('subCategoryId', v)}>
<SelectTrigger>
<SelectValue placeholder="Select Sub Category" />
</SelectTrigger>
<SelectContent>
{shopSubCats?.map(
(c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.subCategoryName || c.subCategoryCode || c.name}
</SelectItem>
)
)}
</SelectContent>
</Select>
{formErrors.subCategoryId && (
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
)}
</div>
</div>
<div>
<Label>Revision Title *</Label>
<Input {...register("title")} placeholder="Current Revision Title" />
{formErrors.title && (
<p className="text-sm text-destructive">{formErrors.title.message}</p>
)}
</div>
<div>
<Label>Revision Title *</Label>
<Input {...register('title')} placeholder="Current Revision Title" />
{formErrors.title && <p className="text-sm text-destructive">{formErrors.title.message}</p>}
</div>
<div>
<Label>Description</Label>
<Textarea {...register("description")} />
</div>
</>
<div>
<Label>Description</Label>
<Textarea {...register('description')} />
</div>
</>
)}
{/* AS BUILT FIELDS */}
{drawingType === 'AS_BUILT' && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Drawing No *</Label>
<Input {...register("drawingNumber")} placeholder="e.g. AB-101" />
{formErrors.drawingNumber && (
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
)}
</div>
<div>
<Label>Legacy Number</Label>
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
</div>
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Drawing No *</Label>
<Input {...register('drawingNumber')} placeholder="e.g. AB-101" />
{formErrors.drawingNumber && (
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
)}
</div>
<div>
<Label>Legacy Number</Label>
<Input {...register('legacyDrawingNumber')} placeholder="Legacy No." />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Main Category *</Label>
<Select onValueChange={(v) => {
setValue("mainCategoryId", v);
setSelectedShopMainCat(v ? parseInt(v) : undefined);
}}>
<SelectTrigger>
<SelectValue placeholder="Select Main Category" />
</SelectTrigger>
<SelectContent>
{shopMainCats?.map((c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.mainCategoryName || c.mainCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.mainCategoryId && (
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
)}
</div>
<div>
<Label>Sub Category *</Label>
<Select onValueChange={(v) => setValue("subCategoryId", v)}>
<SelectTrigger>
<SelectValue placeholder="Select Sub Category" />
</SelectTrigger>
<SelectContent>
{shopSubCats?.map((c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.subCategoryName || c.subCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.subCategoryId && (
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Main Category *</Label>
<Select
onValueChange={(v) => {
setValue('mainCategoryId', v);
setSelectedShopMainCat(v ? Number(v) : undefined);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select Main Category" />
</SelectTrigger>
<SelectContent>
{shopMainCats?.map(
(c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.mainCategoryName || c.mainCategoryCode || c.name}
</SelectItem>
)
)}
</SelectContent>
</Select>
{formErrors.mainCategoryId && (
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
)}
</div>
<div>
<Label>Sub Category *</Label>
<Select onValueChange={(v) => setValue('subCategoryId', v)}>
<SelectTrigger>
<SelectValue placeholder="Select Sub Category" />
</SelectTrigger>
<SelectContent>
{shopSubCats?.map(
(c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.subCategoryName || c.subCategoryCode || c.name}
</SelectItem>
)
)}
</SelectContent>
</Select>
{formErrors.subCategoryId && (
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
)}
</div>
</div>
<div>
<Label>Title *</Label>
<Input {...register("title")} placeholder="Drawing Title" />
{formErrors.title && (
<p className="text-sm text-destructive">{formErrors.title.message}</p>
)}
</div>
<div>
<Label>Description</Label>
<Textarea {...register("description")} />
</div>
</>
<div>
<Label>Title *</Label>
<Input {...register('title')} placeholder="Drawing Title" />
{formErrors.title && <p className="text-sm text-destructive">{formErrors.title.message}</p>}
</div>
<div>
<Label>Description</Label>
<Textarea {...register('description')} />
</div>
</>
)}
<div className="mt-4">
@@ -409,14 +421,11 @@ export function DrawingUploadForm() {
className="cursor-pointer"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setValue("file", file);
if (file) setValue('file', file);
}}
/>
{errors.file && (
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
)}
{errors.file && <p className="text-sm text-destructive mt-1">{errors.file.message}</p>}
</div>
</div>
</Card>
@@ -1,8 +1,8 @@
// File: components/layout/dashboard-shell.tsx
"use client";
'use client';
import { useUIStore } from "@/lib/stores/ui-store";
import { cn } from "@/lib/utils";
import { useUIStore } from '@/lib/stores/ui-store';
import { cn } from '@/lib/utils';
export function DashboardShell({ children }: { children: React.ReactNode }) {
const { isSidebarOpen } = useUIStore();
@@ -10,12 +10,12 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"flex flex-col min-h-screen transition-all duration-300 ease-in-out",
'flex flex-col min-h-screen transition-all duration-300 ease-in-out',
// ปรับ Margin ซ้าย ตามสถานะ Sidebar
isSidebarOpen ? "md:ml-[240px]" : "md:ml-[70px]"
isSidebarOpen ? 'md:ml-[240px]' : 'md:ml-[70px]'
)}
>
{children}
</div>
);
}
}
+25 -27
View File
@@ -1,18 +1,12 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Search, FileText, Clipboard, Image, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Command, CommandGroup, CommandItem, CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useSearchSuggestions } from "@/hooks/use-search";
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Search, FileText, Clipboard, Image, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useSearchSuggestions } from '@/hooks/use-search';
/** Search suggestion item returned from the API */
interface SearchSuggestion {
@@ -39,7 +33,7 @@ function useDebounceValue<T>(value: T, delay: number): T {
export function GlobalSearch() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [query, setQuery] = useState('');
const debouncedQuery = useDebounceValue(query, 300);
@@ -62,10 +56,14 @@ export function GlobalSearch() {
const getIcon = (type: string) => {
switch (type) {
case "correspondence": return <FileText className="mr-2 h-4 w-4" />;
case "rfa": return <Clipboard className="mr-2 h-4 w-4" />;
case "drawing": return <Image className="mr-2 h-4 w-4" />;
default: return <Search className="mr-2 h-4 w-4" />;
case 'correspondence':
return <FileText className="mr-2 h-4 w-4" />;
case 'rfa':
return <Clipboard className="mr-2 h-4 w-4" />;
case 'drawing':
return <Image className="mr-2 h-4 w-4" />;
default:
return <Search className="mr-2 h-4 w-4" />;
}
};
@@ -81,17 +79,19 @@ export function GlobalSearch() {
className="pl-8 w-full bg-background"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
onFocus={() => {
if (suggestions && suggestions.length > 0) setOpen(true);
}}
/>
{isLoading && (
<Loader2 className="absolute right-2.5 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
)}
{isLoading && <Loader2 className="absolute right-2.5 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />}
</div>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<CommandList>
{suggestions && suggestions.length > 0 && (
@@ -114,9 +114,7 @@ export function GlobalSearch() {
</CommandGroup>
)}
{(!suggestions || suggestions.length === 0) && !isLoading && (
<div className="py-6 text-center text-sm text-muted-foreground">
No suggestions found.
</div>
<div className="py-6 text-center text-sm text-muted-foreground">No suggestions found.</div>
)}
</CommandList>
</Command>
+10 -17
View File
@@ -1,11 +1,11 @@
// File: components/layout/navbar.tsx
"use client";
'use client';
import Link from "next/link";
import { Menu, Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/lib/stores/ui-store";
import { UserNav } from "./user-nav";
import _Link from 'next/link';
import { Menu, Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/lib/stores/ui-store';
import { UserNav } from './user-nav';
export function Navbar() {
const { toggleSidebar } = useUIStore();
@@ -13,21 +13,14 @@ export function Navbar() {
return (
<header className="flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:pr-6 lg:pl-1 sticky top-0 z-30">
{/* Toggle Sidebar Button (Mobile Only) */}
<Button
variant="outline"
size="icon"
className="shrink-0 md:hidden"
onClick={toggleSidebar}
>
<Button variant="outline" size="icon" className="shrink-0 md:hidden" onClick={toggleSidebar}>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
<div className="w-full flex-1">
{/* Breadcrumbs หรือ Search Bar จะมาใส่ตรงนี้ */}
<h1 className="text-lg font-semibold md:text-xl hidden md:block">
Document Management System
</h1>
<h1 className="text-lg font-semibold md:text-xl hidden md:block">Document Management System</h1>
</div>
{/* Right Actions (เหลือชุดเดียวที่ถูกต้อง) */}
@@ -36,10 +29,10 @@ export function Navbar() {
<Bell className="h-5 w-5" />
<span className="sr-only">Notifications</span>
</Button>
{/* User Menu */}
<UserNav />
</div>
</header>
);
}
}
@@ -1,7 +1,7 @@
"use client";
'use client';
import { Bell, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Bell, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,12 +9,12 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { useNotifications, useMarkNotificationRead } from "@/hooks/use-notification";
import { formatDistanceToNow } from "date-fns";
import { useRouter } from "next/navigation";
import type { Notification } from "@/types/notification";
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { useNotifications, useMarkNotificationRead } from '@/hooks/use-notification';
import { formatDistanceToNow } from 'date-fns';
import { useRouter } from 'next/navigation';
import type { Notification } from '@/types/notification';
export function NotificationsDropdown() {
const router = useRouter();
@@ -54,30 +54,24 @@ export function NotificationsDropdown() {
<DropdownMenuSeparator />
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No new notifications
<div className="flex justify-center p-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">No new notifications</div>
) : (
<div className="max-h-96 overflow-y-auto">
{notifications.slice(0, 5).map((notification: Notification) => (
<DropdownMenuItem
key={notification.notificationId}
className={`flex flex-col items-start p-3 cursor-pointer ${
!notification.isRead ? 'bg-muted/30' : ''
}`}
className={`flex flex-col items-start p-3 cursor-pointer ${!notification.isRead ? 'bg-muted/30' : ''}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex justify-between w-full">
<span className="font-medium text-sm">{notification.title}</span>
{!notification.isRead && <span className="h-2 w-2 rounded-full bg-blue-500 mt-1" />}
</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{notification.message}
<span className="font-medium text-sm">{notification.title}</span>
{!notification.isRead && <span className="h-2 w-2 rounded-full bg-blue-500 mt-1" />}
</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">{notification.message}</div>
<div className="text-[10px] text-muted-foreground mt-1 self-end">
{formatDistanceToNow(new Date(notification.createdAt), {
addSuffix: true,
+16 -22
View File
@@ -1,4 +1,4 @@
"use client";
'use client';
import {
DropdownMenu,
@@ -7,12 +7,12 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LogOut, Settings, User } from "lucide-react";
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { signOut, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { LogOut, Settings, User } from 'lucide-react';
export function UserMenu() {
const router = useRouter();
@@ -24,18 +24,18 @@ export function UserMenu() {
// Generate initials from name or username
const getInitials = (name: string) => {
return name
.split(" ")
.split(' ')
.map((n) => n[0])
.join("")
.join('')
.toUpperCase()
.slice(0, 2);
};
const initials = user.name ? getInitials(user.name) : "U";
const initials = user.name ? getInitials(user.name) : 'U';
const handleLogout = async () => {
await signOut({ redirect: false });
router.push("/login");
router.push('/login');
};
return (
@@ -43,9 +43,7 @@ export function UserMenu() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-primary/10 text-primary">
{initials}
</AvatarFallback>
<AvatarFallback className="bg-primary/10 text-primary">{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
@@ -53,20 +51,16 @@ export function UserMenu() {
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
<p className="text-xs leading-none text-muted-foreground mt-1">
Role: {user.role}
</p>
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
<p className="text-xs leading-none text-muted-foreground mt-1">Role: {user.role}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<DropdownMenuItem onClick={() => router.push('/profile')}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<DropdownMenuItem onClick={() => router.push('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
+22 -28
View File
@@ -1,12 +1,8 @@
// File: components/layout/user-nav.tsx
"use client";
'use client';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,9 +12,9 @@ import {
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
} from '@/components/ui/dropdown-menu';
import { signOut, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export function UserNav() {
const { data: session } = useSession();
@@ -26,22 +22,24 @@ export function UserNav() {
// Helper function to get initials from name
const getInitials = (name: string) => {
return name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.substring(0, 2) || "US";
return (
name
?.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.substring(0, 2) || 'US'
);
};
const userName = session?.user?.name || "User";
const userEmail = session?.user?.email || "user@example.com";
const userName = session?.user?.name || 'User';
const userEmail = session?.user?.email || 'user@example.com';
// ใช้ role หรือ organization หากมีใน session (ต้องแก้ type ใน next-auth.d.ts แล้ว)
const userRole = session?.user?.role || "Viewer";
const userRole = session?.user?.role || 'Viewer';
const handleLogout = async () => {
await signOut({ redirect: false });
router.push("/login");
router.push('/login');
};
return (
@@ -50,7 +48,7 @@ export function UserNav() {
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
{/* ใส่ URL รูปถ้ามี */}
<AvatarImage src={session?.user?.image || ""} alt={userName} />
<AvatarImage src={session?.user?.image || ''} alt={userName} />
<AvatarFallback>{getInitials(userName)}</AvatarFallback>
</Avatar>
</Button>
@@ -59,12 +57,8 @@ export function UserNav() {
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{userName}</p>
<p className="text-xs leading-none text-muted-foreground">
{userEmail}
</p>
<p className="text-xs leading-none text-primary mt-1 font-semibold">
{userRole}
</p>
<p className="text-xs leading-none text-muted-foreground">{userEmail}</p>
<p className="text-xs leading-none text-primary mt-1 font-semibold">{userRole}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
@@ -86,4 +80,4 @@ export function UserNav() {
</DropdownMenuContent>
</DropdownMenu>
);
}
}
@@ -1,19 +1,12 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { documentNumberingService } from "@/lib/services/document-numbering.service";
import { format } from "date-fns";
import { useEffect, useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { documentNumberingService } from '@/lib/services/document-numbering.service';
import { format } from 'date-fns';
export function AuditLogsTable() {
const [logs, setLogs] = useState<any[]>([]); // Replace with AuditLog type
const [logs, setLogs] = useState<unknown[]>([]); // Replace with AuditLog type
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -21,9 +14,9 @@ export function AuditLogsTable() {
try {
const data = await documentNumberingService.getMetrics(); // Using metrics endpoint for now as it contains logs
if (data && data.audit) {
setLogs(data.audit);
setLogs(data.audit);
}
} catch (error) {
} catch (_error) {
// Failed to fetch audit logs - empty state shown
} finally {
setLoading(false);
@@ -49,15 +42,17 @@ export function AuditLogsTable() {
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center">No logs found.</TableCell>
<TableCell colSpan={5} className="text-center">
No logs found.
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id}>
<TableCell>{format(new Date(log.createdAt), "yyyy-MM-dd HH:mm:ss")}</TableCell>
<TableCell>{format(new Date(log.createdAt), 'yyyy-MM-dd HH:mm:ss')}</TableCell>
<TableCell>{log.operation}</TableCell>
<TableCell>{log.generatedNumber}</TableCell>
<TableCell>{log.createdBy || "System"}</TableCell>
<TableCell>{log.createdBy || 'System'}</TableCell>
<TableCell>{log.status}</TableCell>
</TableRow>
))
@@ -1,11 +1,11 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { documentNumberingService } from "@/lib/services/document-numbering.service";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { documentNumberingService } from '@/lib/services/document-numbering.service';
export function BulkImportForm({ projectId = 1 }: { projectId?: number | string }) {
const [file, setFile] = useState<File | null>(null);
@@ -22,14 +22,14 @@ export function BulkImportForm({ projectId = 1 }: { projectId?: number | string
setLoading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("projectId", projectId.toString());
formData.append('file', file);
formData.append('projectId', projectId.toString());
await documentNumberingService.bulkImport(formData);
toast.success("Bulk import initiated. Check audit logs for progress.");
toast.success('Bulk import initiated. Check audit logs for progress.');
setFile(null);
} catch (error) {
toast.error("Failed to import numbers.");
} catch (_error) {
toast.error('Failed to import numbers.');
} finally {
setLoading(false);
}
@@ -37,17 +37,17 @@ export function BulkImportForm({ projectId = 1 }: { projectId?: number | string
return (
<div className="border p-4 rounded-md space-y-4">
<h3 className="text-lg font-medium">Bulk Import Numbers</h3>
<p className="text-sm text-gray-500">Import legacy numbers via CSV to reserve them in the system.</p>
<h3 className="text-lg font-medium">Bulk Import Numbers</h3>
<p className="text-sm text-gray-500">Import legacy numbers via CSV to reserve them in the system.</p>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="csv-file">CSV File</Label>
<Input id="csv-file" type="file" accept=".csv,.xlsx" onChange={handleFileChange} />
</div>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="csv-file">CSV File</Label>
<Input id="csv-file" type="file" accept=".csv,.xlsx" onChange={handleFileChange} />
</div>
<Button onClick={handleUpload} disabled={!file || loading}>
{loading ? "Importing..." : "Upload & Import"}
</Button>
<Button onClick={handleUpload} disabled={!file || loading}>
{loading ? 'Importing...' : 'Upload & Import'}
</Button>
</div>
);
}
@@ -1,26 +1,19 @@
"use client";
'use client';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { documentNumberingService } from "@/lib/services/document-numbering.service";
import { CancelNumberDto } from "@/types/dto/numbering.dto";
import { useState } from "react";
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { documentNumberingService } from '@/lib/services/document-numbering.service';
import { CancelNumberDto } from '@/types/dto/numbering.dto';
import { useState } from 'react';
const formSchema = z.object({
documentNumber: z.string().min(3, "Document Number is required"),
reason: z.string().min(5, "Reason must be at least 5 characters"),
documentNumber: z.string().min(3, 'Document Number is required'),
reason: z.string().min(5, 'Reason must be at least 5 characters'),
});
type CancelNumberFormData = z.infer<typeof formSchema>;
@@ -31,8 +24,8 @@ export function CancelNumberForm() {
const form = useForm<CancelNumberFormData>({
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
defaultValues: {
documentNumber: "",
reason: "",
documentNumber: '',
reason: '',
},
});
@@ -41,10 +34,10 @@ export function CancelNumberForm() {
try {
const dto: CancelNumberDto = values;
await documentNumberingService.cancelNumber(dto);
toast.success("Number cancelled successfully.");
toast.success('Number cancelled successfully.');
form.reset();
} catch (error) {
toast.error("Failed to cancel number. It may not exist or is already cancelled.");
} catch (_error) {
toast.error('Failed to cancel number. It may not exist or is already cancelled.');
} finally {
setLoading(false);
}
@@ -54,30 +47,40 @@ export function CancelNumberForm() {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md">
<h3 className="text-lg font-medium">Cancel Number</h3>
<p className="text-sm text-gray-500">Permanently cancel a number (e.g. if generated by mistake). It cannot be reused.</p>
<p className="text-sm text-gray-500">
Permanently cancel a number (e.g. if generated by mistake). It cannot be reused.
</p>
<FormField control={form.control} name="documentNumber" render={({ field }) => (
<FormItem>
<FormLabel>Document Number</FormLabel>
<FormControl>
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="documentNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Document Number</FormLabel>
<FormControl>
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="reason" render={({ field }) => (
<FormItem>
<FormLabel>Reason</FormLabel>
<FormControl>
<Input placeholder="Reason for cancellation..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>Reason</FormLabel>
<FormControl>
<Input placeholder="Reason for cancellation..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" variant="destructive" disabled={loading}>
{loading ? "Cancelling..." : "Cancel Number"}
{loading ? 'Cancelling...' : 'Cancel Number'}
</Button>
</form>
</Form>
@@ -1,32 +1,24 @@
"use client";
'use client';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { documentNumberingService } from "@/lib/services/document-numbering.service";
import { ManualOverrideDto } from "@/types/dto/numbering.dto";
import { useState } from "react";
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { documentNumberingService } from '@/lib/services/document-numbering.service';
import { ManualOverrideDto } from '@/types/dto/numbering.dto';
import { useState } from 'react';
const formSchema = z.object({
projectId: z.coerce.number().min(1, "Project is required"),
originatorOrganizationId: z.coerce.number().min(1, "Originator is required"),
recipientOrganizationId: z.coerce.number().min(1, "Recipient is required"),
correspondenceTypeId: z.coerce.number().min(1, "Type is required"),
newLastNumber: z.coerce.number().min(1, "New number is required"),
reason: z.string().min(5, "Reason must be at least 5 characters"),
resetScope: z.string().optional()
projectId: z.coerce.number().min(1, 'Project is required'),
originatorOrganizationId: z.coerce.number().min(1, 'Originator is required'),
recipientOrganizationId: z.coerce.number().min(1, 'Recipient is required'),
correspondenceTypeId: z.coerce.number().min(1, 'Type is required'),
newLastNumber: z.coerce.number().min(1, 'New number is required'),
reason: z.string().min(5, 'Reason must be at least 5 characters'),
resetScope: z.string().optional(),
});
export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | string }) {
@@ -40,8 +32,8 @@ export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | str
recipientOrganizationId: 0,
correspondenceTypeId: 0,
newLastNumber: 0,
reason: "",
resetScope: "YEAR_2025" // Example, should be dynamic or selected
reason: '',
resetScope: 'YEAR_2025', // Example, should be dynamic or selected
},
});
@@ -50,13 +42,13 @@ export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | str
try {
const dto: ManualOverrideDto = {
...values,
resetScope: values.resetScope || "YEAR_" + new Date().getFullYear()
resetScope: values.resetScope || 'YEAR_' + new Date().getFullYear(),
};
await documentNumberingService.manualOverride(dto);
toast.success("Manual override applied successfully.");
toast.success('Manual override applied successfully.');
form.reset();
} catch (error) {
toast.error("Failed to apply override.");
} catch (_error) {
toast.error('Failed to apply override.');
} finally {
setLoading(false);
}
@@ -66,65 +58,97 @@ export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | str
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md mt-4">
<h3 className="text-lg font-medium">Manual Override Sequence</h3>
<p className="text-sm text-gray-500">Careful: This updates the LAST generated number. Next number will receive +1.</p>
<p className="text-sm text-gray-500">
Careful: This updates the LAST generated number. Next number will receive +1.
</p>
<div className="grid grid-cols-2 gap-4">
{/* Allow simple text input for IDs for now, ideally Selects from Master Data */}
<FormField control={form.control} name="projectId" render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="correspondenceTypeId" render={({ field }) => (
<FormItem>
<FormLabel>Type ID</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="originatorOrganizationId" render={({ field }) => (
<FormItem>
<FormLabel>Originator Org ID</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="recipientOrganizationId" render={({ field }) => (
<FormItem>
<FormLabel>Recipient Org ID</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{/* Allow simple text input for IDs for now, ideally Selects from Master Data */}
<FormField
control={form.control}
name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="correspondenceTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Type ID</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="originatorOrganizationId"
render={({ field }) => (
<FormItem>
<FormLabel>Originator Org ID</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="recipientOrganizationId"
render={({ field }) => (
<FormItem>
<FormLabel>Recipient Org ID</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField control={form.control} name="newLastNumber" render={({ field }) => (
<FormItem>
<FormLabel>Set Last Number To</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormDescription>
If you set 99, the next auto-generated number will be 100.
</FormDescription>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="newLastNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Set Last Number To</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormDescription>If you set 99, the next auto-generated number will be 100.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="reason" render={({ field }) => (
<FormItem>
<FormLabel>Reason</FormLabel>
<FormControl>
<Input placeholder="Why are you overriding?" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>Reason</FormLabel>
<FormControl>
<Input placeholder="Why are you overriding?" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading}>
{loading ? "Applying..." : "Apply Override"}
{loading ? 'Applying...' : 'Apply Override'}
</Button>
</form>
</Form>
@@ -1,10 +1,10 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { documentNumberingService } from "@/lib/services/document-numbering.service";
import { NumberingMetrics } from "@/types/dto/numbering.dto";
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, _CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { documentNumberingService } from '@/lib/services/document-numbering.service';
import { NumberingMetrics } from '@/types/dto/numbering.dto';
export function MetricsDashboard() {
const [metrics, setMetrics] = useState<Partial<NumberingMetrics>>({});
@@ -15,7 +15,7 @@ export function MetricsDashboard() {
try {
const data = await documentNumberingService.getMetrics();
setMetrics(data);
} catch (error) {
} catch (_error) {
// Failed to fetch metrics - handled by loading state
} finally {
setLoading(false);
@@ -48,12 +48,12 @@ export function MetricsDashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sequence Utilization</CardTitle>
<CardTitle className="text-sm font-medium">Sequence Utilization</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{utilization}%</div>
<Progress value={utilization} className="mt-2" />
<p className="text-xs text-muted-foreground mt-1">Average capacity used</p>
<div className="text-2xl font-bold">{utilization}%</div>
<Progress value={utilization} className="mt-2" />
<p className="text-xs text-muted-foreground mt-1">Average capacity used</p>
</CardContent>
</Card>
@@ -68,13 +68,13 @@ export function MetricsDashboard() {
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics.errors?.length || 0}</div>
<p className="text-xs text-muted-foreground">In the last 24 hours</p>
</CardContent>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics.errors?.length || 0}</div>
<p className="text-xs text-muted-foreground">In the last 24 hours</p>
</CardContent>
</Card>
</div>
);
@@ -18,7 +18,7 @@ export function SequenceViewer() {
try {
const response = await numberingApi.getSequences();
// Handle wrapped response { data: [...] } or direct array
const data = Array.isArray(response) ? response : (response as { data?: NumberSequence[] })?.data ?? [];
const data = Array.isArray(response) ? response : ((response as { data?: NumberSequence[] })?.data ?? []);
setSequences(data);
} catch {
// Failed to fetch sequences - show empty state
@@ -43,12 +43,7 @@ export function SequenceViewer() {
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Number Counters</h3>
<Button
variant="outline"
size="sm"
onClick={fetchSequences}
disabled={loading}
>
<Button variant="outline" size="sm" onClick={fetchSequences} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
@@ -64,9 +59,7 @@ export function SequenceViewer() {
<div className="space-y-2">
{filteredSequences.length === 0 && (
<div className="text-center text-muted-foreground py-4">
No sequences found
</div>
<div className="text-center text-muted-foreground py-4">No sequences found</div>
)}
{filteredSequences.map((seq, index) => (
<div
@@ -78,15 +71,11 @@ export function SequenceViewer() {
<span className="font-medium">Year {seq.year}</span>
<Badge variant="outline">Project: {seq.projectId}</Badge>
<Badge>Type: {seq.typeId}</Badge>
{seq.disciplineId > 0 && (
<Badge variant="secondary">Disc: {seq.disciplineId}</Badge>
)}
{seq.disciplineId > 0 && <Badge variant="secondary">Disc: {seq.disciplineId}</Badge>}
</div>
<div className="text-sm text-muted-foreground">
<span className="text-foreground font-medium">
Counter: {seq.lastNumber}
</span>{' '}
| Originator: {seq.originatorId} | Recipient:{' '}
<span className="text-foreground font-medium">Counter: {seq.lastNumber}</span> | Originator:{' '}
{seq.originatorId} | Recipient:{' '}
{seq.recipientOrganizationId === -1 ? 'All' : seq.recipientOrganizationId}
</div>
</div>
+136 -140
View File
@@ -5,21 +5,11 @@ import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { NumberingTemplate } from '@/lib/api/numbering';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
// Aligned with Backend replacement logic
const VARIABLES = [
@@ -36,13 +26,13 @@ const VARIABLES = [
];
export interface TemplateEditorProps {
template?: NumberingTemplate;
projectId: number | string;
projectName: string;
correspondenceTypes: unknown[];
disciplines: unknown[];
onSave: (data: Partial<NumberingTemplate>) => void;
onCancel: () => void;
template?: NumberingTemplate;
projectId: number | string;
projectName: string;
correspondenceTypes: unknown[];
disciplines: unknown[];
onSave: (data: Partial<NumberingTemplate>) => void;
onCancel: () => void;
}
export function TemplateEditor({
@@ -52,7 +42,7 @@ export function TemplateEditor({
correspondenceTypes,
disciplines,
onSave,
onCancel
onCancel,
}: TemplateEditorProps) {
const [format, setFormat] = useState(template?.formatTemplate || '');
const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || '');
@@ -65,18 +55,20 @@ export function TemplateEditor({
// Generate preview
let previewText = format || '';
VARIABLES.forEach((v) => {
// Simple mock replacement for preview
let replacement = v.example;
if (v.key === '{YEAR:BE}') replacement = (new Date().getFullYear() + 543).toString();
if (v.key === '{YEAR:CE}') replacement = new Date().getFullYear().toString();
// Simple mock replacement for preview
let replacement = v.example;
if (v.key === '{YEAR:BE}') replacement = (new Date().getFullYear() + 543).toString();
if (v.key === '{YEAR:CE}') replacement = new Date().getFullYear().toString();
// Dynamic context based on selection (optional visual enhancement)
if (v.key === '{TYPE}' && typeId) {
const t = (correspondenceTypes as { id: number; typeCode: string; typeName: string }[]).find((ct) => ct.id?.toString() === typeId);
if (t) replacement = t.typeCode;
}
// Dynamic context based on selection (optional visual enhancement)
if (v.key === '{TYPE}' && typeId) {
const t = (correspondenceTypes as { id: number; typeCode: string; typeName: string }[]).find(
(ct) => ct.id?.toString() === typeId
);
if (t) replacement = t.typeCode;
}
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
});
setPreview(previewText);
}, [format, typeId, correspondenceTypes]);
@@ -86,14 +78,14 @@ export function TemplateEditor({
};
const handleSave = () => {
onSave({
...template,
projectId: projectId,
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
disciplineId: Number(disciplineId),
formatTemplate: format,
resetSequenceYearly: reset,
});
onSave({
...template,
projectId: projectId,
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
disciplineId: Number(disciplineId),
formatTemplate: format,
resetSequenceYearly: reset,
});
};
const isValid = format.length > 0; // typeId is optional (null = default for all types)
@@ -102,121 +94,125 @@ export function TemplateEditor({
<Card className="p-6 space-y-6">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
</div>
<p className="text-sm text-muted-foreground">Define how document numbers are generated for this project.</p>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
</div>
<p className="text-sm text-muted-foreground">Define how document numbers are generated for this project.</p>
</div>
<div className="flex flex-col items-end gap-2">
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
Project: {projectName}
</Badge>
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
Project: {projectName}
</Badge>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Configuration Column */}
<div className="space-y-4">
<div>
<Label>Document Type (Optional)</Label>
<Select value={typeId} onValueChange={setTypeId}>
<SelectTrigger>
<SelectValue placeholder="Default (All Types)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (All Types)</SelectItem>
{correspondenceTypes.map((type: unknown) => {
const typedType = type as { id: number; typeCode: string; typeName: string };
return (
<SelectItem key={typedType.id} value={typedType.id.toString()}>
{typedType.typeCode} - {typedType.typeName}
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to create a default template for this project.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Discipline (Optional)</Label>
<Select value={disciplineId} onValueChange={setDisciplineId}>
<SelectTrigger>
<SelectValue placeholder="All Disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">All Disciplines</SelectItem>
{disciplines.map((d: any) => (
<SelectItem key={d.id} value={d.id.toString()}>
{d.disciplineCode} - {d.codeNameEn || d.codeNameTh}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Reset Rule</Label>
<div className="flex items-center h-10">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={reset} onCheckedChange={(c) => setReset(!!c)} />
<span className="text-sm">Reset Annually</span>
</label>
</div>
</div>
</div>
{/* Configuration Column */}
<div className="space-y-4">
<div>
<Label>Document Type (Optional)</Label>
<Select value={typeId} onValueChange={setTypeId}>
<SelectTrigger>
<SelectValue placeholder="Default (All Types)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (All Types)</SelectItem>
{correspondenceTypes.map((type: unknown) => {
const typedType = type as { id: number; typeCode: string; typeName: string };
return (
<SelectItem key={typedType.id} value={typedType.id.toString()}>
{typedType.typeCode} - {typedType.typeName}
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to create a default template for this project.
</p>
</div>
{/* Format Column */}
<div className="space-y-4">
<div>
<Label>Template Format *</Label>
<Input
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="{ORG}-{TYPE}-{SEQ:4}"
className="font-mono text-base mb-2"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
<HoverCard key={v.key}>
<HoverCardTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
className="font-mono text-xs bg-slate-50 hover:bg-slate-100"
>
{v.key}
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-60 p-3">
<p className="font-semibold text-sm">{v.name}</p>
<p className="text-xs text-muted-foreground mt-1">Example: <span className="font-mono">{v.example}</span></p>
</HoverCardContent>
</HoverCard>
))}
</div>
</div>
<div className="bg-green-50/50 border border-green-200 rounded-lg p-4">
<p className="text-xs uppercase tracking-wide text-green-700 font-semibold mb-2">Preview Output</p>
<p className="text-2xl font-mono font-bold text-green-800 tracking-tight">
{preview || '...'}
</p>
<p className="text-xs text-green-600 mt-2">
* This is an approximation. Actual numbers depend on runtime context.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Discipline (Optional)</Label>
<Select value={disciplineId} onValueChange={setDisciplineId}>
<SelectTrigger>
<SelectValue placeholder="All Disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">All Disciplines</SelectItem>
{disciplines.map((d: unknown) => (
<SelectItem key={d.id} value={d.id.toString()}>
{d.disciplineCode} - {d.codeNameEn || d.codeNameTh}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Reset Rule</Label>
<div className="flex items-center h-10">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={reset} onCheckedChange={(c) => setReset(!!c)} />
<span className="text-sm">Reset Annually</span>
</label>
</div>
</div>
</div>
</div>
{/* Format Column */}
<div className="space-y-4">
<div>
<Label>Template Format *</Label>
<Input
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="{ORG}-{TYPE}-{SEQ:4}"
className="font-mono text-base mb-2"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
<HoverCard key={v.key}>
<HoverCardTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
className="font-mono text-xs bg-slate-50 hover:bg-slate-100"
>
{v.key}
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-60 p-3">
<p className="font-semibold text-sm">{v.name}</p>
<p className="text-xs text-muted-foreground mt-1">
Example: <span className="font-mono">{v.example}</span>
</p>
</HoverCardContent>
</HoverCard>
))}
</div>
</div>
<div className="bg-green-50/50 border border-green-200 rounded-lg p-4">
<p className="text-xs uppercase tracking-wide text-green-700 font-semibold mb-2">Preview Output</p>
<p className="text-2xl font-mono font-bold text-green-800 tracking-tight">{preview || '...'}</p>
<p className="text-xs text-green-600 mt-2">
* This is an approximation. Actual numbers depend on runtime context.
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={handleSave} disabled={!isValid}>Save Template</Button>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isValid}>
Save Template
</Button>
</div>
</Card>
);
+152 -157
View File
@@ -1,25 +1,14 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useOrganizations, useCorrespondenceTypes, useDisciplines, useContracts } from '@/hooks/use-master-data';
import { Organization } from '@/types/organization';
@@ -35,19 +24,18 @@ interface Discipline {
disciplineCode: string;
}
interface TemplateTesterProps {
open: boolean;
onOpenChange: (open: boolean) => void;
template: NumberingTemplate | null;
open: boolean;
onOpenChange: (open: boolean) => void;
template: NumberingTemplate | null;
}
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
const [testData, setTestData] = useState({
originatorId: "",
recipientId: "",
correspondenceTypeId: "",
disciplineId: "",
originatorId: '',
recipientId: '',
correspondenceTypeId: '',
disciplineId: '',
year: new Date().getFullYear(),
});
const [testResult, setTestResult] = useState<{ number: string; isDefault?: boolean } | null>(null);
@@ -69,28 +57,28 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
setLoading(true);
setTestResult(null);
try {
const payload = {
projectId: projectId,
originatorOrganizationId: testData.originatorId || "0",
recipientOrganizationId: testData.recipientId || "0",
correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"),
disciplineId: parseInt(testData.disciplineId || "0"),
year: testData.year
};
console.log("TemplateTester: Sending payload:", payload);
const result = await numberingApi.previewNumber(payload);
console.log("TemplateTester: Received result:", result);
setTestResult({
number: result.previewNumber,
isDefault: result.isDefault
});
} catch (error: any) {
console.error("Test Preview Error:", error);
const errMsg = error?.response?.data?.message || error?.message || "Unknown error";
setTestResult({ number: `Error: ${errMsg}`, isDefault: false });
const payload = {
projectId: projectId,
originatorOrganizationId: testData.originatorId || '0',
recipientOrganizationId: testData.recipientId || '0',
correspondenceTypeId: Number(testData.correspondenceTypeId || '0'),
disciplineId: Number(testData.disciplineId || '0'),
year: testData.year,
};
// console.log("TemplateTester: Sending payload:", payload); /* TODO: Remove before prod */
const result = await numberingApi.previewNumber(payload);
// console.log("TemplateTester: Received result:", result); /* TODO: Remove before prod */
setTestResult({
number: result.previewNumber,
isDefault: result.isDefault,
});
} catch (error: unknown) {
// console.error("Test Preview Error:", error); /* TODO: Remove before prod */
const errMsg = error?.response?.data?.message || error?.message || 'Unknown error';
setTestResult({ number: `Error: ${errMsg}`, isDefault: false });
} finally {
setLoading(false);
setLoading(false);
}
};
@@ -102,132 +90,139 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</DialogHeader>
<div className="text-sm text-muted-foreground mb-2">
Template: <span className="font-mono font-bold text-foreground">{template?.formatTemplate}</span>
Template: <span className="font-mono font-bold text-foreground">{template?.formatTemplate}</span>
</div>
<Card className="p-6 mt-6 bg-muted/50 rounded-lg">
<h3 className="text-lg font-semibold mb-4">Template Tester</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Originator */}
<div className="space-y-2">
<label className="text-xs font-medium">Originator (ORG)</label>
<Select
value={testData.originatorId}
onValueChange={(val) => setTestData({...testData, originatorId: val})}
>
<SelectTrigger>
<SelectValue placeholder="Select Originator" />
</SelectTrigger>
<SelectContent>
{(organizations as Organization[])?.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<h3 className="text-lg font-semibold mb-4">Template Tester</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Originator */}
<div className="space-y-2">
<label className="text-xs font-medium">Originator (ORG)</label>
<Select
value={testData.originatorId}
onValueChange={(val) => setTestData({ ...testData, originatorId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select Originator" />
</SelectTrigger>
<SelectContent>
{(organizations as Organization[])?.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Recipient */}
<div className="space-y-2">
<label className="text-xs font-medium">Recipient (REC)</label>
<Select
value={testData.recipientId}
onValueChange={(val) => setTestData({...testData, recipientId: val})}
>
<SelectTrigger>
<SelectValue placeholder="Select Recipient" />
</SelectTrigger>
<SelectContent>
{(organizations as Organization[])?.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Recipient */}
<div className="space-y-2">
<label className="text-xs font-medium">Recipient (REC)</label>
<Select
value={testData.recipientId}
onValueChange={(val) => setTestData({ ...testData, recipientId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select Recipient" />
</SelectTrigger>
<SelectContent>
{(organizations as Organization[])?.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Document Type */}
<div className="space-y-2">
<label className="text-xs font-medium">Document Type (TYPE)</label>
<Select
value={testData.correspondenceTypeId}
onValueChange={(val) => setTestData({...testData, correspondenceTypeId: val})}
>
<SelectTrigger>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Default (All Types)</SelectItem>
{(correspondenceTypes as CorrespondenceType[])?.map((type) => (
<SelectItem key={type.id} value={type.id.toString()}>
{type.typeCode} - {type.typeName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Document Type */}
<div className="space-y-2">
<label className="text-xs font-medium">Document Type (TYPE)</label>
<Select
value={testData.correspondenceTypeId}
onValueChange={(val) => setTestData({ ...testData, correspondenceTypeId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Default (All Types)</SelectItem>
{(correspondenceTypes as CorrespondenceType[])?.map((type) => (
<SelectItem key={type.id} value={type.id.toString()}>
{type.typeCode} - {type.typeName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Discipline */}
<div className="space-y-2">
<label className="text-xs font-medium">Discipline (DIS)</label>
<Select
value={testData.disciplineId}
onValueChange={(val) => setTestData({...testData, disciplineId: val})}
>
<SelectTrigger>
<SelectValue placeholder="Select Discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">None</SelectItem>
{(disciplines as Discipline[])?.map((disc) => (
<SelectItem key={disc.id} value={disc.id.toString()}>
{disc.disciplineCode}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground mt-4">
Format Preview: {template?.formatTemplate}
</p>
</div>
{/* Discipline */}
<div className="space-y-2">
<label className="text-xs font-medium">Discipline (DIS)</label>
<Select
value={testData.disciplineId}
onValueChange={(val) => setTestData({ ...testData, disciplineId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select Discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">None</SelectItem>
{(disciplines as Discipline[])?.map((disc) => (
<SelectItem key={disc.id} value={disc.id.toString()}>
{disc.disciplineCode}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground mt-4">Format Preview: {template?.formatTemplate}</p>
</div>
</div>
</div>
</Card>
<Button onClick={handleGenerate} className="w-full mt-4" disabled={loading || !template}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Test Number
</Button>
{testResult && (
<Card className={`p-4 mt-4 border text-center ${(testResult.number || '').startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}>
<div className="flex justify-between items-center mb-1">
<p className="text-sm text-muted-foreground">{(testResult.number || '').startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}</p>
{testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">Default Template</Badge>
)}
{!testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
<Badge variant="outline" className="text-[10px] h-4 px-1 border-green-300 text-green-700 bg-green-50">Specific Template</Badge>
)}
</div>
<div className={`text-2xl font-mono font-bold ${(testResult.number || '').startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}>
{testResult.number || (
<div className="text-xs font-mono text-left bg-slate-100 p-2 overflow-auto max-h-48 border rounded text-foreground">
Empty Result. Raw: {JSON.stringify(testResult, null, 2)}
</div>
)}
</div>
</Card>
)}
<Button onClick={handleGenerate} className="w-full mt-4" disabled={loading || !template}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Test Number
</Button>
{testResult && (
<Card
className={`p-4 mt-4 border text-center ${(testResult.number || '').startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}
>
<div className="flex justify-between items-center mb-1">
<p className="text-sm text-muted-foreground">
{(testResult.number || '').startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}
</p>
{testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
Default Template
</Badge>
)}
{!testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
<Badge variant="outline" className="text-[10px] h-4 px-1 border-green-300 text-green-700 bg-green-50">
Specific Template
</Badge>
)}
</div>
<div
className={`text-2xl font-mono font-bold ${(testResult.number || '').startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}
>
{testResult.number || (
<div className="text-xs font-mono text-left bg-slate-100 p-2 overflow-auto max-h-48 border rounded text-foreground">
Empty Result. Raw: {JSON.stringify(testResult, null, 2)}
</div>
)}
</div>
</Card>
)}
</DialogContent>
</Dialog>
);
@@ -1,30 +1,22 @@
"use client";
'use client';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import { documentNumberingService } from "@/lib/services/document-numbering.service";
import { VoidReplaceDto } from "@/types/dto/numbering.dto";
import { useState } from "react";
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { documentNumberingService } from '@/lib/services/document-numbering.service';
import { VoidReplaceDto } from '@/types/dto/numbering.dto';
import { useState } from 'react';
const formSchema = z.object({
documentNumber: z.string().min(3, "Document Number is required"),
reason: z.string().min(5, "Reason must be at least 5 characters"),
documentNumber: z.string().min(3, 'Document Number is required'),
reason: z.string().min(5, 'Reason must be at least 5 characters'),
replace: z.boolean(),
projectId: z.number()
projectId: z.number(),
});
type VoidReplaceFormData = z.infer<typeof formSchema>;
@@ -35,10 +27,10 @@ export function VoidReplaceForm({ projectId = 1 }: { projectId?: number | string
const form = useForm<VoidReplaceFormData>({
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
defaultValues: {
documentNumber: "",
reason: "",
documentNumber: '',
reason: '',
replace: false,
projectId: Number(projectId)
projectId: Number(projectId),
},
});
@@ -49,10 +41,10 @@ export function VoidReplaceForm({ projectId = 1 }: { projectId?: number | string
...values,
};
await documentNumberingService.voidAndReplace(dto);
toast.success("Number voided successfully. " + (values.replace ? "Replacement generated." : ""));
toast.success('Number voided successfully. ' + (values.replace ? 'Replacement generated.' : ''));
form.reset();
} catch (error) {
toast.error("Failed to void number. Check if it exists.");
} catch (_error) {
toast.error('Failed to void number. Check if it exists.');
} finally {
setLoading(false);
}
@@ -64,47 +56,52 @@ export function VoidReplaceForm({ projectId = 1 }: { projectId?: number | string
<h3 className="text-lg font-medium">Void & Replace Number</h3>
<p className="text-sm text-gray-500">Void a generated number. Useful for skipped numbers or errors.</p>
<FormField control={form.control} name="documentNumber" render={({ field }) => (
<FormItem>
<FormLabel>Document Number</FormLabel>
<FormControl>
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="documentNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Document Number</FormLabel>
<FormControl>
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="reason" render={({ field }) => (
<FormItem>
<FormLabel>Reason</FormLabel>
<FormControl>
<Input placeholder="Reason for voiding..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>Reason</FormLabel>
<FormControl>
<Input placeholder="Reason for voiding..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="replace" render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Generate Replacement?
</FormLabel>
<FormDescription>
If checked, a new number will be reserved immediately.
</FormDescription>
</div>
</FormItem>
)} />
<FormField
control={form.control}
name="replace"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Generate Replacement?</FormLabel>
<FormDescription>If checked, a new number will be reserved immediately.</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" variant="destructive" disabled={loading}>
{loading ? "Processing..." : "Void Number"}
{loading ? 'Processing...' : 'Void Number'}
</Button>
</form>
</Form>

Some files were not shown because too many files have changed in this diff Show More