251211:1622 Frontend: refactor Dashboard (not finish)
This commit is contained in:
@@ -25,6 +25,17 @@ import {
|
||||
import { Plus, Pencil, Trash2, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface FieldConfig {
|
||||
name: string;
|
||||
@@ -66,6 +77,10 @@ export function GenericCrudTable({
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
|
||||
// Delete Dialog State
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: fetchFn,
|
||||
@@ -96,6 +111,8 @@ export function GenericCrudTable({
|
||||
onSuccess: () => {
|
||||
toast.success(`${entityName} deleted successfully`);
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
setDeleteDialogOpen(false);
|
||||
setItemToDelete(null);
|
||||
},
|
||||
onError: () => toast.error(`Failed to delete ${entityName}`),
|
||||
});
|
||||
@@ -112,9 +129,14 @@ export function GenericCrudTable({
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(`Are you sure you want to delete this ${entityName}?`)) {
|
||||
deleteMutation.mutate(id);
|
||||
const handleDeleteClick = (id: number) => {
|
||||
setItemToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (itemToDelete) {
|
||||
deleteMutation.mutate(itemToDelete);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +178,7 @@ export function GenericCrudTable({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => handleDelete(row.original.id)}
|
||||
onClick={() => handleDeleteClick(row.original.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -191,7 +213,17 @@ export function GenericCrudTable({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable columns={tableColumns} data={data || []} />
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={tableColumns} data={data || []} />
|
||||
)}
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
@@ -270,6 +302,26 @@ export function GenericCrudTable({
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the {entityName.toLowerCase()} and remove it from the system.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Pagination } from "@/components/common/pagination";
|
||||
import { useCorrespondences } from "@/hooks/use-correspondence";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export function CorrespondencesContent() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -12,10 +14,13 @@ export function CorrespondencesContent() {
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const search = searchParams.get("search") || undefined;
|
||||
|
||||
const revisionStatus = (searchParams.get('revisionStatus') as 'CURRENT' | 'ALL' | 'OLD') || 'CURRENT';
|
||||
|
||||
const { data, isLoading, isError } = useCorrespondences({
|
||||
page,
|
||||
status,
|
||||
search,
|
||||
revisionStatus,
|
||||
} as any);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -36,12 +41,27 @@ export function CorrespondencesContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CorrespondenceList data={data} />
|
||||
<div className="mb-4 flex gap-2">
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-md">
|
||||
{['ALL', 'CURRENT', 'OLD'].map((status) => (
|
||||
<Link key={status} href={`?${new URLSearchParams({...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1'}).toString()}`}>
|
||||
<Button
|
||||
variant={revisionStatus === status ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="text-xs px-3"
|
||||
>
|
||||
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CorrespondenceList data={data?.data || []} />
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={data?.page || 1}
|
||||
totalPages={data?.totalPages || 1}
|
||||
total={data?.total || 0}
|
||||
currentPage={data?.meta?.page || 1}
|
||||
totalPages={data?.meta?.totalPages || 1}
|
||||
total={data?.meta?.total || 0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Correspondence, Attachment } from "@/types/correspondence";
|
||||
import { Correspondence } from "@/types/correspondence";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle, Edit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSubmitCorrespondence, useProcessWorkflow } from "@/hooks/use-correspondence";
|
||||
import { useState } from "react";
|
||||
@@ -22,11 +22,24 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
|
||||
const [comments, setComments] = useState("");
|
||||
|
||||
if (!data) return <div>No data found</div>;
|
||||
|
||||
console.log("Correspondence Detail Data:", data);
|
||||
|
||||
// Derive Current Revision Data
|
||||
const currentRevision = data.revisions?.find(r => r.isCurrent) || data.revisions?.[0];
|
||||
const subject = currentRevision?.title || "-";
|
||||
const description = currentRevision?.description || "-";
|
||||
const status = currentRevision?.status?.statusCode || "UNKNOWN"; // e.g. DRAFT
|
||||
const attachments = currentRevision?.attachments || [];
|
||||
|
||||
// Note: Importance might be in details
|
||||
const importance = currentRevision?.details?.importance || "NORMAL";
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (confirm("Are you sure you want to submit this correspondence?")) {
|
||||
// TODO: Implement Template Selection. Hardcoded to 1 for now.
|
||||
submitMutation.mutate({
|
||||
id: data.correspondenceId,
|
||||
id: data.id,
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
@@ -37,7 +50,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
|
||||
const action = actionState === "approve" ? "APPROVE" : "REJECT";
|
||||
processMutation.mutate({
|
||||
id: data.correspondenceId,
|
||||
id: data.id,
|
||||
data: {
|
||||
action,
|
||||
comments
|
||||
@@ -61,20 +74,30 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{data.documentNumber}</h1>
|
||||
<h1 className="text-2xl font-bold">{data.correspondenceNumber}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Created on {format(new Date(data.createdAt), "dd MMM yyyy HH:mm")}
|
||||
Created on {data.createdAt ? format(new Date(data.createdAt), "dd MMM yyyy HH:mm") : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{data.status === "DRAFT" && (
|
||||
{/* EDIT BUTTON LOGIC: Show if DRAFT */}
|
||||
{status === "DRAFT" && (
|
||||
<Link href={`/correspondences/${data.id}/edit`}>
|
||||
<Button variant="outline">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{status === "DRAFT" && (
|
||||
<Button onClick={handleSubmit} disabled={submitMutation.isPending}>
|
||||
{submitMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
Submit for Review
|
||||
</Button>
|
||||
)}
|
||||
{data.status === "IN_REVIEW" && (
|
||||
{status === "IN_REVIEW" && (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -134,15 +157,15 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl">{data.subject}</CardTitle>
|
||||
<StatusBadge status={data.status} />
|
||||
<CardTitle className="text-xl">{subject}</CardTitle>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Description</h3>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">
|
||||
{data.description || "No description provided."}
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -150,9 +173,9 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Attachments</h3>
|
||||
{data.attachments && data.attachments.length > 0 ? (
|
||||
{attachments && attachments.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{data.attachments.map((file, index) => (
|
||||
{attachments.map((file, index) => (
|
||||
<div
|
||||
key={file.id || index}
|
||||
className="flex items-center justify-between p-3 border rounded-lg bg-muted/20"
|
||||
@@ -170,7 +193,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No attachments.</p>
|
||||
<p className="text-sm text-muted-foreground italic">No attachments found.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -188,10 +211,10 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
<p className="text-sm font-medium text-muted-foreground">Importance</p>
|
||||
<div className="mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${data.importance === 'URGENT' ? 'bg-red-100 text-red-800' :
|
||||
data.importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
|
||||
${importance === 'URGENT' ? 'bg-red-100 text-red-800' :
|
||||
importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-blue-100 text-blue-800'}`}>
|
||||
{data.importance}
|
||||
{importance}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,15 +222,15 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
<hr className="my-4 border-t" />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">From Organization</p>
|
||||
<p className="font-medium mt-1">{data.fromOrganization?.orgName}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.fromOrganization?.orgCode}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">Originator</p>
|
||||
<p className="font-medium mt-1">{data.originator?.orgName || '-'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.originator?.orgCode || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">To Organization</p>
|
||||
<p className="font-medium mt-1">{data.toOrganization?.orgName}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.toOrganization?.orgCode}</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Project</p>
|
||||
<p className="font-medium mt-1">{data.project?.projectName || '-'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.project?.projectCode || '-'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { FileUpload } from "@/components/common/file-upload";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCreateCorrespondence } from "@/hooks/use-correspondence";
|
||||
import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence";
|
||||
import { Organization } from "@/types/organization";
|
||||
import { useOrganizations } from "@/hooks/use-master-data";
|
||||
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
|
||||
@@ -34,50 +34,66 @@ const correspondenceSchema = z.object({
|
||||
|
||||
type FormData = z.infer<typeof correspondenceSchema>;
|
||||
|
||||
export function CorrespondenceForm() {
|
||||
export function CorrespondenceForm({ initialData, id }: { initialData?: any, id?: number }) {
|
||||
const router = useRouter();
|
||||
const createMutation = useCreateCorrespondence();
|
||||
const updateMutation = useUpdateCorrespondence(); // Add this hook
|
||||
const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations();
|
||||
|
||||
// Extract initial values if editing
|
||||
const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0];
|
||||
const defaultValues: Partial<FormData> = {
|
||||
subject: currentRev?.title || "",
|
||||
description: currentRev?.description || "",
|
||||
documentTypeId: initialData?.correspondenceTypeId || 1,
|
||||
fromOrganizationId: initialData?.originatorId || undefined,
|
||||
toOrganizationId: currentRev?.details?.to_organization_id || undefined,
|
||||
importance: currentRev?.details?.importance || "NORMAL",
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch, // Watch values to control Select value props if needed, or rely on RHF
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(correspondenceSchema),
|
||||
defaultValues: {
|
||||
importance: "NORMAL",
|
||||
documentTypeId: 1,
|
||||
} as any, // Cast to any to handle partial defaults for required fields
|
||||
defaultValues: defaultValues as any,
|
||||
});
|
||||
|
||||
// Watch for controlled inputs to set value in Select components if needed for better UX
|
||||
const fromOrgId = watch("fromOrganizationId");
|
||||
const toOrgId = watch("toOrganizationId");
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
// Map FormData to CreateCorrespondenceDto
|
||||
// Note: projectId is hardcoded to 1 for now as per requirements/context
|
||||
const payload: CreateCorrespondenceDto = {
|
||||
projectId: 1,
|
||||
typeId: data.documentTypeId,
|
||||
title: data.subject,
|
||||
description: data.description,
|
||||
originatorId: data.fromOrganizationId, // Mapping From -> Originator (Impersonation)
|
||||
originatorId: data.fromOrganizationId,
|
||||
details: {
|
||||
to_organization_id: data.toOrganizationId,
|
||||
importance: data.importance
|
||||
},
|
||||
// create-correspondence DTO does not have 'attachments' field at root usually, often handled separate or via multipart
|
||||
// If useCreateCorrespondence handles multipart, we might need to pass FormData object or specific structure
|
||||
// For now, aligning with DTO interface.
|
||||
};
|
||||
|
||||
// If the hook expects the DTO directly:
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
router.push("/correspondences");
|
||||
},
|
||||
});
|
||||
if (id && initialData) {
|
||||
// UPDATE Mode
|
||||
updateMutation.mutate({ id, data: payload }, {
|
||||
onSuccess: () => router.push(`/correspondences/${id}`)
|
||||
});
|
||||
} else {
|
||||
// CREATE Mode
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => router.push("/correspondences"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
@@ -103,6 +119,7 @@ export function CorrespondenceForm() {
|
||||
<Label>From Organization *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("fromOrganizationId", parseInt(v))}
|
||||
value={fromOrgId ? String(fromOrgId) : undefined}
|
||||
disabled={isLoadingOrgs}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -125,6 +142,7 @@ export function CorrespondenceForm() {
|
||||
<Label>To Organization *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("toOrganizationId", parseInt(v))}
|
||||
value={toOrgId ? String(toOrgId) : undefined}
|
||||
disabled={isLoadingOrgs}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -177,22 +195,24 @@ export function CorrespondenceForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Attachments</Label>
|
||||
<FileUpload
|
||||
onFilesSelected={(files) => setValue("attachments", files)}
|
||||
maxFiles={10}
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
|
||||
/>
|
||||
</div>
|
||||
{!initialData && (
|
||||
<div className="space-y-2">
|
||||
<Label>Attachments</Label>
|
||||
<FileUpload
|
||||
onFilesSelected={(files) => setValue("attachments", files)}
|
||||
maxFiles={10}
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-4 pt-6 border-t">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Correspondence
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{id ? "Update Correspondence" : "Create Correspondence"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Correspondence } from "@/types/correspondence";
|
||||
import { CorrespondenceRevision } from "@/types/correspondence";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Edit } from "lucide-react";
|
||||
import { Eye, Edit, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface CorrespondenceListProps {
|
||||
data?: {
|
||||
items: Correspondence[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
data: CorrespondenceRevision[];
|
||||
}
|
||||
|
||||
export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
const columns: ColumnDef<Correspondence>[] = [
|
||||
const columns: ColumnDef<CorrespondenceRevision>[] = [
|
||||
{
|
||||
accessorKey: "documentNumber",
|
||||
accessorKey: "correspondence.correspondenceNumber",
|
||||
header: "Document No.",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("documentNumber")}</span>
|
||||
<span className="font-medium">{row.original.correspondence?.correspondenceNumber}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
accessorKey: "revisionLabel",
|
||||
header: "Rev",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.revisionLabel || row.original.revisionNumber}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Subject",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
|
||||
{row.getValue("subject")}
|
||||
<div className="max-w-[300px] truncate" title={row.original.title}>
|
||||
{row.original.title}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "fromOrganization.orgName",
|
||||
accessorKey: "correspondence.originator.orgName",
|
||||
header: "From",
|
||||
},
|
||||
{
|
||||
accessorKey: "toOrganization.orgName",
|
||||
header: "To",
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.correspondence?.originator?.orgName || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
@@ -50,23 +51,45 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
accessorKey: "status.statusName",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status?.statusCode || "UNKNOWN"} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
// Edit/View link goes to the DOCUMENT detail (correspondence.id)
|
||||
// Ideally we might pass ?revId=item.id to view specific revision, but detail page defaults to latest.
|
||||
// For editing, we edit the document.
|
||||
const docId = item.correspondence.id;
|
||||
const statusCode = item.status?.statusCode;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/correspondences/${row.original.correspondenceId}`}>
|
||||
<Button variant="ghost" size="icon" title="View">
|
||||
<Link href={`/correspondences/${docId}`}>
|
||||
<Button variant="ghost" size="icon" title="View Details">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{item.status === "DRAFT" && (
|
||||
<Link href={`/correspondences/${row.original.correspondenceId}/edit`}>
|
||||
<Button variant="ghost" size="icon" title="View File" onClick={() => {
|
||||
const attachments = item.attachments; // Now we are on Revision, so attachments might be here if joined
|
||||
if (attachments && attachments.length > 0 && attachments[0].url) {
|
||||
window.open(attachments[0].url, '_blank');
|
||||
} else {
|
||||
// Fallback check if attachments are on details json inside revision
|
||||
// or if we simply didn't join them yet.
|
||||
// Current Backend join: leftJoinAndSelect('rev.status', 'status') doesn't join attachments explicitly but maybe relation exists?
|
||||
// Wait, checking Entity... CorrespondenceRevision does NOT have attachments relation in code snippet provided earlier.
|
||||
// It might be in 'details' JSON or implied.
|
||||
// Just Alert for now as per previous logic.
|
||||
alert("ไม่พบไฟล์แนบ (No file attached)");
|
||||
}
|
||||
}}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
{statusCode === "DRAFT" && (
|
||||
<Link href={`/correspondences/${docId}/edit`}>
|
||||
<Button variant="ghost" size="icon" title="Edit">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -80,8 +103,7 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable columns={columns} data={data?.items || []} />
|
||||
{/* Pagination component would go here, receiving props from data */}
|
||||
<DataTable columns={columns} data={data || []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ export function StatsCards({ stats, isLoading }: StatsCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: "Total Correspondences",
|
||||
value: stats.correspondences,
|
||||
value: stats.totalDocuments,
|
||||
icon: FileText,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
title: "Active RFAs",
|
||||
value: stats.rfas,
|
||||
value: stats.totalRfas,
|
||||
icon: Clipboard,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-50",
|
||||
@@ -43,7 +43,7 @@ export function StatsCards({ stats, isLoading }: StatsCardsProps) {
|
||||
},
|
||||
{
|
||||
title: "Pending Approvals",
|
||||
value: stats.pending,
|
||||
value: stats.pendingApprovals,
|
||||
icon: Clock,
|
||||
color: "text-orange-600",
|
||||
bgColor: "bg-orange-50",
|
||||
|
||||
@@ -5,17 +5,12 @@ import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye } from "lucide-react";
|
||||
import { Eye, Edit, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface RFAListProps {
|
||||
data: {
|
||||
items: RFA[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
data: RFA[];
|
||||
}
|
||||
|
||||
export function RFAList({ data }: RFAListProps) {
|
||||
@@ -25,48 +20,87 @@ export function RFAList({ data }: RFAListProps) {
|
||||
{
|
||||
accessorKey: "rfa_number",
|
||||
header: "RFA No.",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("rfa_number")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const rev = row.original.revisions?.[0];
|
||||
return <span className="font-medium">{rev?.correspondence?.correspondenceNumber || '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
|
||||
{row.getValue("subject")}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const rev = row.original.revisions?.[0];
|
||||
return (
|
||||
<div className="max-w-[300px] truncate" title={rev?.title}>
|
||||
{rev?.title || '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "contract_name",
|
||||
accessorKey: "contract_name", // AccessorKey can be anything if we provide cell
|
||||
header: "Contract",
|
||||
cell: ({ row }) => {
|
||||
const rev = row.original.revisions?.[0];
|
||||
return <span>{rev?.correspondence?.project?.projectName || '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "discipline_name",
|
||||
header: "Discipline",
|
||||
cell: ({ row }) => <span>{row.original.discipline?.name || '-'}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.revisions?.[0]?.correspondence?.createdAt;
|
||||
return date ? format(new Date(date), "dd MMM yyyy") : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.revisions?.[0]?.statusCode?.statusName || row.original.revisions?.[0]?.statusCode?.statusCode;
|
||||
return <StatusBadge status={status || 'Unknown'} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
|
||||
const handleViewFile = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Logic to find first attachment: Check items -> shopDrawingRevision -> attachments
|
||||
const firstAttachment = item.revisions?.[0]?.items?.[0]?.shopDrawingRevision?.attachments?.[0];
|
||||
if (firstAttachment?.url) {
|
||||
window.open(firstAttachment.url, '_blank');
|
||||
} else {
|
||||
// Use alert or toast. Assuming toast is available or use generic alert for now if toast not imported
|
||||
// But rfa.service.ts in use-rfa.ts uses 'sonner', so 'sonner' is likely available.
|
||||
// I will try to use toast from 'sonner' if I import it, or just window.alert for safety.
|
||||
// User said "หน้าต่างแจ้งเตือน" -> Alert window.
|
||||
alert("ไม่พบไฟล์แนบ (No file attached)");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/rfas/${row.original.rfaId}`}>
|
||||
<Button variant="ghost" size="icon" title="View">
|
||||
<Link href={`/rfas/${row.original.id}`}>
|
||||
<Button variant="ghost" size="icon" title="View Details">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" title="View File" onClick={handleViewFile}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link href={`/rfas/${row.original.id}/edit`}>
|
||||
<Button variant="ghost" size="icon" title="Edit">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -75,7 +109,7 @@ export function RFAList({ data }: RFAListProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable columns={columns} data={data?.items || []} />
|
||||
<DataTable columns={columns} data={data || []} />
|
||||
{/* Pagination component would go here */}
|
||||
</div>
|
||||
);
|
||||
|
||||
15
frontend/components/ui/skeleton.tsx
Normal file
15
frontend/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
Reference in New Issue
Block a user