260322:1648 Correct Coresspondence / Doing RFA / Correct CI
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,15 +5,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
export default function AdminError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error('[Admin Error Boundary]', error);
|
||||
// // console.error('[Admin Error Boundary]', error); /* TODO: Remove before prod */
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -24,9 +18,7 @@ export default function AdminError({
|
||||
<p className="text-muted-foreground mt-1 text-sm max-w-md">
|
||||
{error.message || 'An error occurred in the admin panel.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
|
||||
)}
|
||||
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={reset} variant="outline" size="sm">
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
// Force dynamic rendering for all pages under (auth) route group.
|
||||
// QNAP overlayfs cannot handle the .segments/!<base64> directories
|
||||
// that Next.js 16 creates during static page generation.
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-muted/40 p-4">
|
||||
{/* Container หลักจัดกึ่งกลาง */}
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
// File: app/(auth)/login/page.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
// กำหนด Schema สำหรับตรวจสอบข้อมูลฟอร์ม
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "กรุณาระบุชื่อผู้ใช้งาน"),
|
||||
password: z.string().min(1, "กรุณาระบุรหัสผ่าน"),
|
||||
username: z.string().min(1, 'กรุณาระบุชื่อผู้ใช้งาน'),
|
||||
password: z.string().min(1, 'กรุณาระบุรหัสผ่าน'),
|
||||
});
|
||||
|
||||
type LoginValues = z.infer<typeof loginSchema>;
|
||||
@@ -44,8 +37,8 @@ export default function LoginPage() {
|
||||
} = useForm<LoginValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,7 +48,7 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
// เรียกใช้ NextAuth signIn (Credential Provider)
|
||||
const result = await signIn("credentials", {
|
||||
const result = await signIn('credentials', {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
redirect: false, // เราจะจัดการ Redirect เอง
|
||||
@@ -63,21 +56,21 @@ export default function LoginPage() {
|
||||
|
||||
if (result?.error) {
|
||||
// กรณี Login ไม่สำเร็จ
|
||||
toast.error("เข้าสู่ระบบไม่สำเร็จ", {
|
||||
description: "ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่",
|
||||
toast.error('เข้าสู่ระบบไม่สำเร็จ', {
|
||||
description: 'ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Login สำเร็จ -> ไปหน้า Dashboard
|
||||
toast.success("เข้าสู่ระบบสำเร็จ", {
|
||||
description: "กำลังพาท่านไปยังหน้า Dashboard...",
|
||||
toast.success('เข้าสู่ระบบสำเร็จ', {
|
||||
description: 'กำลังพาท่านไปยังหน้า Dashboard...',
|
||||
});
|
||||
router.push("/dashboard");
|
||||
router.push('/dashboard');
|
||||
router.refresh(); // Refresh เพื่อให้ Server Component รับรู้ Session ใหม่
|
||||
} catch (error) {
|
||||
toast.error("เกิดข้อผิดพลาด", {
|
||||
description: "ระบบขัดข้อง กรุณาลองใหม่อีกครั้ง หรือติดต่อผู้ดูแลระบบ",
|
||||
} catch (_error) {
|
||||
toast.error('เกิดข้อผิดพลาด', {
|
||||
description: 'ระบบขัดข้อง กรุณาลองใหม่อีกครั้ง หรือติดต่อผู้ดูแลระบบ',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -87,12 +80,8 @@ export default function LoginPage() {
|
||||
return (
|
||||
<Card className="w-full max-w-sm shadow-lg">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<CardTitle className="text-2xl font-bold text-primary">
|
||||
LCBP3 DMS
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
กรอกชื่อผู้ใช้งานและรหัสผ่านเพื่อเข้าสู่ระบบ
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold text-primary">LCBP3 DMS</CardTitle>
|
||||
<CardDescription>กรอกชื่อผู้ใช้งานและรหัสผ่านเพื่อเข้าสู่ระบบ</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -108,14 +97,10 @@ export default function LoginPage() {
|
||||
autoComplete="username"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
className={errors.username ? "border-destructive" : ""}
|
||||
{...register("username")}
|
||||
className={errors.username ? 'border-destructive' : ''}
|
||||
{...register('username')}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
{errors.username && <p className="text-xs text-destructive">{errors.username.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
@@ -125,11 +110,11 @@ export default function LoginPage() {
|
||||
<Input
|
||||
id="password"
|
||||
placeholder="••••••••"
|
||||
type={showPassword ? "text" : "password"}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={errors.password ? "border-destructive pr-10" : "pr-10"}
|
||||
{...register("password")}
|
||||
className={errors.password ? 'border-destructive pr-10' : 'pr-10'}
|
||||
{...register('password')}
|
||||
/>
|
||||
{/* ปุ่ม Show/Hide Password */}
|
||||
<Button
|
||||
@@ -145,16 +130,10 @@ export default function LoginPage() {
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{showPassword ? "Hide password" : "Show password"}
|
||||
</span>
|
||||
<span className="sr-only">{showPassword ? 'Hide password' : 'Show password'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { circulationService } from "@/lib/services/circulation.service";
|
||||
import { Circulation, UpdateCirculationRoutingDto } from "@/types/circulation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ArrowLeft, RefreshCw, CheckCircle2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { circulationService } from '@/lib/services/circulation.service';
|
||||
import { Circulation, UpdateCirculationRoutingDto } from '@/types/circulation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ArrowLeft, RefreshCw, CheckCircle2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Get initials from name
|
||||
*/
|
||||
function getInitials(firstName?: string, lastName?: string): string {
|
||||
const first = firstName?.charAt(0) || "";
|
||||
const last = lastName?.charAt(0) || "";
|
||||
return (first + last).toUpperCase() || "?";
|
||||
const first = firstName?.charAt(0) || '';
|
||||
const last = lastName?.charAt(0) || '';
|
||||
return (first + last).toUpperCase() || '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge variant
|
||||
*/
|
||||
function getStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
|
||||
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status?.toUpperCase()) {
|
||||
case "PENDING":
|
||||
return "outline";
|
||||
case "IN_PROGRESS":
|
||||
return "default";
|
||||
case "COMPLETED":
|
||||
return "secondary";
|
||||
case "REJECTED":
|
||||
return "destructive";
|
||||
case 'PENDING':
|
||||
return 'outline';
|
||||
case 'IN_PROGRESS':
|
||||
return 'default';
|
||||
case 'COMPLETED':
|
||||
return 'secondary';
|
||||
case 'REJECTED':
|
||||
return 'destructive';
|
||||
default:
|
||||
return "outline";
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,12 @@ export default function CirculationDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const uuid = params.uuid as string;
|
||||
|
||||
const { data: circulation, isLoading, error } = useQuery<Circulation>({
|
||||
queryKey: ["circulation", uuid],
|
||||
const {
|
||||
data: circulation,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<Circulation>({
|
||||
queryKey: ['circulation', uuid],
|
||||
queryFn: () => circulationService.getByUuid(uuid),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
@@ -55,18 +59,18 @@ export default function CirculationDetailPage() {
|
||||
mutationFn: ({ routingId, data }: { routingId: number; data: UpdateCirculationRoutingDto }) =>
|
||||
circulationService.updateRouting(routingId, data),
|
||||
onSuccess: () => {
|
||||
toast.success("Task completed successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["circulation", uuid] });
|
||||
toast.success('Task completed successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['circulation', uuid] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update task status");
|
||||
toast.error('Failed to update task status');
|
||||
},
|
||||
});
|
||||
|
||||
const handleComplete = (routingId: number) => {
|
||||
completeMutation.mutate({
|
||||
routingId,
|
||||
data: { status: "COMPLETED", comments: "Completed via UI" },
|
||||
data: { status: 'COMPLETED', comments: 'Completed via UI' },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -109,9 +113,7 @@ export default function CirculationDetailPage() {
|
||||
<p className="text-muted-foreground">{circulation.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(circulation.statusCode)}>
|
||||
{circulation.statusCode}
|
||||
</Badge>
|
||||
<Badge variant={getStatusVariant(circulation.statusCode)}>{circulation.statusCode}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
@@ -122,24 +124,20 @@ export default function CirculationDetailPage() {
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Organization</p>
|
||||
<p className="font-medium">
|
||||
{circulation.organization?.organization_name || "-"}
|
||||
</p>
|
||||
<p className="font-medium">{circulation.organization?.organization_name || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Created By</p>
|
||||
<p className="font-medium">
|
||||
{circulation.creator
|
||||
? `${circulation.creator.first_name || ""} ${circulation.creator.last_name || ""}`.trim() ||
|
||||
? `${circulation.creator.first_name || ''} ${circulation.creator.last_name || ''}`.trim() ||
|
||||
circulation.creator.username
|
||||
: "-"}
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Created At</p>
|
||||
<p className="font-medium">
|
||||
{format(new Date(circulation.createdAt), "dd MMM yyyy, HH:mm")}
|
||||
</p>
|
||||
<p className="font-medium">{format(new Date(circulation.createdAt), 'dd MMM yyyy, HH:mm')}</p>
|
||||
</div>
|
||||
{circulation.correspondence && (
|
||||
<div>
|
||||
@@ -164,25 +162,19 @@ export default function CirculationDetailPage() {
|
||||
{circulation.routings && circulation.routings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{circulation.routings.map((routing) => (
|
||||
<div
|
||||
key={routing.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div key={routing.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
{getInitials(
|
||||
routing.assignee?.first_name,
|
||||
routing.assignee?.last_name
|
||||
)}
|
||||
{getInitials(routing.assignee?.first_name, routing.assignee?.last_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{routing.assignee
|
||||
? `${routing.assignee.first_name || ""} ${routing.assignee.last_name || ""}`.trim() ||
|
||||
? `${routing.assignee.first_name || ''} ${routing.assignee.last_name || ''}`.trim() ||
|
||||
routing.assignee.username
|
||||
: "Unassigned"}
|
||||
: 'Unassigned'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Step {routing.stepNumber}
|
||||
@@ -191,10 +183,8 @@ export default function CirculationDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getStatusVariant(routing.status)}>
|
||||
{routing.status}
|
||||
</Badge>
|
||||
{routing.status === "PENDING" && (
|
||||
<Badge variant={getStatusVariant(routing.status)}>{routing.status}</Badge>
|
||||
{routing.status === 'PENDING' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleComplete(routing.id)}
|
||||
|
||||
@@ -1,47 +1,29 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { circulationService } from "@/lib/services/circulation.service";
|
||||
import { userService } from "@/lib/services/user.service";
|
||||
import { correspondenceService } from "@/lib/services/correspondence.service";
|
||||
import { CreateCirculationDto } from "@/types/circulation";
|
||||
import { circulationService } from '@/lib/services/circulation.service';
|
||||
import { userService } from '@/lib/services/user.service';
|
||||
import { correspondenceService } from '@/lib/services/correspondence.service';
|
||||
import { CreateCirculationDto } from '@/types/circulation';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft, Check, ChevronsUpDown, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Force dynamic rendering to prevent build-time prerendering issues
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -51,9 +33,9 @@ export const fetchCache = 'force-no-store';
|
||||
|
||||
// Form validation schema
|
||||
const formSchema = z.object({
|
||||
correspondenceId: z.string().min(1, "Please select a document"),
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
assigneeIds: z.array(z.string()).min(1, "At least one assignee is required"),
|
||||
correspondenceId: z.string().min(1, 'Please select a document'),
|
||||
subject: z.string().min(1, 'Subject is required'),
|
||||
assigneeIds: z.array(z.string()).min(1, 'At least one assignee is required'),
|
||||
remarks: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -67,32 +49,32 @@ export default function CreateCirculationPage() {
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
subject: "",
|
||||
subject: '',
|
||||
assigneeIds: [],
|
||||
remarks: "",
|
||||
remarks: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch users for assignee selection
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryKey: ['users'],
|
||||
queryFn: () => userService.getAll(),
|
||||
});
|
||||
|
||||
// Fetch correspondences for document selection
|
||||
const { data: correspondences } = useQuery({
|
||||
queryKey: ["correspondences-dropdown"],
|
||||
queryKey: ['correspondences-dropdown'],
|
||||
queryFn: () => correspondenceService.getAll({ limit: 100 }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateCirculationDto) => circulationService.create(data),
|
||||
onSuccess: (result) => {
|
||||
toast.success("Circulation created successfully");
|
||||
toast.success('Circulation created successfully');
|
||||
router.push(`/circulation/${result.uuid}`);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to create circulation");
|
||||
toast.error('Failed to create circulation');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,22 +82,20 @@ export default function CreateCirculationPage() {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const selectedAssignees = form.watch("assigneeIds");
|
||||
const selectedDocId = form.watch("correspondenceId");
|
||||
const selectedAssignees = form.watch('assigneeIds');
|
||||
const selectedDocId = form.watch('correspondenceId');
|
||||
|
||||
const selectedDoc = correspondences?.data?.find(
|
||||
(c: { uuid: string }) => c.uuid === selectedDocId
|
||||
);
|
||||
const selectedDoc = correspondences?.data?.find((c: { uuid: string }) => c.uuid === selectedDocId);
|
||||
|
||||
const toggleAssignee = (userUuid: string) => {
|
||||
const current = form.getValues("assigneeIds");
|
||||
const current = form.getValues('assigneeIds');
|
||||
if (current.includes(userUuid)) {
|
||||
form.setValue(
|
||||
"assigneeIds",
|
||||
'assigneeIds',
|
||||
current.filter((id) => id !== userUuid)
|
||||
);
|
||||
} else {
|
||||
form.setValue("assigneeIds", [...current, userUuid]);
|
||||
form.setValue('assigneeIds', [...current, userUuid]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,9 +110,7 @@ export default function CreateCirculationPage() {
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create Circulation</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create a new internal document circulation
|
||||
</p>
|
||||
<p className="text-muted-foreground">Create a new internal document circulation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,14 +134,9 @@ export default function CreateCirculationPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
className={cn('justify-between', !field.value && 'text-muted-foreground')}
|
||||
>
|
||||
{selectedDoc
|
||||
? selectedDoc.correspondenceNumber
|
||||
: "Select document..."}
|
||||
{selectedDoc ? selectedDoc.correspondenceNumber : 'Select document...'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -179,16 +152,14 @@ export default function CreateCirculationPage() {
|
||||
key={doc.uuid}
|
||||
value={doc.correspondenceNumber}
|
||||
onSelect={() => {
|
||||
form.setValue("correspondenceId", doc.uuid);
|
||||
form.setValue('correspondenceId', doc.uuid);
|
||||
setDocOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
doc.uuid === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
'mr-2 h-4 w-4',
|
||||
doc.uuid === field.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{doc.correspondenceNumber}
|
||||
@@ -223,29 +194,19 @@ export default function CreateCirculationPage() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assigneeIds"
|
||||
render={({ field }) => (
|
||||
render={({ _field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Assignees</FormLabel>
|
||||
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="justify-between h-auto min-h-10"
|
||||
>
|
||||
<Button variant="outline" role="combobox" className="justify-between h-auto min-h-10">
|
||||
{selectedAssignees.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedAssignees.map((userUuid) => {
|
||||
const user = users.find(
|
||||
(u) => u.uuid === userUuid
|
||||
);
|
||||
const user = users.find((u) => u.uuid === userUuid);
|
||||
return user ? (
|
||||
<Badge
|
||||
key={userUuid}
|
||||
variant="secondary"
|
||||
className="mr-1"
|
||||
>
|
||||
<Badge key={userUuid} variant="secondary" className="mr-1">
|
||||
{user.firstName || user.username}
|
||||
<X
|
||||
className="ml-1 h-3 w-3 cursor-pointer"
|
||||
@@ -259,9 +220,7 @@ export default function CreateCirculationPage() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
Select assignees...
|
||||
</span>
|
||||
<span className="text-muted-foreground">Select assignees...</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -281,10 +240,8 @@ export default function CreateCirculationPage() {
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedAssignees.includes(user.uuid)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
'mr-2 h-4 w-4',
|
||||
selectedAssignees.includes(user.uuid) ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{user.firstName && user.lastName
|
||||
@@ -310,11 +267,7 @@ export default function CreateCirculationPage() {
|
||||
<FormItem>
|
||||
<FormLabel>Remarks (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Additional notes..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
<Textarea placeholder="Additional notes..." className="resize-none" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -329,7 +282,7 @@ export default function CreateCirculationPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Creating..." : "Create Circulation"}
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Circulation'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CirculationList } from "@/components/circulation/circulation-list";
|
||||
import { circulationService } from "@/lib/services/circulation.service";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CirculationListResponse } from "@/types/circulation";
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CirculationList } from '@/components/circulation/circulation-list';
|
||||
import { circulationService } from '@/lib/services/circulation.service';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { CirculationListResponse } from '@/types/circulation';
|
||||
|
||||
/**
|
||||
* Circulation list page - displays circulations for the current user's organization
|
||||
*/
|
||||
export default function CirculationPage() {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<CirculationListResponse>({
|
||||
queryKey: ["circulations"],
|
||||
const { data, isLoading, error, refetch } = useQuery<CirculationListResponse>({
|
||||
queryKey: ['circulations'],
|
||||
queryFn: () => circulationService.getAll(),
|
||||
});
|
||||
|
||||
@@ -27,19 +22,11 @@ export default function CirculationPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Circulation</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage internal document circulation and assignments
|
||||
</p>
|
||||
<p className="text-muted-foreground">Manage internal document circulation and assignments</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading} title="Refresh">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Link href="/circulation/new">
|
||||
<Button>
|
||||
@@ -63,9 +50,7 @@ export default function CirculationPage() {
|
||||
) : data ? (
|
||||
<CirculationList data={data} />
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No circulations found
|
||||
</div>
|
||||
<div className="text-center py-12 text-muted-foreground">No circulations found</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { CorrespondenceDetail } from "@/components/correspondences/detail";
|
||||
import { useCorrespondence } from "@/hooks/use-correspondence";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CorrespondenceDetail } from '@/components/correspondences/detail';
|
||||
import { useCorrespondence } from '@/hooks/use-correspondence';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function CorrespondenceDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -13,26 +13,26 @@ export default function CorrespondenceDetailPage() {
|
||||
|
||||
if (!uuid) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence UUID</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence UUID</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex bg-muted/20 min-h-screen justify-center items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
<div className="flex bg-muted/20 min-h-screen justify-center items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !correspondence) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
|
||||
<p>Please try again later or verify the UUID.</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
|
||||
<p>Please try again later or verify the UUID.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CorrespondenceForm } from "@/components/correspondences/form";
|
||||
import { CorrespondenceForm } from '@/components/correspondences/form';
|
||||
|
||||
// Force dynamic rendering to prevent build-time prerendering issues
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -11,9 +11,7 @@ export default function NewCorrespondencePage() {
|
||||
<div className="max-w-4xl mx-auto py-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">New Correspondence</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Create a new official letter or communication record.
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Create a new official letter or communication record.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg p-6 shadow-sm">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { CorrespondencesContent } from "@/components/correspondences/correspondences-content";
|
||||
import { Suspense } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { CorrespondencesContent } from '@/components/correspondences/correspondences-content';
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function CorrespondencesPage() {
|
||||
return (
|
||||
@@ -12,9 +12,7 @@ export default function CorrespondencesPage() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Correspondences</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage official letters and communications
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Manage official letters and communications</p>
|
||||
</div>
|
||||
<Link href="/correspondences/new">
|
||||
<Button>
|
||||
@@ -24,7 +22,13 @@ export default function CorrespondencesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div className="flex justify-center py-8"><Loader2 className="h-8 w-8 animate-spin" /></div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CorrespondencesContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { StatsCards } from "@/components/dashboard/stats-cards";
|
||||
import { RecentActivity } from "@/components/dashboard/recent-activity";
|
||||
import { PendingTasks } from "@/components/dashboard/pending-tasks";
|
||||
import { QuickActions } from "@/components/dashboard/quick-actions";
|
||||
import { useDashboardStats, useRecentActivity, usePendingTasks } from "@/hooks/use-dashboard";
|
||||
import { StatsCards } from '@/components/dashboard/stats-cards';
|
||||
import { RecentActivity } from '@/components/dashboard/recent-activity';
|
||||
import { PendingTasks } from '@/components/dashboard/pending-tasks';
|
||||
import { QuickActions } from '@/components/dashboard/quick-actions';
|
||||
import { useDashboardStats, useRecentActivity, usePendingTasks } from '@/hooks/use-dashboard';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: stats, isLoading: statsLoading } = useDashboardStats();
|
||||
@@ -16,9 +16,7 @@ export default function DashboardPage() {
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Welcome back! Here's an overview of your project status.
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here's an overview of your project status.</p>
|
||||
</div>
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { use, useState } from "react";
|
||||
import { notFound, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ArrowLeft, Download, FileText, Loader2, Pencil, Upload, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { RevisionHistory } from "@/components/drawings/revision-history";
|
||||
import { format } from "date-fns";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { contractDrawingService } from "@/lib/services/contract-drawing.service";
|
||||
import { shopDrawingService } from "@/lib/services/shop-drawing.service";
|
||||
import { asBuiltDrawingService } from "@/lib/services/asbuilt-drawing.service";
|
||||
import { useUpdateContractDrawing, useUploadRevision } from "@/hooks/use-drawing";
|
||||
import { use, useState } from 'react';
|
||||
import { notFound, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ArrowLeft, Download, FileText, Loader2, Pencil, Upload, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { RevisionHistory } from '@/components/drawings/revision-history';
|
||||
import { format } from 'date-fns';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
|
||||
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
|
||||
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service';
|
||||
import { useUpdateContractDrawing, useUploadRevision } from '@/hooks/use-drawing';
|
||||
|
||||
type DrawingType = "CONTRACT" | "SHOP" | "AS_BUILT";
|
||||
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT';
|
||||
|
||||
interface FetchedDrawing {
|
||||
_type: DrawingType;
|
||||
@@ -31,41 +31,56 @@ interface FetchedDrawing {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
currentRevision?: { title?: string; revisionNumber?: string; legacyDrawingNumber?: string };
|
||||
revisions?: { revisionId?: number; uuid: string; revisionNumber: string; title?: string; legacyDrawingNumber?: string; revisionDate: string; revisionDescription?: string; revisedByName: string; fileUrl: string; isCurrent: boolean | null; createdBy?: number; updatedBy?: number }[];
|
||||
revisions?: {
|
||||
revisionId?: number;
|
||||
uuid: string;
|
||||
revisionNumber: string;
|
||||
title?: string;
|
||||
legacyDrawingNumber?: string;
|
||||
revisionDate: string;
|
||||
revisionDescription?: string;
|
||||
revisedByName: string;
|
||||
fileUrl: string;
|
||||
isCurrent: boolean | null;
|
||||
createdBy?: number;
|
||||
updatedBy?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function fetchDrawingByUuid(uuid: string): Promise<FetchedDrawing | null> {
|
||||
try {
|
||||
const result = await contractDrawingService.getByUuid(uuid);
|
||||
if (result?.data) return { ...result.data, _type: "CONTRACT" as const };
|
||||
} catch { /* not found */ }
|
||||
if (result?.data) return { ...result.data, _type: 'CONTRACT' as const };
|
||||
} catch {
|
||||
/* not found */
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await shopDrawingService.getByUuid(uuid);
|
||||
if (result?.data) return { ...result.data, _type: "SHOP" as const };
|
||||
} catch { /* not found */ }
|
||||
if (result?.data) return { ...result.data, _type: 'SHOP' as const };
|
||||
} catch {
|
||||
/* not found */
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await asBuiltDrawingService.getByUuid(uuid);
|
||||
if (result?.data) return { ...result.data, _type: "AS_BUILT" as const };
|
||||
} catch { /* not found */ }
|
||||
if (result?.data) return { ...result.data, _type: 'AS_BUILT' as const };
|
||||
} catch {
|
||||
/* not found */
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function DrawingDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ uuid: string }>;
|
||||
}) {
|
||||
export default function DrawingDetailPage({ params }: { params: Promise<{ uuid: string }> }) {
|
||||
const { uuid } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isEditMode = searchParams.get("edit") === "true";
|
||||
const isUploadMode = searchParams.get("upload") === "true";
|
||||
const isEditMode = searchParams.get('edit') === 'true';
|
||||
const isUploadMode = searchParams.get('upload') === 'true';
|
||||
|
||||
const { data: drawing, isLoading } = useQuery({
|
||||
queryKey: ["drawing-detail", uuid],
|
||||
queryKey: ['drawing-detail', uuid],
|
||||
queryFn: () => fetchDrawingByUuid(uuid),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
@@ -100,8 +115,8 @@ export default function DrawingDetailPage({
|
||||
);
|
||||
}
|
||||
|
||||
const drawingNumber = drawing.contractDrawingNo || drawing.drawingNumber || "N/A";
|
||||
const title = drawing.title || drawing.currentRevision?.title || "Untitled";
|
||||
const drawingNumber = drawing.contractDrawingNo || drawing.drawingNumber || 'N/A';
|
||||
const title = drawing.title || drawing.currentRevision?.title || 'Untitled';
|
||||
const revisions = drawing.revisions || [];
|
||||
|
||||
return (
|
||||
@@ -129,7 +144,7 @@ export default function DrawingDetailPage({
|
||||
Edit Detail
|
||||
</Link>
|
||||
</Button>
|
||||
{drawing._type !== "CONTRACT" && (
|
||||
{drawing._type !== 'CONTRACT' && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/drawings/${uuid}?upload=true`}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
@@ -155,12 +170,10 @@ export default function DrawingDetailPage({
|
||||
</div>
|
||||
|
||||
{/* Edit Detail Form */}
|
||||
{isEditMode && (
|
||||
<EditDetailForm drawing={drawing} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />
|
||||
)}
|
||||
{isEditMode && <EditDetailForm drawing={drawing} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />}
|
||||
|
||||
{/* Upload Revision Form */}
|
||||
{isUploadMode && drawing._type !== "CONTRACT" && (
|
||||
{isUploadMode && drawing._type !== 'CONTRACT' && (
|
||||
<UploadRevisionForm drawingType={drawing._type} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />
|
||||
)}
|
||||
|
||||
@@ -194,7 +207,7 @@ export default function DrawingDetailPage({
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Created</p>
|
||||
<p className="font-medium mt-1">
|
||||
{drawing.createdAt ? format(new Date(drawing.createdAt), "dd MMM yyyy") : "N/A"}
|
||||
{drawing.createdAt ? format(new Date(drawing.createdAt), 'dd MMM yyyy') : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,36 +237,28 @@ export default function DrawingDetailPage({
|
||||
}
|
||||
|
||||
/* ─── Edit Detail Form ─── */
|
||||
function EditDetailForm({
|
||||
drawing,
|
||||
uuid,
|
||||
onDone,
|
||||
}: {
|
||||
drawing: FetchedDrawing;
|
||||
uuid: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
function EditDetailForm({ drawing, uuid, onDone }: { drawing: FetchedDrawing; uuid: string; onDone: () => void }) {
|
||||
const updateMutation = useUpdateContractDrawing();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [formTitle, setFormTitle] = useState(drawing.title || drawing.currentRevision?.title || "");
|
||||
const [formDrawingNo, setFormDrawingNo] = useState(drawing.contractDrawingNo || drawing.drawingNumber || "");
|
||||
const [formVolumePage, setFormVolumePage] = useState(drawing.volumePage?.toString() || "");
|
||||
const [formTitle, setFormTitle] = useState(drawing.title || drawing.currentRevision?.title || '');
|
||||
const [formDrawingNo, setFormDrawingNo] = useState(drawing.contractDrawingNo || drawing.drawingNumber || '');
|
||||
const [formVolumePage, setFormVolumePage] = useState(drawing.volumePage?.toString() || '');
|
||||
|
||||
const handleSave = () => {
|
||||
if (drawing._type === "CONTRACT") {
|
||||
if (drawing._type === 'CONTRACT') {
|
||||
updateMutation.mutate(
|
||||
{
|
||||
uuid,
|
||||
data: {
|
||||
title: formTitle,
|
||||
contractDrawingNo: formDrawingNo,
|
||||
volumePage: formVolumePage ? parseInt(formVolumePage, 10) : undefined,
|
||||
volumePage: formVolumePage ? Number(formVolumePage) : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["drawing-detail", uuid] });
|
||||
queryClient.invalidateQueries({ queryKey: ['drawing-detail', uuid] });
|
||||
onDone();
|
||||
},
|
||||
}
|
||||
@@ -273,7 +278,7 @@ function EditDetailForm({
|
||||
<Label>Title</Label>
|
||||
<Input value={formTitle} onChange={(e) => setFormTitle(e.target.value)} />
|
||||
</div>
|
||||
{drawing._type === "CONTRACT" && (
|
||||
{drawing._type === 'CONTRACT' && (
|
||||
<div>
|
||||
<Label>Volume Page</Label>
|
||||
<Input type="number" value={formVolumePage} onChange={(e) => setFormVolumePage(e.target.value)} />
|
||||
@@ -306,10 +311,10 @@ function UploadRevisionForm({
|
||||
const uploadMutation = useUploadRevision(drawingType);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [revisionLabel, setRevisionLabel] = useState("");
|
||||
const [revTitle, setRevTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [legacyNo, setLegacyNo] = useState("");
|
||||
const [revisionLabel, setRevisionLabel] = useState('');
|
||||
const [revTitle, setRevTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [legacyNo, setLegacyNo] = useState('');
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!revisionLabel || !revTitle) return;
|
||||
@@ -327,7 +332,7 @@ function UploadRevisionForm({
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["drawing-detail", uuid] });
|
||||
queryClient.invalidateQueries({ queryKey: ['drawing-detail', uuid] });
|
||||
onDone();
|
||||
},
|
||||
}
|
||||
@@ -341,7 +346,11 @@ function UploadRevisionForm({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Revision Label *</Label>
|
||||
<Input placeholder="e.g. A, B, 1, 2" value={revisionLabel} onChange={(e) => setRevisionLabel(e.target.value)} />
|
||||
<Input
|
||||
placeholder="e.g. A, B, 1, 2"
|
||||
value={revisionLabel}
|
||||
onChange={(e) => setRevisionLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legacy Drawing No.</Label>
|
||||
@@ -354,7 +363,12 @@ function UploadRevisionForm({
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea placeholder="What changed in this revision?" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
<Textarea
|
||||
placeholder="What changed in this revision?"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button onClick={handleUpload} disabled={uploadMutation.isPending || !revisionLabel || !revTitle}>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { DrawingList } from "@/components/drawings/list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Upload, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useProjects } from "@/hooks/use-master-data";
|
||||
import { useState } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { DrawingList } from '@/components/drawings/list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Upload, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useProjects } from '@/hooks/use-master-data';
|
||||
|
||||
export default function DrawingsPage() {
|
||||
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string | undefined>(undefined);
|
||||
@@ -24,9 +18,7 @@ export default function DrawingsPage() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Drawings</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage contract, shop, and as-built drawings
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Manage contract, shop, and as-built drawings</p>
|
||||
</div>
|
||||
<Link href="/drawings/upload">
|
||||
<Button>
|
||||
@@ -39,10 +31,7 @@ export default function DrawingsPage() {
|
||||
{/* Project Selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium">Project:</span>
|
||||
<Select
|
||||
value={selectedProjectUuid ?? ""}
|
||||
onValueChange={(v) => setSelectedProjectUuid(v || undefined)}
|
||||
>
|
||||
<Select value={selectedProjectUuid ?? ''} onValueChange={(v) => setSelectedProjectUuid(v || undefined)}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
{isLoadingProjects ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -72,43 +61,42 @@ export default function DrawingsPage() {
|
||||
}
|
||||
|
||||
function DrawingTabs({ projectUuid }: { projectUuid: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState('');
|
||||
// We can add more specific filters here (e.g. category) later
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="contract" className="w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-[400px]">
|
||||
<TabsTrigger value="contract">Contract</TabsTrigger>
|
||||
<TabsTrigger value="shop">Shop</TabsTrigger>
|
||||
<TabsTrigger value="asbuilt">As Built</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs defaultValue="contract" className="w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-[400px]">
|
||||
<TabsTrigger value="contract">Contract</TabsTrigger>
|
||||
<TabsTrigger value="shop">Shop</TabsTrigger>
|
||||
<TabsTrigger value="asbuilt">As Built</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search drawings..."
|
||||
className="h-10 w-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search drawings..."
|
||||
className="h-10 w-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="contract" className="mt-0">
|
||||
<DrawingList type="CONTRACT" projectUuid={projectUuid} filters={{ search }} />
|
||||
</TabsContent>
|
||||
<TabsContent value="contract" className="mt-0">
|
||||
<DrawingList type="CONTRACT" projectUuid={projectUuid} filters={{ search }} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shop" className="mt-0">
|
||||
<DrawingList type="SHOP" projectUuid={projectUuid} filters={{ search }} />
|
||||
</TabsContent>
|
||||
<TabsContent value="shop" className="mt-0">
|
||||
<DrawingList type="SHOP" projectUuid={projectUuid} filters={{ search }} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="asbuilt" className="mt-0">
|
||||
<DrawingList type="AS_BUILT" projectUuid={projectUuid} filters={{ search }} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
<TabsContent value="asbuilt" className="mt-0">
|
||||
<DrawingList type="AS_BUILT" projectUuid={projectUuid} filters={{ search }} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DrawingUploadForm } from "@/components/drawings/upload-form";
|
||||
import { DrawingUploadForm } from '@/components/drawings/upload-form';
|
||||
|
||||
// Force dynamic rendering to prevent build-time prerendering issues
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -11,9 +11,7 @@ export default function DrawingUploadPage() {
|
||||
<div className="max-w-4xl mx-auto py-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Upload Drawing</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Upload a new contract or shop drawing revision.
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Upload a new contract or shop drawing revision.</p>
|
||||
</div>
|
||||
|
||||
<DrawingUploadForm />
|
||||
|
||||
@@ -5,15 +5,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error('[Dashboard Error Boundary]', error);
|
||||
// // console.error('[Dashboard Error Boundary]', error); /* TODO: Remove before prod */
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -24,9 +18,7 @@ export default function DashboardError({
|
||||
<p className="text-muted-foreground mt-1 text-sm max-w-md">
|
||||
{error.message || 'An error occurred while loading this page.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
|
||||
)}
|
||||
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={reset} variant="outline" size="sm">
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
|
||||
// Force dynamic rendering for all pages under (dashboard) route group.
|
||||
// QNAP overlayfs cannot handle the .segments/!<base64> directories
|
||||
// that Next.js 16 creates during static page generation.
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-h-screen overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6 bg-muted/10">
|
||||
{children}
|
||||
</main>
|
||||
<main className="flex-1 overflow-y-auto p-6 bg-muted/10">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
// File: app/(dashboard)/profile/page.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Loader2, User, Shield, Bell } from "lucide-react";
|
||||
import { useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Loader2, User, Shield, Bell } from 'lucide-react';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import apiClient from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Schemas
|
||||
@@ -31,13 +24,13 @@ import { toast } from "sonner";
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "กรุณาระบุรหัสผ่านปัจจุบัน"),
|
||||
newPassword: z.string().min(8, "รหัสผ่านใหม่ต้องมีอย่างน้อย 8 ตัวอักษร"),
|
||||
confirmPassword: z.string().min(1, "กรุณายืนยันรหัสผ่านใหม่"),
|
||||
currentPassword: z.string().min(1, 'กรุณาระบุรหัสผ่านปัจจุบัน'),
|
||||
newPassword: z.string().min(8, 'รหัสผ่านใหม่ต้องมีอย่างน้อย 8 ตัวอักษร'),
|
||||
confirmPassword: z.string().min(1, 'กรุณายืนยันรหัสผ่านใหม่'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "รหัสผ่านใหม่ไม่ตรงกัน",
|
||||
path: ["confirmPassword"],
|
||||
message: 'รหัสผ่านใหม่ไม่ตรงกัน',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type PasswordValues = z.infer<typeof passwordSchema>;
|
||||
@@ -60,14 +53,14 @@ export default function ProfilePage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// เรียก API เปลี่ยนรหัสผ่าน
|
||||
await apiClient.put("/users/change-password", {
|
||||
await apiClient.put('/users/change-password', {
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
|
||||
toast.success('เปลี่ยนรหัสผ่านสำเร็จ');
|
||||
reset();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast.error('ไม่สามารถเปลี่ยนรหัสผ่านได้: รหัสผ่านปัจจุบันไม่ถูกต้อง');
|
||||
// Password change failed - toast shown
|
||||
} finally {
|
||||
@@ -82,11 +75,11 @@ export default function ProfilePage() {
|
||||
const [digestMode, setDigestMode] = useState(false);
|
||||
|
||||
// Helper to get initials
|
||||
const userName = session?.user?.name || "User";
|
||||
const userName = session?.user?.name || 'User';
|
||||
const userInitials = userName
|
||||
.split(" ")
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.substring(0, 2);
|
||||
|
||||
@@ -94,9 +87,7 @@ export default function ProfilePage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile & Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
จัดการข้อมูลส่วนตัวและการตั้งค่าระบบของคุณ
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">จัดการข้อมูลส่วนตัวและการตั้งค่าระบบของคุณ</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
@@ -120,21 +111,19 @@ export default function ProfilePage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลทั่วไป</CardTitle>
|
||||
<CardDescription>
|
||||
ข้อมูลพื้นฐานของคุณที่แสดงในระบบ
|
||||
</CardDescription>
|
||||
<CardDescription>ข้อมูลพื้นฐานของคุณที่แสดงในระบบ</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={session?.user?.image || ""} />
|
||||
<AvatarImage src={session?.user?.image || ''} />
|
||||
<AvatarFallback className="text-lg">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold">{userName}</h4>
|
||||
<p className="text-sm text-muted-foreground">{session?.user?.email}</p>
|
||||
<div className="mt-2 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground hover:bg-primary/80">
|
||||
{session?.user?.role || "Member"}
|
||||
{session?.user?.role || 'Member'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,20 +131,20 @@ export default function ProfilePage() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>ชื่อจริง</Label>
|
||||
<Input defaultValue={userName.split(" ")[0]} disabled />
|
||||
<Input defaultValue={userName.split(' ')[0]} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>นามสกุล</Label>
|
||||
<Input defaultValue={userName.split(" ")[1] || ""} disabled />
|
||||
<Input defaultValue={userName.split(' ')[1] || ''} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>อีเมล</Label>
|
||||
<Input defaultValue={session?.user?.email || ""} disabled />
|
||||
<Input defaultValue={session?.user?.email || ''} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>หน่วยงาน / องค์กร</Label>
|
||||
{/* ในอนาคตดึงจาก Organization ID */}
|
||||
<Input defaultValue={`Organization ID: ${session?.user?.organizationId || "-"}`} disabled />
|
||||
<Input defaultValue={`Organization ID: ${session?.user?.organizationId || '-'}`} disabled />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -171,41 +160,25 @@ export default function ProfilePage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>รหัสผ่าน</CardTitle>
|
||||
<CardDescription>
|
||||
เปลี่ยนรหัสผ่านเพื่อความปลอดภัยของบัญชี (ต้องมีอย่างน้อย 8 ตัวอักษร)
|
||||
</CardDescription>
|
||||
<CardDescription>เปลี่ยนรหัสผ่านเพื่อความปลอดภัยของบัญชี (ต้องมีอย่างน้อย 8 ตัวอักษร)</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit(onPasswordSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">รหัสผ่านปัจจุบัน</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
/>
|
||||
<Input id="currentPassword" type="password" {...register('currentPassword')} />
|
||||
{errors.currentPassword && (
|
||||
<p className="text-xs text-destructive">{errors.currentPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">รหัสผ่านใหม่</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
{...register("newPassword")}
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<p className="text-xs text-destructive">{errors.newPassword.message}</p>
|
||||
)}
|
||||
<Input id="newPassword" type="password" {...register('newPassword')} />
|
||||
{errors.newPassword && <p className="text-xs text-destructive">{errors.newPassword.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">ยืนยันรหัสผ่านใหม่</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<Input id="confirmPassword" type="password" {...register('confirmPassword')} />
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-xs text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
@@ -226,9 +199,7 @@ export default function ProfilePage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>การแจ้งเตือน</CardTitle>
|
||||
<CardDescription>
|
||||
กำหนดช่องทางที่คุณต้องการรับข้อมูลข่าวสารจากระบบ
|
||||
</CardDescription>
|
||||
<CardDescription>กำหนดช่องทางที่คุณต้องการรับข้อมูลข่าวสารจากระบบ</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
@@ -238,11 +209,7 @@ export default function ProfilePage() {
|
||||
รับแจ้งเตือนงานใหม่และการอนุมัติผ่านทางอีเมล
|
||||
</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="notify-email"
|
||||
checked={notifyEmail}
|
||||
onCheckedChange={setNotifyEmail}
|
||||
/>
|
||||
<Switch id="notify-email" checked={notifyEmail} onCheckedChange={setNotifyEmail} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
@@ -252,11 +219,7 @@ export default function ProfilePage() {
|
||||
รับแจ้งเตือนด่วนผ่าน LINE Official Account
|
||||
</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="notify-line"
|
||||
checked={notifyLine}
|
||||
onCheckedChange={setNotifyLine}
|
||||
/>
|
||||
<Switch id="notify-line" checked={notifyLine} onCheckedChange={setNotifyLine} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
@@ -266,11 +229,7 @@ export default function ProfilePage() {
|
||||
รับสรุปแจ้งเตือนวันละครั้ง แทนการแจ้งเตือนทันที (ลด Spam)
|
||||
</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="digest-mode"
|
||||
checked={digestMode}
|
||||
onCheckedChange={setDigestMode}
|
||||
/>
|
||||
<Switch id="digest-mode" checked={digestMode} onCheckedChange={setDigestMode} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
// File: app/(dashboard)/projects/new/page.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Loader2, ChevronLeft, Save } from "lucide-react";
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Loader2, ChevronLeft, Save } from 'lucide-react';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Force dynamic rendering to prevent build-time prerendering issues
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -40,15 +27,12 @@ export const fetchCache = 'force-no-store';
|
||||
const projectSchema = z.object({
|
||||
projectCode: z
|
||||
.string()
|
||||
.min(1, "กรุณาระบุรหัสโครงการ")
|
||||
.max(50, "รหัสโครงการต้องไม่เกิน 50 ตัวอักษร")
|
||||
.regex(/^[A-Z0-9-]+$/, "รหัสโครงการควรประกอบด้วยตัวอักษรภาษาอังกฤษตัวใหญ่ ตัวเลข หรือขีด (-) เท่านั้น"),
|
||||
projectName: z
|
||||
.string()
|
||||
.min(1, "กรุณาระบุชื่อโครงการ")
|
||||
.max(255, "ชื่อโครงการต้องไม่เกิน 255 ตัวอักษร"),
|
||||
.min(1, 'กรุณาระบุรหัสโครงการ')
|
||||
.max(50, 'รหัสโครงการต้องไม่เกิน 50 ตัวอักษร')
|
||||
.regex(/^[A-Z0-9-]+$/, 'รหัสโครงการควรประกอบด้วยตัวอักษรภาษาอังกฤษตัวใหญ่ ตัวเลข หรือขีด (-) เท่านั้น'),
|
||||
projectName: z.string().min(1, 'กรุณาระบุชื่อโครงการ').max(255, 'ชื่อโครงการต้องไม่เกิน 255 ตัวอักษร'),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["Active", "Inactive", "On Hold"]),
|
||||
status: z.enum(['Active', 'Inactive', 'On Hold']),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
});
|
||||
@@ -68,14 +52,14 @@ export default function CreateProjectPage() {
|
||||
} = useForm<ProjectValues>({
|
||||
resolver: zodResolver(projectSchema),
|
||||
defaultValues: {
|
||||
projectCode: "",
|
||||
projectName: "",
|
||||
status: "Active",
|
||||
projectCode: '',
|
||||
projectName: '',
|
||||
status: 'Active',
|
||||
},
|
||||
});
|
||||
|
||||
// 3. ฟังก์ชัน Submit
|
||||
async function onSubmit(data: ProjectValues) {
|
||||
async function onSubmit(_data: ProjectValues) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// เรียก API สร้างโครงการ (Mockup URL)
|
||||
@@ -89,7 +73,7 @@ export default function CreateProjectPage() {
|
||||
toast.success('สร้างโครงการสำเร็จ');
|
||||
router.push('/projects');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast.error('เกิดข้อผิดพลาดในการสร้างโครงการ');
|
||||
// Project creation failed - toast shown
|
||||
} finally {
|
||||
@@ -101,20 +85,13 @@ export default function CreateProjectPage() {
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header with Back Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Button variant="outline" size="icon" onClick={() => router.back()} className="h-9 w-9">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="sr-only">Back</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Create New Project</h2>
|
||||
<p className="text-muted-foreground">
|
||||
สร้างโครงการใหม่เพื่อเริ่มบริหารจัดการสัญญาและเอกสาร
|
||||
</p>
|
||||
<p className="text-muted-foreground">สร้างโครงการใหม่เพื่อเริ่มบริหารจัดการสัญญาและเอกสาร</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,9 +99,7 @@ export default function CreateProjectPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลโครงการ</CardTitle>
|
||||
<CardDescription>
|
||||
กรอกรายละเอียดสำคัญของโครงการ รหัสโครงการควรไม่ซ้ำกับที่มีอยู่
|
||||
</CardDescription>
|
||||
<CardDescription>กรอกรายละเอียดสำคัญของโครงการ รหัสโครงการควรไม่ซ้ำกับที่มีอยู่</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
@@ -136,19 +111,17 @@ export default function CreateProjectPage() {
|
||||
<Input
|
||||
id="project_code"
|
||||
placeholder="e.g. LCBP3-C1"
|
||||
className={errors.projectCode ? "border-destructive" : ""}
|
||||
{...register("projectCode")}
|
||||
className={errors.projectCode ? 'border-destructive' : ''}
|
||||
{...register('projectCode')}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.toUpperCase();
|
||||
register("projectCode").onChange(e);
|
||||
register('projectCode').onChange(e);
|
||||
}}
|
||||
/>
|
||||
{errors.projectCode ? (
|
||||
<p className="text-xs text-destructive">{errors.projectCode.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ใช้ภาษาอังกฤษตัวพิมพ์ใหญ่ ตัวเลข และขีด (-) เท่านั้น
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">ใช้ภาษาอังกฤษตัวพิมพ์ใหญ่ ตัวเลข และขีด (-) เท่านั้น</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -160,12 +133,10 @@ export default function CreateProjectPage() {
|
||||
<Input
|
||||
id="project_name"
|
||||
placeholder="ระบุชื่อโครงการฉบับเต็ม..."
|
||||
className={errors.projectName ? "border-destructive" : ""}
|
||||
{...register("projectName")}
|
||||
className={errors.projectName ? 'border-destructive' : ''}
|
||||
{...register('projectName')}
|
||||
/>
|
||||
{errors.projectName && (
|
||||
<p className="text-xs text-destructive">{errors.projectName.message}</p>
|
||||
)}
|
||||
{errors.projectName && <p className="text-xs text-destructive">{errors.projectName.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
@@ -175,7 +146,7 @@ export default function CreateProjectPage() {
|
||||
id="description"
|
||||
placeholder="คำอธิบายเกี่ยวกับขอบเขตงานของโครงการ..."
|
||||
className="min-h-[100px]"
|
||||
{...register("description")}
|
||||
{...register('description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -183,19 +154,11 @@ export default function CreateProjectPage() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start_date">วันที่เริ่มต้นสัญญา</Label>
|
||||
<Input
|
||||
id="start_date"
|
||||
type="date"
|
||||
{...register("startDate")}
|
||||
/>
|
||||
<Input id="start_date" type="date" {...register('startDate')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="end_date">วันที่สิ้นสุดสัญญา</Label>
|
||||
<Input
|
||||
id="end_date"
|
||||
type="date"
|
||||
{...register("endDate")}
|
||||
/>
|
||||
<Input id="end_date" type="date" {...register('endDate')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,12 +185,7 @@ export default function CreateProjectPage() {
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-end gap-2 border-t p-4 bg-muted/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Button type="button" variant="ghost" onClick={() => router.back()} disabled={isLoading}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
// File: app/(dashboard)/projects/page.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -24,22 +17,15 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
|
||||
// Type สำหรับข้อมูล Project (Mockup ตาม Data Dictionary)
|
||||
interface Project {
|
||||
id: number;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
status: "Active" | "Completed" | "On Hold";
|
||||
status: 'Active' | 'Completed' | 'On Hold';
|
||||
progress: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
@@ -50,56 +36,61 @@ interface Project {
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
projectCode: "LCBP3",
|
||||
projectName: "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)",
|
||||
status: "Active",
|
||||
projectCode: 'LCBP3',
|
||||
projectName: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)',
|
||||
status: 'Active',
|
||||
progress: 45,
|
||||
startDate: "2021-01-01",
|
||||
endDate: "2025-12-31",
|
||||
contractorName: "Multiple Contractors",
|
||||
startDate: '2021-01-01',
|
||||
endDate: '2025-12-31',
|
||||
contractorName: 'Multiple Contractors',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
projectCode: "LCBP3-C1",
|
||||
projectName: "งานก่อสร้างงานทางทะเล (ส่วนที่ 1)",
|
||||
status: "Active",
|
||||
projectCode: 'LCBP3-C1',
|
||||
projectName: 'งานก่อสร้างงานทางทะเล (ส่วนที่ 1)',
|
||||
status: 'Active',
|
||||
progress: 70,
|
||||
startDate: "2021-06-01",
|
||||
endDate: "2024-06-01",
|
||||
contractorName: "CNNC",
|
||||
startDate: '2021-06-01',
|
||||
endDate: '2024-06-01',
|
||||
contractorName: 'CNNC',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
projectCode: "LCBP3-C2",
|
||||
projectName: "งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)",
|
||||
status: "Active",
|
||||
projectCode: 'LCBP3-C2',
|
||||
projectName: 'งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)',
|
||||
status: 'Active',
|
||||
progress: 15,
|
||||
startDate: "2023-01-01",
|
||||
endDate: "2026-01-01",
|
||||
contractorName: "ITD-NWR Joint Venture",
|
||||
startDate: '2023-01-01',
|
||||
endDate: '2026-01-01',
|
||||
contractorName: 'ITD-NWR Joint Venture',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredProjects = mockProjects.filter((project) =>
|
||||
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const filteredProjects = mockProjects.filter(
|
||||
(project) =>
|
||||
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active": return "success"; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
|
||||
case "Completed": return "default";
|
||||
case "On Hold": return "warning";
|
||||
default: return "secondary";
|
||||
case 'Active':
|
||||
return 'success'; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
|
||||
case 'Completed':
|
||||
return 'default';
|
||||
case 'On Hold':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = () => {
|
||||
router.push("/projects/new"); // อัปเดตเป็นลิงก์จริง
|
||||
router.push('/projects/new'); // อัปเดตเป็นลิงก์จริง
|
||||
};
|
||||
|
||||
const handleViewDetails = (id: number) => {
|
||||
@@ -112,9 +103,7 @@ export default function ProjectsPage() {
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Projects</h2>
|
||||
<p className="text-muted-foreground">
|
||||
จัดการโครงการ สัญญา และความคืบหน้า
|
||||
</p>
|
||||
<p className="text-muted-foreground">จัดการโครงการ สัญญา และความคืบหน้า</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateProject} className="w-full md:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" /> New Project
|
||||
@@ -149,7 +138,11 @@ export default function ProjectsPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProjects.map((project) => (
|
||||
<TableRow key={project.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleViewDetails(project.id)}>
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewDetails(project.id)}
|
||||
>
|
||||
<TableCell className="font-medium">{project.projectCode}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
@@ -161,16 +154,12 @@ export default function ProjectsPage() {
|
||||
</TableCell>
|
||||
<TableCell>{project.contractorName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(project.status)}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
<Badge variant={getStatusVariant(project.status)}>{project.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
<span className="text-xs text-muted-foreground w-[30px] text-right">
|
||||
{project.progress}%
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-[30px] text-right">{project.progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -183,14 +172,29 @@ export default function ProjectsPage() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(project.id); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetails(project.id);
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Manage Contracts for ${project.projectCode}`); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alert(`Manage Contracts for ${project.projectCode}`);
|
||||
}}
|
||||
>
|
||||
Manage Contracts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Edit ${project.projectCode}`); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alert(`Edit ${project.projectCode}`);
|
||||
}}
|
||||
>
|
||||
Edit Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -205,14 +209,16 @@ export default function ProjectsPage() {
|
||||
{/* Mobile View: Cards */}
|
||||
<div className="grid gap-4 md:hidden">
|
||||
{filteredProjects.map((project) => (
|
||||
<Card key={project.id} onClick={() => handleViewDetails(project.id)} className="cursor-pointer active:bg-muted/50">
|
||||
<Card
|
||||
key={project.id}
|
||||
onClick={() => handleViewDetails(project.id)}
|
||||
className="cursor-pointer active:bg-muted/50"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold">{project.projectCode}</CardTitle>
|
||||
<CardDescription className="mt-1 line-clamp-2">
|
||||
{project.projectName}
|
||||
</CardDescription>
|
||||
<CardDescription className="mt-1 line-clamp-2">{project.projectName}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(project.status)} className="shrink-0">
|
||||
{project.status}
|
||||
@@ -226,7 +232,9 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{project.startDate} - {project.endDate}</span>
|
||||
<span>
|
||||
{project.startDate} - {project.endDate}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
// File: app/(dashboard)/projects/page.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -24,22 +17,15 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
|
||||
// Type สำหรับข้อมูล Project (Mockup ตาม Data Dictionary)
|
||||
interface Project {
|
||||
id: number;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
status: "Active" | "Completed" | "On Hold";
|
||||
status: 'Active' | 'Completed' | 'On Hold';
|
||||
progress: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
@@ -50,56 +36,61 @@ interface Project {
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
projectCode: "LCBP3",
|
||||
projectName: "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)",
|
||||
status: "Active",
|
||||
projectCode: 'LCBP3',
|
||||
projectName: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)',
|
||||
status: 'Active',
|
||||
progress: 45,
|
||||
startDate: "2021-01-01",
|
||||
endDate: "2025-12-31",
|
||||
contractorName: "Multiple Contractors",
|
||||
startDate: '2021-01-01',
|
||||
endDate: '2025-12-31',
|
||||
contractorName: 'Multiple Contractors',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
projectCode: "LCBP3-C1",
|
||||
projectName: "งานก่อสร้างงานทางทะเล (ส่วนที่ 1)",
|
||||
status: "Active",
|
||||
projectCode: 'LCBP3-C1',
|
||||
projectName: 'งานก่อสร้างงานทางทะเล (ส่วนที่ 1)',
|
||||
status: 'Active',
|
||||
progress: 70,
|
||||
startDate: "2021-06-01",
|
||||
endDate: "2024-06-01",
|
||||
contractorName: "CNNC",
|
||||
startDate: '2021-06-01',
|
||||
endDate: '2024-06-01',
|
||||
contractorName: 'CNNC',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
projectCode: "LCBP3-C2",
|
||||
projectName: "งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)",
|
||||
status: "Active",
|
||||
projectCode: 'LCBP3-C2',
|
||||
projectName: 'งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)',
|
||||
status: 'Active',
|
||||
progress: 15,
|
||||
startDate: "2023-01-01",
|
||||
endDate: "2026-01-01",
|
||||
contractorName: "ITD-NWR Joint Venture",
|
||||
startDate: '2023-01-01',
|
||||
endDate: '2026-01-01',
|
||||
contractorName: 'ITD-NWR Joint Venture',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredProjects = mockProjects.filter((project) =>
|
||||
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const filteredProjects = mockProjects.filter(
|
||||
(project) =>
|
||||
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active": return "success"; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
|
||||
case "Completed": return "default";
|
||||
case "On Hold": return "warning";
|
||||
default: return "secondary";
|
||||
case 'Active':
|
||||
return 'success'; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
|
||||
case 'Completed':
|
||||
return 'default';
|
||||
case 'On Hold':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = () => {
|
||||
router.push("/projects/new"); // อัปเดตเป็นลิงก์จริง
|
||||
router.push('/projects/new'); // อัปเดตเป็นลิงก์จริง
|
||||
};
|
||||
|
||||
const handleViewDetails = (id: number) => {
|
||||
@@ -112,9 +103,7 @@ export default function ProjectsPage() {
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Projects</h2>
|
||||
<p className="text-muted-foreground">
|
||||
จัดการโครงการ สัญญา และความคืบหน้า
|
||||
</p>
|
||||
<p className="text-muted-foreground">จัดการโครงการ สัญญา และความคืบหน้า</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateProject} className="w-full md:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" /> New Project
|
||||
@@ -149,7 +138,11 @@ export default function ProjectsPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProjects.map((project) => (
|
||||
<TableRow key={project.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleViewDetails(project.id)}>
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewDetails(project.id)}
|
||||
>
|
||||
<TableCell className="font-medium">{project.projectCode}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
@@ -161,16 +154,12 @@ export default function ProjectsPage() {
|
||||
</TableCell>
|
||||
<TableCell>{project.contractorName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(project.status)}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
<Badge variant={getStatusVariant(project.status)}>{project.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
<span className="text-xs text-muted-foreground w-[30px] text-right">
|
||||
{project.progress}%
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-[30px] text-right">{project.progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -183,14 +172,29 @@ export default function ProjectsPage() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(project.id); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetails(project.id);
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Manage Contracts for ${project.projectCode}`); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alert(`Manage Contracts for ${project.projectCode}`);
|
||||
}}
|
||||
>
|
||||
Manage Contracts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Edit ${project.projectCode}`); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alert(`Edit ${project.projectCode}`);
|
||||
}}
|
||||
>
|
||||
Edit Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -205,14 +209,16 @@ export default function ProjectsPage() {
|
||||
{/* Mobile View: Cards */}
|
||||
<div className="grid gap-4 md:hidden">
|
||||
{filteredProjects.map((project) => (
|
||||
<Card key={project.id} onClick={() => handleViewDetails(project.id)} className="cursor-pointer active:bg-muted/50">
|
||||
<Card
|
||||
key={project.id}
|
||||
onClick={() => handleViewDetails(project.id)}
|
||||
className="cursor-pointer active:bg-muted/50"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold">{project.projectCode}</CardTitle>
|
||||
<CardDescription className="mt-1 line-clamp-2">
|
||||
{project.projectName}
|
||||
</CardDescription>
|
||||
<CardDescription className="mt-1 line-clamp-2">{project.projectName}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(project.status)} className="shrink-0">
|
||||
{project.status}
|
||||
@@ -226,7 +232,9 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{project.startDate} - {project.endDate}</span>
|
||||
<span>
|
||||
{project.startDate} - {project.endDate}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { RFADetail } from "@/components/rfas/detail";
|
||||
import { notFound, useParams } from "next/navigation";
|
||||
import { useRFA } from "@/hooks/use-rfa";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { RFADetail } from '@/components/rfas/detail';
|
||||
import { notFound, useParams } from 'next/navigation';
|
||||
import { useRFA } from '@/hooks/use-rfa';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function RFADetailPage() {
|
||||
const { uuid } = useParams();
|
||||
@@ -22,11 +22,7 @@ export default function RFADetailPage() {
|
||||
|
||||
if (isError || !rfa) {
|
||||
// Check if error is 404
|
||||
return (
|
||||
<div className="text-center py-20 text-red-500">
|
||||
RFA not found or failed to load.
|
||||
</div>
|
||||
);
|
||||
return <div className="text-center py-20 text-red-500">RFA not found or failed to load.</div>;
|
||||
}
|
||||
|
||||
return <RFADetail data={rfa} />;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RFAForm } from "@/components/rfas/form";
|
||||
import { RFAForm } from '@/components/rfas/form';
|
||||
|
||||
// Force dynamic rendering to prevent build-time prerendering issues
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -11,9 +11,7 @@ export default function NewRFAPage() {
|
||||
<div className="max-w-4xl mx-auto py-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">New RFA</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Create a new Request for Approval.
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Create a new Request for Approval.</p>
|
||||
</div>
|
||||
|
||||
<RFAForm />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { RFAList } from '@/components/rfas/list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -11,8 +11,8 @@ import { Suspense } from 'react';
|
||||
|
||||
function RFAsContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const statusId = searchParams.get('status') ? parseInt(searchParams.get('status')!) : undefined;
|
||||
const page = Number(searchParams.get('page') || '1');
|
||||
const statusId = searchParams.get('status') ? Number(searchParams.get('status')!) : undefined;
|
||||
const search = searchParams.get('search') || undefined;
|
||||
const projectId = searchParams.get('projectId') || undefined; // ADR-019: Pass UUID string directly
|
||||
|
||||
@@ -20,24 +20,21 @@ function RFAsContent() {
|
||||
|
||||
const { data, isLoading, isError } = useRFAs({ page, statusId, search, projectId, revisionStatus });
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex gap-2">
|
||||
{/* Simple Filter Buttons using standard Buttons for now, or use a Select if imported */}
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-md">
|
||||
{['ALL', 'CURRENT', 'OLD'].map((status) => (
|
||||
<Link key={status} href={`?${new URLSearchParams({...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1'}).toString()}`}>
|
||||
<Button
|
||||
variant={revisionStatus === status ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="text-xs px-3"
|
||||
>
|
||||
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
{['ALL', 'CURRENT', 'OLD'].map((status) => (
|
||||
<Link
|
||||
key={status}
|
||||
href={`?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1' }).toString()}`}
|
||||
>
|
||||
<Button variant={revisionStatus === status ? 'default' : 'ghost'} size="sm" className="text-xs px-3">
|
||||
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,13 +43,11 @@ function RFAsContent() {
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-red-500 text-center py-8">
|
||||
Failed to load RFAs.
|
||||
</div>
|
||||
<div className="text-red-500 text-center py-8">Failed to load RFAs.</div>
|
||||
) : (
|
||||
<>
|
||||
<RFAList data={data?.data || []} />
|
||||
<div className="mt-4">
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={data?.meta?.page || 1}
|
||||
totalPages={data?.meta?.totalPages || 1}
|
||||
@@ -71,9 +66,7 @@ export default function RFAsPage() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">RFAs (Request for Approval)</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage approval requests and submissions
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Manage approval requests and submissions</p>
|
||||
</div>
|
||||
<Link href="/rfas/new">
|
||||
<Button>
|
||||
@@ -83,11 +76,13 @@ export default function RFAsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RFAsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { SearchFilters } from "@/components/search/filters";
|
||||
import { SearchResults } from "@/components/search/results";
|
||||
import { SearchFilters as FilterType } from "@/types/search";
|
||||
import { useSearch } from "@/hooks/use-search";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { SearchFilters } from '@/components/search/filters';
|
||||
import { SearchResults } from '@/components/search/results';
|
||||
import { SearchFilters as FilterType } from '@/types/search';
|
||||
import { useSearch } from '@/hooks/use-search';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
function SearchContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL Params state
|
||||
const query = searchParams.get("q") || "";
|
||||
const typeParam = searchParams.get("type");
|
||||
const statusParam = searchParams.get("status");
|
||||
const query = searchParams.get('q') || '';
|
||||
const typeParam = searchParams.get('type');
|
||||
const statusParam = searchParams.get('status');
|
||||
|
||||
// Local Filter State (synced with URL initially, but can be independent before apply)
|
||||
// For simplicity, we'll keep filters in sync with valid search params or local state that pushes to URL
|
||||
@@ -43,9 +43,8 @@ function SearchContent() {
|
||||
<h1 className="text-3xl font-bold">Search Results</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{isLoading
|
||||
? "Searching..."
|
||||
: `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"`
|
||||
}
|
||||
? 'Searching...'
|
||||
: `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,11 +68,13 @@ function SearchContent() {
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Suspense fallback={
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { transmittalService } from "@/lib/services/transmittal.service";
|
||||
import { Transmittal } from "@/types/transmittal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ArrowLeft, RefreshCw, Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { transmittalService } from '@/lib/services/transmittal.service';
|
||||
import { Transmittal } from '@/types/transmittal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { ArrowLeft, RefreshCw, Printer } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function TransmittalDetailPage() {
|
||||
const params = useParams();
|
||||
const uuid = params.uuid as string;
|
||||
|
||||
const { data: transmittal, isLoading, error } = useQuery<Transmittal>({
|
||||
queryKey: ["transmittal", uuid],
|
||||
const {
|
||||
data: transmittal,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<Transmittal>({
|
||||
queryKey: ['transmittal', uuid],
|
||||
queryFn: () => transmittalService.getByUuid(uuid),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const handlePrint = () => {
|
||||
toast.info("PDF Export is coming soon...");
|
||||
toast.info('PDF Export is coming soon...');
|
||||
// TODO: Implement PDF download
|
||||
};
|
||||
|
||||
@@ -74,7 +71,7 @@ export default function TransmittalDetailPage() {
|
||||
{transmittal.correspondence?.correspondenceNumber || transmittal.transmittalNo}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{transmittal.correspondence?.revisions?.find(r => r.isCurrent)?.title || transmittal.subject}
|
||||
{transmittal.correspondence?.revisions?.find((r) => r.isCurrent)?.title || transmittal.subject}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,12 +89,12 @@ export default function TransmittalDetailPage() {
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Purpose</p>
|
||||
<Badge variant="outline">{transmittal.purpose || "OTHER"}</Badge>
|
||||
<Badge variant="outline">{transmittal.purpose || 'OTHER'}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Date</p>
|
||||
<p className="font-medium">
|
||||
{format(new Date(transmittal.correspondence?.createdAt || transmittal.createdAt), "dd MMM yyyy")}
|
||||
{format(new Date(transmittal.correspondence?.createdAt || transmittal.createdAt), 'dd MMM yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -116,9 +113,7 @@ export default function TransmittalDetailPage() {
|
||||
{transmittal.remarks && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm text-muted-foreground">Remarks</p>
|
||||
<p className="font-medium whitespace-pre-wrap">
|
||||
{transmittal.remarks}
|
||||
</p>
|
||||
<p className="font-medium whitespace-pre-wrap">{transmittal.remarks}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -144,10 +139,8 @@ export default function TransmittalDetailPage() {
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{item.itemType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{item.documentNumber || `ID: ${item.itemId}`}
|
||||
</TableCell>
|
||||
<TableCell>{item.description || "-"}</TableCell>
|
||||
<TableCell className="font-medium">{item.documentNumber || `ID: ${item.itemId}`}</TableCell>
|
||||
<TableCell>{item.description || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(!transmittal.items || transmittal.items.length === 0) && (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TransmittalForm } from "@/components/transmittal/transmittal-form";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { TransmittalForm } from '@/components/transmittal/transmittal-form';
|
||||
|
||||
// Force dynamic rendering to prevent build-time prerendering issues
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -23,9 +23,7 @@ export default function CreateTransmittalPage() {
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create Transmittal</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Prepare a new document transmittal slip
|
||||
</p>
|
||||
<p className="text-muted-foreground">Prepare a new document transmittal slip</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,39 +1,28 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { TransmittalList } from "@/components/transmittal/transmittal-list";
|
||||
import { transmittalService } from "@/lib/services/transmittal.service";
|
||||
import { projectService } from "@/lib/services/project.service";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TransmittalListResponse } from "@/types/transmittal";
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { TransmittalList } from '@/components/transmittal/transmittal-list';
|
||||
import { transmittalService } from '@/lib/services/transmittal.service';
|
||||
import { projectService } from '@/lib/services/project.service';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { TransmittalListResponse } from '@/types/transmittal';
|
||||
|
||||
export default function TransmittalPage() {
|
||||
// ADR-019: Dynamic project selection via UUID
|
||||
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>("");
|
||||
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>('');
|
||||
|
||||
const { data: projectsData } = useQuery({
|
||||
queryKey: ["projects-for-transmittals"],
|
||||
queryKey: ['projects-for-transmittals'],
|
||||
queryFn: () => projectService.getAll(),
|
||||
});
|
||||
const projects = projectsData?.data || projectsData || [];
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<TransmittalListResponse>({
|
||||
queryKey: ["transmittals", selectedProjectUuid],
|
||||
const { data, isLoading, error, refetch } = useQuery<TransmittalListResponse>({
|
||||
queryKey: ['transmittals', selectedProjectUuid],
|
||||
queryFn: () => transmittalService.getAll({ projectId: selectedProjectUuid }),
|
||||
enabled: !!selectedProjectUuid,
|
||||
});
|
||||
@@ -43,19 +32,11 @@ export default function TransmittalPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Transmittals</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage document transmittal slips
|
||||
</p>
|
||||
<p className="text-muted-foreground">Manage document transmittal slips</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading} title="Refresh">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Link href="/transmittals/new">
|
||||
<Button>
|
||||
@@ -74,19 +55,19 @@ export default function TransmittalPage() {
|
||||
<SelectValue placeholder="Select a project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Array.isArray(projects) ? projects : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
|
||||
<SelectItem key={p.uuid} value={p.uuid}>
|
||||
{p.projectName || p.projectCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(Array.isArray(projects) ? projects : []).map(
|
||||
(p: { uuid: string; projectName?: string; projectCode?: string }) => (
|
||||
<SelectItem key={p.uuid} value={p.uuid}>
|
||||
{p.projectName || p.projectCode}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
|
||||
Failed to load transmittals.
|
||||
</div>
|
||||
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">Failed to load transmittals.</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// File: app/api/auth/[...nextauth]/route.ts
|
||||
import { GET, POST } from "@/lib/auth";
|
||||
import { GET, POST } from '@/lib/auth';
|
||||
|
||||
export { GET, POST };
|
||||
export { GET, POST };
|
||||
|
||||
+3
-11
@@ -4,15 +4,9 @@ import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error('[App Error Boundary]', error);
|
||||
// // console.error('[App Error Boundary]', error); /* TODO: Remove before prod */
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -23,9 +17,7 @@ export default function Error({
|
||||
<p className="text-muted-foreground mt-1 text-sm max-w-md">
|
||||
{error.message || 'An unexpected error occurred. Please try again.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
|
||||
)}
|
||||
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
|
||||
</div>
|
||||
<Button onClick={reset} variant="outline">
|
||||
Try again
|
||||
|
||||
@@ -4,15 +4,9 @@ import { useEffect } from 'react';
|
||||
|
||||
// global-error.tsx catches errors in the root layout.tsx itself.
|
||||
// It MUST include its own <html> and <body> tags per Next.js spec.
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error('[Global Error Boundary]', error);
|
||||
// // console.error('[Global Error Boundary]', error); /* TODO: Remove before prod */
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -30,17 +24,11 @@ export default function GlobalError({
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
|
||||
Application Error
|
||||
</h2>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>Application Error</h2>
|
||||
<p style={{ color: '#6b7280', fontSize: '0.875rem', maxWidth: '400px' }}>
|
||||
{error.message || 'A critical error occurred. Please refresh the page.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
{error.digest && <p style={{ fontSize: '0.75rem', color: '#9ca3af' }}>Error ID: {error.digest}</p>}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -66,8 +64,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
|
||||
+14
-17
@@ -1,17 +1,17 @@
|
||||
// File: app/layout.tsx
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import QueryProvider from "@/providers/query-provider";
|
||||
import SessionProvider from "@/providers/session-provider"; // ✅ Import เข้ามา
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import QueryProvider from '@/providers/query-provider';
|
||||
import SessionProvider from '@/providers/session-provider'; // ✅ Import เข้ามา
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "LCBP3-DMS",
|
||||
description: "Document Management System for Laem Chabang Port Phase 3",
|
||||
title: 'LCBP3-DMS',
|
||||
description: 'Document Management System for Laem Chabang Port Phase 3',
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
@@ -22,13 +22,10 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head />
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
inter.className
|
||||
)}
|
||||
>
|
||||
<SessionProvider> {/* ✅ หุ้มด้วย SessionProvider เป็นชั้นนอกสุด หรือใน body */}
|
||||
<body className={cn('min-h-screen bg-background font-sans antialiased', inter.className)}>
|
||||
<SessionProvider>
|
||||
{' '}
|
||||
{/* ✅ หุ้มด้วย SessionProvider เป็นชั้นนอกสุด หรือใน body */}
|
||||
<QueryProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// File: app/page.tsx
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function RootPage() {
|
||||
// เมื่อเข้าหน้าแรก ให้ Redirect ไปที่ /dashboard ทันที
|
||||
// ซึ่งถ้ายังไม่ Login -> Middleware จะดีดไปหน้า /login ให้เอง
|
||||
redirect("/dashboard");
|
||||
}
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user