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>