690414:1113 Update README.md /.agents/skills, /.windsurf/workflows
This commit is contained in:
@@ -1,17 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { circulationService } from '@/lib/services/circulation.service';
|
||||
import { Circulation, UpdateCirculationRoutingDto } from '@/types/circulation';
|
||||
import { UpdateCirculationRoutingDto } from '@/types/circulation';
|
||||
import { useCirculation, circulationKeys } from '@/hooks/use-circulation';
|
||||
import { useWorkflowHistory } from '@/hooks/use-workflow-history';
|
||||
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 { ArrowLeft, RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { format, isPast, addDays, parseISO } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedBanner } from '@/components/workflow/integrated-banner';
|
||||
import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
/**
|
||||
* EC-CIRC-003: ตรวจสอบว่า deadline เลยกำหนดแล้วหรือไม่ (overdue = วันถัดไปหลัง deadline + 1 วัน)
|
||||
*/
|
||||
function isOverdue(deadlineDate?: string): boolean {
|
||||
if (!deadlineDate) return false;
|
||||
return isPast(addDays(parseISO(deadlineDate), 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initials from name
|
||||
@@ -44,23 +58,22 @@ export default function CirculationDetailPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const uuid = params.uuid as string;
|
||||
const [pendingAttachmentIds, setPendingAttachmentIds] = useState<string[]>([]);
|
||||
|
||||
const { circulation, isLoading, error } = useCirculation(uuid);
|
||||
|
||||
const {
|
||||
data: circulation,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<Circulation>({
|
||||
queryKey: ['circulation', uuid],
|
||||
queryFn: () => circulationService.getByUuid(uuid),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
data: workflowHistory,
|
||||
isLoading: historyLoading,
|
||||
error: historyError,
|
||||
} = useWorkflowHistory(circulation?.workflowInstanceId);
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: ({ routingId, data }: { routingId: number; data: UpdateCirculationRoutingDto }) =>
|
||||
circulationService.updateRouting(routingId, data),
|
||||
onSuccess: () => {
|
||||
toast.success('Task completed successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['circulation', uuid] });
|
||||
queryClient.invalidateQueries({ queryKey: circulationKeys.detail(uuid) });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update task status');
|
||||
@@ -99,23 +112,37 @@ export default function CirculationDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
{/* Header */}
|
||||
<section className="space-y-4">
|
||||
{/* ADR-021: Integrated Banner — wired with live workflow data (v1.8.7) */}
|
||||
<IntegratedBanner
|
||||
docNo={circulation.circulationNo ?? ''}
|
||||
subject={circulation.subject ?? ''}
|
||||
status={circulation.statusCode ?? ''}
|
||||
instanceId={circulation.workflowInstanceId}
|
||||
workflowState={circulation.workflowState}
|
||||
availableActions={circulation.availableActions}
|
||||
pendingAttachmentIds={pendingAttachmentIds}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Navigation Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/circulation">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{circulation.circulationNo}</h1>
|
||||
<p className="text-muted-foreground">{circulation.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(circulation.statusCode)}>{circulation.statusCode}</Badge>
|
||||
<Link href="/circulation">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
กลับ
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Tabs — Details / Workflow */}
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">รายละเอียด</TabsTrigger>
|
||||
<TabsTrigger value="workflow">Workflow</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
|
||||
{/* Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -150,6 +177,20 @@ export default function CirculationDetailPage() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{circulation.deadlineDate && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Deadline</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
{format(parseISO(circulation.deadlineDate), 'dd MMM yyyy')}
|
||||
{isOverdue(circulation.deadlineDate) && (
|
||||
<Badge variant="destructive" className="text-xs ml-1">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Overdue
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -203,6 +244,18 @@ export default function CirculationDetailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="workflow">
|
||||
{/* ADR-021: WorkflowLifecycle — wired with history data (v1.8.7) */}
|
||||
<WorkflowLifecycle
|
||||
history={workflowHistory}
|
||||
currentState={circulation.workflowState}
|
||||
isLoading={historyLoading}
|
||||
error={historyError instanceof Error ? historyError : null}
|
||||
onAttachmentsChange={setPendingAttachmentIds}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CorrespondenceDetail } from '@/components/correspondences/detail';
|
||||
import { useCorrespondence } from '@/hooks/use-correspondence';
|
||||
import { IntegratedBanner } from '@/components/workflow/integrated-banner';
|
||||
import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle';
|
||||
import { FilePreviewModal } from '@/components/common/file-preview-modal';
|
||||
import { WorkflowErrorBoundary } from '@/components/common/workflow-error-boundary';
|
||||
import { useCorrespondence, useWorkflowHistory } from '@/hooks/use-correspondence';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { Correspondence } from '@/types/correspondence';
|
||||
import type { WorkflowAttachmentSummary } from '@/types/workflow';
|
||||
|
||||
export default function CorrespondenceDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -11,7 +19,23 @@ export default function CorrespondenceDetailPage() {
|
||||
const uuid = (params?.uuid as string) ?? '';
|
||||
const selectedRevisionId = searchParams.get('revId') ?? undefined;
|
||||
|
||||
// Hooks ทั้งหมดต้องเรียกก่อน early return (Rules of Hooks)
|
||||
const { data: correspondence, isLoading, isError } = useCorrespondence(uuid);
|
||||
const corrData = correspondence as Correspondence | undefined;
|
||||
|
||||
// ADR-021: ดึงประวัติ Workflow (disabled อัตโนมัติถ้าไม่มี workflowInstanceId)
|
||||
const { data: wfHistory, isLoading: wfLoading, error: wfError } = useWorkflowHistory(
|
||||
corrData?.workflowInstanceId
|
||||
);
|
||||
|
||||
// ADR-021 US4: state สำหรับ FilePreviewModal
|
||||
const [previewFile, setPreviewFile] = useState<WorkflowAttachmentSummary | null>(null);
|
||||
// ADR-021 T029: publicIds ของไฟล์ที่อัปโหลดใน WorkflowLifecycle Upload Zone
|
||||
const [pendingAttachmentIds, setPendingAttachmentIds] = useState<string[]>([]);
|
||||
// ADR-021 T041: ติดตาม publicIds ที่ Storage แจ้ง 404
|
||||
const [unavailableIds, setUnavailableIds] = useState<string[]>([]);
|
||||
const handleUnavailable = (publicId: string) =>
|
||||
setUnavailableIds((prev) => [...new Set([...prev, publicId])]);
|
||||
|
||||
if (!uuid) {
|
||||
return (
|
||||
@@ -38,5 +62,58 @@ export default function CorrespondenceDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return <CorrespondenceDetail data={correspondence} selectedRevisionId={selectedRevisionId} />;
|
||||
// ดึง Current Revision สำหรับแสดงใน Banner
|
||||
const currentRevision = corrData!.revisions?.find((r) => r.isCurrent) ?? corrData!.revisions?.[0];
|
||||
const docNo = corrData!.correspondenceNumber ?? '';
|
||||
const subject = currentRevision?.subject ?? '';
|
||||
const status = currentRevision?.status?.statusCode ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ADR-021: Integrated Banner — เลขเอกสาร + สถานะ + ปุ่ม Action */}
|
||||
<IntegratedBanner
|
||||
docNo={docNo}
|
||||
subject={subject}
|
||||
status={status}
|
||||
priority={correspondence.priority}
|
||||
workflowState={correspondence.workflowState}
|
||||
availableActions={correspondence.availableActions}
|
||||
instanceId={corrData!.workflowInstanceId}
|
||||
pendingAttachmentIds={pendingAttachmentIds}
|
||||
/>
|
||||
|
||||
{/* Tabs — Details / Workflow (WorkflowLifecycle ถูกเพิ่มใน T020) */}
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">รายละเอียด</TabsTrigger>
|
||||
<TabsTrigger value="workflow">Workflow</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="details">
|
||||
<CorrespondenceDetail data={correspondence} selectedRevisionId={selectedRevisionId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="workflow">
|
||||
<WorkflowErrorBoundary>
|
||||
<WorkflowLifecycle
|
||||
history={wfHistory}
|
||||
currentState={corrData?.workflowState}
|
||||
isLoading={wfLoading}
|
||||
error={wfError instanceof Error ? wfError : null}
|
||||
onFileClick={setPreviewFile}
|
||||
onAttachmentsChange={setPendingAttachmentIds}
|
||||
unavailableAttachmentIds={unavailableIds}
|
||||
/>
|
||||
</WorkflowErrorBoundary>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ADR-021 US4: File Preview Modal */}
|
||||
<WorkflowErrorBoundary fallback={null}>
|
||||
<FilePreviewModal
|
||||
attachment={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
</WorkflowErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { RFADetail } from '@/components/rfas/detail';
|
||||
import { IntegratedBanner } from '@/components/workflow/integrated-banner';
|
||||
import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle';
|
||||
import { FilePreviewModal } from '@/components/common/file-preview-modal';
|
||||
import { WorkflowErrorBoundary } from '@/components/common/workflow-error-boundary';
|
||||
import { notFound, useParams } from 'next/navigation';
|
||||
import { useRFA } from '@/hooks/use-rfa';
|
||||
import { useRFA, useWorkflowHistory } from '@/hooks/use-rfa';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { RFA } from '@/types/rfa';
|
||||
import type { WorkflowAttachmentSummary } from '@/types/workflow';
|
||||
|
||||
export default function RFADetailPage() {
|
||||
const { uuid } = useParams();
|
||||
const uuidStr = uuid ? String(uuid) : '';
|
||||
|
||||
// Hooks ทั้งหมดต้องเรียกก่อน early return (Rules of Hooks)
|
||||
const { data: rfa, isLoading, isError } = useRFA(uuidStr);
|
||||
const rfaData = rfa as RFA | undefined;
|
||||
|
||||
// ADR-021: ดึงประวัติ Workflow (disabled อัตโนมัติถ้าไม่มี workflowInstanceId)
|
||||
const { data: wfHistory, isLoading: wfLoading, error: wfError } = useWorkflowHistory(
|
||||
rfaData?.workflowInstanceId
|
||||
);
|
||||
|
||||
// ADR-021 US4: state สำหรับ FilePreviewModal
|
||||
const [previewFile, setPreviewFile] = useState<WorkflowAttachmentSummary | null>(null);
|
||||
// ADR-021 T029: publicIds ของไฟล์ที่อัปโหลดใน WorkflowLifecycle Upload Zone
|
||||
const [pendingAttachmentIds, setPendingAttachmentIds] = useState<string[]>([]);
|
||||
// ADR-021 T041: ติดตาม publicIds ที่ Storage แจ้ง 404
|
||||
const [unavailableIds, setUnavailableIds] = useState<string[]>([]);
|
||||
const handleUnavailable = (publicId: string) =>
|
||||
setUnavailableIds((prev) => [...new Set([...prev, publicId])]);
|
||||
|
||||
if (!uuid) notFound();
|
||||
|
||||
const { data: rfa, isLoading, isError } = useRFA(String(uuid));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
@@ -20,10 +45,62 @@ export default function RFADetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !rfa) {
|
||||
// Check if error is 404
|
||||
if (isError || !rfaData) {
|
||||
return <div className="text-center py-20 text-red-500">RFA not found or failed to load.</div>;
|
||||
}
|
||||
|
||||
return <RFADetail data={rfa} />;
|
||||
// ดึง Current Revision สำหรับแสดงใน Banner
|
||||
const currentRevision = rfaData.revisions?.find((r) => r.isCurrent) ?? rfaData.revisions?.[0];
|
||||
const docNo = rfaData.correspondence?.correspondenceNumber ?? rfaData.correspondenceNumber ?? '';
|
||||
const subject = currentRevision?.subject ?? '';
|
||||
const status = currentRevision?.statusCode?.statusCode ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ADR-021: Integrated Banner — เลขเอกสาร + สถานะ + ปุ่ม Action */}
|
||||
<IntegratedBanner
|
||||
docNo={docNo}
|
||||
subject={subject}
|
||||
status={status}
|
||||
priority={rfa.priority}
|
||||
workflowState={rfa.workflowState}
|
||||
availableActions={rfa.availableActions}
|
||||
instanceId={rfaData.workflowInstanceId}
|
||||
pendingAttachmentIds={pendingAttachmentIds}
|
||||
/>
|
||||
|
||||
{/* Tabs — Details / Workflow (WorkflowLifecycle ถูกเพิ่มใน T019) */}
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">รายละเอียด</TabsTrigger>
|
||||
<TabsTrigger value="workflow">Workflow</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="details">
|
||||
<RFADetail data={rfa} />
|
||||
</TabsContent>
|
||||
<TabsContent value="workflow">
|
||||
<WorkflowErrorBoundary>
|
||||
<WorkflowLifecycle
|
||||
history={wfHistory}
|
||||
currentState={rfaData.workflowState}
|
||||
isLoading={wfLoading}
|
||||
error={wfError instanceof Error ? wfError : null}
|
||||
onFileClick={setPreviewFile}
|
||||
onAttachmentsChange={setPendingAttachmentIds}
|
||||
unavailableAttachmentIds={unavailableIds}
|
||||
/>
|
||||
</WorkflowErrorBoundary>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ADR-021 US4: File Preview Modal */}
|
||||
<WorkflowErrorBoundary fallback={null}>
|
||||
<FilePreviewModal
|
||||
attachment={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
</WorkflowErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { transmittalService } from '@/lib/services/transmittal.service';
|
||||
import { Transmittal } from '@/types/transmittal';
|
||||
import { useTransmittal } from '@/hooks/use-transmittal';
|
||||
import { useWorkflowHistory } from '@/hooks/use-workflow-history';
|
||||
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 { IntegratedBanner } from '@/components/workflow/integrated-banner';
|
||||
import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
@@ -16,16 +19,15 @@ import { toast } from 'sonner';
|
||||
export default function TransmittalDetailPage() {
|
||||
const params = useParams();
|
||||
const uuid = params.uuid as string;
|
||||
const [pendingAttachmentIds, setPendingAttachmentIds] = useState<string[]>([]);
|
||||
|
||||
const { transmittal, isLoading, error } = useTransmittal(uuid);
|
||||
|
||||
const {
|
||||
data: transmittal,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<Transmittal>({
|
||||
queryKey: ['transmittal', uuid],
|
||||
queryFn: () => transmittalService.getByUuid(uuid),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
data: workflowHistory,
|
||||
isLoading: historyLoading,
|
||||
error: historyError,
|
||||
} = useWorkflowHistory(transmittal?.workflowInstanceId);
|
||||
|
||||
const handlePrint = () => {
|
||||
toast.info('PDF Export is coming soon...');
|
||||
@@ -56,31 +58,46 @@ export default function TransmittalDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const transmittalDocNo = transmittal.correspondence?.correspondenceNumber ?? transmittal.transmittalNo ?? '';
|
||||
const transmittalSubject = transmittal.subject ?? '';
|
||||
const transmittalStatus = transmittal.purpose ?? '';
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
{/* Header */}
|
||||
<section className="space-y-4">
|
||||
{/* ADR-021: Integrated Banner — wired with live workflow data (v1.8.7) */}
|
||||
<IntegratedBanner
|
||||
docNo={transmittalDocNo}
|
||||
subject={transmittalSubject}
|
||||
status={transmittalStatus}
|
||||
instanceId={transmittal.workflowInstanceId}
|
||||
workflowState={transmittal.workflowState}
|
||||
availableActions={transmittal.availableActions}
|
||||
pendingAttachmentIds={pendingAttachmentIds}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Navigation Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/transmittals">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{transmittal.correspondence?.correspondenceNumber || transmittal.transmittalNo}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{transmittal.correspondence?.revisions?.find((r) => r.isCurrent)?.title || transmittal.subject}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handlePrint}>
|
||||
<Link href="/transmittals">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
กลับ
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs — Details / Workflow */}
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">รายละเอียด</TabsTrigger>
|
||||
<TabsTrigger value="workflow">Workflow</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
|
||||
{/* Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -154,6 +171,18 @@ export default function TransmittalDetailPage() {
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="workflow">
|
||||
{/* ADR-021: WorkflowLifecycle — wired with history data (v1.8.7) */}
|
||||
<WorkflowLifecycle
|
||||
history={workflowHistory}
|
||||
currentState={transmittal.workflowState}
|
||||
isLoading={historyLoading}
|
||||
error={historyError instanceof Error ? historyError : null}
|
||||
onAttachmentsChange={setPendingAttachmentIds}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { TransmittalListResponse } from '@/types/transmittal';
|
||||
import { TransmittalPurpose } from '@/types/dto/transmittal/transmittal.dto';
|
||||
|
||||
const PURPOSE_OPTIONS: { value: TransmittalPurpose | ''; label: string }[] = [
|
||||
{ value: '', label: 'All Purposes' },
|
||||
{ value: TransmittalPurpose.FOR_APPROVAL, label: 'For Approval' },
|
||||
{ value: TransmittalPurpose.FOR_INFORMATION, label: 'For Information' },
|
||||
{ value: TransmittalPurpose.FOR_REVIEW, label: 'For Review' },
|
||||
{ value: TransmittalPurpose.OTHER, label: 'Other' },
|
||||
];
|
||||
|
||||
export default function TransmittalPage() {
|
||||
// ADR-019: Dynamic project selection via UUID
|
||||
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>('');
|
||||
const [selectedPurpose, setSelectedPurpose] = useState<TransmittalPurpose | ''>('');
|
||||
|
||||
const { data: projectsData } = useQuery({
|
||||
queryKey: ['projects-for-transmittals'],
|
||||
@@ -22,8 +32,12 @@ export default function TransmittalPage() {
|
||||
const projects = projectsData?.data || projectsData || [];
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<TransmittalListResponse>({
|
||||
queryKey: ['transmittals', selectedProjectUuid],
|
||||
queryFn: () => transmittalService.getAll({ projectId: selectedProjectUuid }),
|
||||
queryKey: ['transmittals', selectedProjectUuid, selectedPurpose],
|
||||
queryFn: () =>
|
||||
transmittalService.getAll({
|
||||
projectId: selectedProjectUuid,
|
||||
...(selectedPurpose ? { purpose: selectedPurpose } : {}),
|
||||
}),
|
||||
enabled: !!selectedProjectUuid,
|
||||
});
|
||||
|
||||
@@ -47,11 +61,11 @@ export default function TransmittalPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADR-019: Project filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filters: Project + Purpose (v1.8.7 B3) */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">Project:</span>
|
||||
<Select value={selectedProjectUuid} onValueChange={setSelectedProjectUuid}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder="Select a project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -64,6 +78,23 @@ export default function TransmittalPage() {
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground">Purpose:</span>
|
||||
<Select
|
||||
value={selectedPurpose || '__all__'}
|
||||
onValueChange={(v) => setSelectedPurpose(v === '__all__' ? '' : (v as TransmittalPurpose))}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Purposes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PURPOSE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value || '__all__'} value={opt.value || '__all__'}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
// ADR-021: FilePreviewModal — แสดงไฟล์แนบ Workflow Step โดยไม่บังคับดาวน์โหลด (US4)
|
||||
// รองรับ: PDF (iframe), Image (img), อื่นๆ (ลิงก์ดาวน์โหลด)
|
||||
// Auth: ดึงไฟล์ผ่าน apiClient เพื่อแนบ JWT header อัตโนมัติ → แปลงเป็น BlobURL
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, Download, FileIcon } from 'lucide-react';
|
||||
import type { AxiosError } from 'axios';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
import type { WorkflowAttachmentSummary } from '@/types/workflow';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
attachment: WorkflowAttachmentSummary | null;
|
||||
onClose: () => void;
|
||||
// ADR-021 T041: เรียกเมื่อ API คืน 404 (ไฟล์ถูกลบออกจาก Storage)
|
||||
onUnavailable?: (publicId: string) => void;
|
||||
}
|
||||
|
||||
// แปลง bytes เป็น KB/MB สำหรับแสดงขนาดไฟล์
|
||||
function formatBytes(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// ตรวจสอบว่า mimeType เป็น PDF หรือ Image
|
||||
function getPreviewType(mimeType?: string): 'pdf' | 'image' | 'none' {
|
||||
if (!mimeType) return 'none';
|
||||
if (mimeType === 'application/pdf') return 'pdf';
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function FilePreviewModal({ attachment, onClose, onUnavailable }: FilePreviewModalProps) {
|
||||
const t = useTranslations();
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ดึงไฟล์จาก API เมื่อ attachment เปลี่ยน — แปลงเป็น BlobURL เพื่อรองรับ JWT auth
|
||||
useEffect(() => {
|
||||
if (!attachment) {
|
||||
setBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentUrl: string | null = null;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
apiClient
|
||||
.get(`/files/preview/${attachment.publicId}`, { responseType: 'blob' })
|
||||
.then((res) => {
|
||||
const url = URL.createObjectURL(res.data as Blob);
|
||||
currentUrl = url;
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
// ADR-021 T041: ตรวจสอบ 404 เพื่อแยกแยะกรณี ไฟล์ถูกลบ vs เกิดผิดพลาดอื่น
|
||||
if (err.response?.status === 404) {
|
||||
setError(t('filePreview.fileUnavailable'));
|
||||
if (attachment?.publicId) onUnavailable?.(attachment.publicId);
|
||||
} else {
|
||||
setError(t('filePreview.loadError'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Cleanup: เพิกถอน BlobURL เพื่อป้องกัน memory leak
|
||||
return () => {
|
||||
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
||||
};
|
||||
}, [attachment, onUnavailable, t]);
|
||||
|
||||
const previewType = getPreviewType(attachment?.mimeType);
|
||||
|
||||
return (
|
||||
<Dialog open={!!attachment} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl w-full h-[85vh] flex flex-col p-0">
|
||||
{/* Header — ชื่อไฟล์ + ขนาด */}
|
||||
<DialogHeader className="px-6 pt-5 pb-3 border-b shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-base truncate">
|
||||
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{attachment?.originalFilename ?? t('filePreview.fallbackTitle')}</span>
|
||||
{attachment?.fileSize && (
|
||||
<span className="ml-auto text-xs text-muted-foreground font-normal shrink-0">
|
||||
{formatBytes(attachment.fileSize)}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body — Preview Area */}
|
||||
<div className="flex-1 overflow-hidden relative bg-muted/30">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{error ?? t('filePreview.loadError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && blobUrl && previewType === 'pdf' && (
|
||||
<iframe
|
||||
src={blobUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={attachment?.originalFilename}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && blobUrl && previewType === 'image' && (
|
||||
<div className="w-full h-full flex items-center justify-center p-4">
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={attachment?.originalFilename}
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && blobUrl && previewType === 'none' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<FileIcon className="h-12 w-12 opacity-30" />
|
||||
<p>{t('filePreview.unsupported')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer — ปุ่มดาวน์โหลด + ปิด */}
|
||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||
{blobUrl && (
|
||||
<a
|
||||
href={blobUrl}
|
||||
download={attachment?.originalFilename}
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t('filePreview.download')}
|
||||
</a>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t('filePreview.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
// ADR-021 T042: Error Boundary สำหรับ WorkflowLifecycle และ FilePreviewModal
|
||||
// ป้องกัน crash ทั้งหน้าเมื่อเกิด unexpected error ใน Workflow components
|
||||
// ต้องใช้ class component เพราะ React Error Boundary ยังไม่รองรับ hooks
|
||||
|
||||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Custom fallback — ถ้าไม่ระบุจะแสดง default message */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class WorkflowErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<div className="rounded-md bg-muted/50 p-4 text-center text-sm text-muted-foreground">
|
||||
เกิดข้อผิดพลาด ไม่สามารถแสดง Workflow ได้ กรุณารีเฟรชหน้า
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -377,7 +377,7 @@ export function CorrespondenceForm({
|
||||
|
||||
const timer = setTimeout(fetchPreview, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [projectId, documentTypeId, disciplineId, fromOrgId, toOrgId]);
|
||||
}, [projectId, documentTypeId, disciplineId, fromOrgId, toOrgId, uuid]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
||||
|
||||
@@ -286,7 +286,7 @@ export function RFAForm() {
|
||||
|
||||
const timer = setTimeout(fetchPreview, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId, rfaCorrespondenceType?.publicId, watch]);
|
||||
}, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId, rfaCorrespondenceType?.publicId, rfaCorrespondenceType?.id, watch]);
|
||||
|
||||
const onSubmit = (data: RFAFormData) => {
|
||||
if (requiresShopDrawings && data.shopDrawingRevisionIds?.length === 0) {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
// ADR-021: IntegratedBanner — แสดง metadata เอกสาร + สถานะ Workflow + ปุ่ม Action ในแถวเดียว
|
||||
// ใช้ใน RFA, Correspondence, Transmittal, Circulation detail pages
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CheckCircle2, XCircle, RotateCcw, MessageSquare, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { WorkflowPriority } from '@/types/workflow';
|
||||
import { useWorkflowAction } from '@/hooks/use-workflow-action';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
|
||||
// สีของ Priority Badge (label ถูก resolve ผ่าน t() ใน component)
|
||||
const PRIORITY_CONFIG: Record<WorkflowPriority, { labelKey: string; className: string }> = {
|
||||
URGENT: { labelKey: 'workflow.priority.URGENT', className: 'bg-red-600 text-white animate-pulse' },
|
||||
HIGH: { labelKey: 'workflow.priority.HIGH', className: 'bg-orange-500 text-white' },
|
||||
MEDIUM: { labelKey: 'workflow.priority.MEDIUM', className: 'bg-yellow-500 text-white' },
|
||||
LOW: { labelKey: 'workflow.priority.LOW', className: 'bg-green-600 text-white' },
|
||||
};
|
||||
|
||||
// สีของ Status Badge
|
||||
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
const s = status?.toUpperCase();
|
||||
if (['APPROVED', 'COMPLETED', 'ISSUED'].includes(s)) return 'default';
|
||||
if (['REJECTED', 'CANCELLED'].includes(s)) return 'destructive';
|
||||
if (['DRAFT', 'DFT'].includes(s)) return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
// แสดงป้ายสีตาม Workflow State
|
||||
function getStateColor(state?: string): string {
|
||||
if (!state) return 'text-muted-foreground';
|
||||
const s = state.toUpperCase();
|
||||
if (s.includes('APPROV') || s.includes('COMPLET') || s.includes('ISSUED')) return 'text-green-600';
|
||||
if (s.includes('REJECT') || s.includes('CANCEL')) return 'text-red-600';
|
||||
if (s.includes('REVIEW') || s.includes('PENDING') || s.includes('IN_')) return 'text-blue-600';
|
||||
return 'text-amber-600';
|
||||
}
|
||||
|
||||
// Action button config (label ถูก resolve ผ่าน t() ใน component)
|
||||
const ACTION_CONFIG: Record<string, { labelKey: string; icon: React.ReactNode; variant: 'default' | 'destructive' | 'outline' | 'secondary'; requiresComment: boolean }> = {
|
||||
APPROVE: { labelKey: 'workflow.action.APPROVE', icon: <CheckCircle2 className="h-4 w-4" />, variant: 'default', requiresComment: false },
|
||||
REJECT: { labelKey: 'workflow.action.REJECT', icon: <XCircle className="h-4 w-4" />, variant: 'destructive', requiresComment: true },
|
||||
RETURN: { labelKey: 'workflow.action.RETURN', icon: <RotateCcw className="h-4 w-4" />, variant: 'outline', requiresComment: true },
|
||||
ACKNOWLEDGE: { labelKey: 'workflow.action.ACKNOWLEDGE', icon: <CheckCircle2 className="h-4 w-4" />, variant: 'secondary', requiresComment: false },
|
||||
COMMENT: { labelKey: 'workflow.action.COMMENT', icon: <MessageSquare className="h-4 w-4" />, variant: 'outline', requiresComment: true },
|
||||
};
|
||||
|
||||
export interface IntegratedBannerProps {
|
||||
docNo: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
priority?: WorkflowPriority;
|
||||
workflowState?: string;
|
||||
availableActions?: string[];
|
||||
/** Legacy prop — ใช้เมื่อไม่มี instanceId (Transmittal, Circulation) */
|
||||
onAction?: (action: string, comment?: string) => void;
|
||||
isLoading?: boolean;
|
||||
// ADR-021 T029: Workflow action wiring
|
||||
instanceId?: string;
|
||||
/** publicIds ของไฟล์ที่ user อัปโหลดใน WorkflowLifecycle Upload Zone */
|
||||
pendingAttachmentIds?: string[];
|
||||
/** เรียกเมื่อ action สำเร็จ (optional — React Query เปิด invalidate อัตโนมัติ) */
|
||||
onActionSuccess?: () => void;
|
||||
}
|
||||
|
||||
// Action button พร้อม Popover สำหรับ Comment
|
||||
function ActionButton({
|
||||
actionKey,
|
||||
onAction,
|
||||
disabled,
|
||||
t,
|
||||
}: {
|
||||
actionKey: string;
|
||||
onAction: (action: string, comment?: string) => void;
|
||||
disabled?: boolean;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const config = ACTION_CONFIG[actionKey] ?? {
|
||||
labelKey: actionKey,
|
||||
icon: null,
|
||||
variant: 'outline' as const,
|
||||
requiresComment: false,
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onAction(actionKey, comment || undefined);
|
||||
setComment('');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (!config.requiresComment) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={config.variant}
|
||||
disabled={disabled}
|
||||
onClick={() => onAction(actionKey)}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="ml-1">{t(config.labelKey)}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant={config.variant} disabled={disabled}>
|
||||
{config.icon}
|
||||
<span className="ml-1">{t(config.labelKey)}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t('workflow.action.commentLabel')}</p>
|
||||
<Textarea
|
||||
placeholder={t('workflow.action.commentPlaceholder')}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('workflow.action.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant={config.variant} onClick={handleSubmit}>
|
||||
{t('workflow.action.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntegratedBanner({
|
||||
docNo,
|
||||
subject,
|
||||
status,
|
||||
priority,
|
||||
workflowState,
|
||||
availableActions,
|
||||
onAction,
|
||||
isLoading = false,
|
||||
instanceId,
|
||||
pendingAttachmentIds,
|
||||
onActionSuccess,
|
||||
}: IntegratedBannerProps) {
|
||||
const t = useTranslations();
|
||||
// ADR-021 T029: hook สำหรับ Workflow transition (disabled อัตโนมัติถ้าไม่มี instanceId)
|
||||
const wfMutation = useWorkflowAction(instanceId);
|
||||
|
||||
// ถ้ามี instanceId ใช้ hook, ถ้าไม่มี fallback ไปยัง legacy onAction prop
|
||||
const handleAction = (action: string, comment?: string) => {
|
||||
if (instanceId) {
|
||||
wfMutation.mutate(
|
||||
{ action, comment, attachmentPublicIds: pendingAttachmentIds ?? [] },
|
||||
{ onSuccess: onActionSuccess }
|
||||
);
|
||||
} else {
|
||||
onAction?.(action, comment);
|
||||
}
|
||||
};
|
||||
|
||||
const isBusy = isLoading || wfMutation.isPending;
|
||||
const priorityConfig = priority ? PRIORITY_CONFIG[priority] : undefined;
|
||||
const hasActions = availableActions && availableActions.length > 0 && (instanceId || onAction);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-card px-4 py-3 shadow-sm">
|
||||
{/* แถวหลัก */}
|
||||
<div className="flex flex-wrap items-center gap-3 min-w-0">
|
||||
{/* เลขที่เอกสาร + หัวข้อ */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground shrink-0">{docNo || '—'}</span>
|
||||
<Separator orientation="vertical" className="h-4 hidden sm:block" />
|
||||
<span className="text-sm text-muted-foreground truncate">{subject || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges + Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
{/* Status Badge */}
|
||||
<Badge variant={getStatusVariant(status)} className="text-xs">
|
||||
{status || '—'}
|
||||
</Badge>
|
||||
|
||||
{/* Priority Badge */}
|
||||
{priorityConfig && (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${priorityConfig.className}`}>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t(priorityConfig.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Workflow State */}
|
||||
{workflowState && (
|
||||
<span className={`text-xs font-medium ${getStateColor(workflowState)}`}>
|
||||
[{workflowState}]
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action Buttons — แสดง spinner เมื่อ mutation กำลัง pending */}
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
hasActions &&
|
||||
availableActions!.map((actionKey) => (
|
||||
<ActionButton
|
||||
key={actionKey}
|
||||
actionKey={actionKey}
|
||||
onAction={handleAction}
|
||||
disabled={isBusy}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
// ADR-021: WorkflowLifecycle — แสดง Timeline การเดินเรื่องเอกสาร (US2, REQ-02, REQ-03)
|
||||
// แสดง Step ที่เสร็จแล้ว, Step ปัจจุบัน (active, มีสีพิเศษ), และ Step ที่ยังรอ
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { th } from 'date-fns/locale';
|
||||
import { CheckCircle2, XCircle, RotateCcw, MessageSquare, Clock, Loader2, Paperclip, Upload, X as XIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
import type { WorkflowAttachmentSummary, WorkflowHistoryItem } from '@/types/workflow';
|
||||
|
||||
// รูปแบบ response จาก POST /files/upload
|
||||
interface UploadedAttachment {
|
||||
publicId: string;
|
||||
originalFilename: string;
|
||||
}
|
||||
|
||||
interface WorkflowLifecycleProps {
|
||||
history?: WorkflowHistoryItem[];
|
||||
currentState?: string;
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
// ADR-021 US4: callback เมื่อ User คลิก attachment chip เพื่อ preview
|
||||
onFileClick?: (attachment: WorkflowAttachmentSummary) => void;
|
||||
// ADR-021 T028: callback เมื่อ publicIds ของไฟล์แนบ Step ปัจจุบันเปลี่ยน
|
||||
onAttachmentsChange?: (publicIds: string[]) => void;
|
||||
// ADR-021 T041: บอก publicIds ที่ API คืน 404 (ไฟล์ถูกลบออกจาก Storage)
|
||||
unavailableAttachmentIds?: string[];
|
||||
}
|
||||
|
||||
// Icon ตาม Action ประเภท (labels resolve ผ่าน t() ใน component)
|
||||
const ACTION_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
APPROVE: <CheckCircle2 className="h-4 w-4" />,
|
||||
REJECT: <XCircle className="h-4 w-4" />,
|
||||
RETURN: <RotateCcw className="h-4 w-4" />,
|
||||
COMMENT: <MessageSquare className="h-4 w-4" />,
|
||||
ACKNOWLEDGE: <CheckCircle2 className="h-4 w-4" />,
|
||||
SUBMIT: <Clock className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
// สีของ Node ตาม Action (REQ-03: Active Step Color)
|
||||
function getNodeStyle(action: string, isLatest: boolean): { wrapper: string; icon: string } {
|
||||
const a = action.toUpperCase();
|
||||
if (isLatest) {
|
||||
// Step ปัจจุบัน — เน้นด้วย primary ring
|
||||
return {
|
||||
wrapper: 'bg-primary/10 border-primary ring-2 ring-primary/30 ring-offset-2',
|
||||
icon: 'text-primary',
|
||||
};
|
||||
}
|
||||
if (a === 'APPROVE' || a === 'ACKNOWLEDGE') {
|
||||
return { wrapper: 'bg-green-50 border-green-300', icon: 'text-green-600' };
|
||||
}
|
||||
if (a === 'REJECT') {
|
||||
return { wrapper: 'bg-red-50 border-red-300', icon: 'text-red-600' };
|
||||
}
|
||||
if (a === 'RETURN') {
|
||||
return { wrapper: 'bg-amber-50 border-amber-300', icon: 'text-amber-600' };
|
||||
}
|
||||
return { wrapper: 'bg-blue-50 border-blue-200', icon: 'text-blue-600' };
|
||||
}
|
||||
|
||||
// แปลง Action key เป็น i18n key
|
||||
function getActionLabelKey(action: string): string {
|
||||
return `workflow.timeline.step.${action.toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function WorkflowLifecycle({
|
||||
history,
|
||||
currentState,
|
||||
isLoading = false,
|
||||
error,
|
||||
onFileClick,
|
||||
onAttachmentsChange,
|
||||
unavailableAttachmentIds,
|
||||
}: WorkflowLifecycleProps) {
|
||||
const unavailableSet = new Set(unavailableAttachmentIds ?? []);
|
||||
const t = useTranslations();
|
||||
// ADR-021 T028: สถานะการอัปโหลดไฟล์แนบประจำ Step ปัจจุบัน
|
||||
const [pendingFiles, setPendingFiles] = useState<UploadedAttachment[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// อัปโหลดไฟล์ผ่าน Two-Phase upload (POST /files/upload → temp)
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setIsUploading(true);
|
||||
|
||||
const newUploaded: UploadedAttachment[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res = await apiClient.post<{ data?: UploadedAttachment } & UploadedAttachment>(
|
||||
'/files/upload',
|
||||
formData
|
||||
);
|
||||
const att: UploadedAttachment = (res.data as { data?: UploadedAttachment }).data ?? (res.data as UploadedAttachment);
|
||||
if (att?.publicId) newUploaded.push(att);
|
||||
} catch {
|
||||
toast.error(`${t('workflow.timeline.uploadError')} "${file.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = [...pendingFiles, ...newUploaded];
|
||||
setPendingFiles(updated);
|
||||
onAttachmentsChange?.(updated.map((f) => f.publicId));
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
||||
function removeUploadedFile(publicId: string) {
|
||||
const updated = pendingFiles.filter((f) => f.publicId !== publicId);
|
||||
setPendingFiles(updated);
|
||||
onAttachmentsChange?.(updated.map((f) => f.publicId));
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 p-4 text-center text-sm text-muted-foreground">
|
||||
{t('workflow.timeline.loadError')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||
{t('workflow.timeline.noHistory')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-4 px-2">
|
||||
<div className="relative">
|
||||
{/* เส้นแนวตั้งเชื่อม Node */}
|
||||
<div
|
||||
className="absolute left-5 top-5 bottom-5 w-px bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<ol className="space-y-6">
|
||||
{history.map((item, index) => {
|
||||
const isLatest = index === history.length - 1;
|
||||
const nodeStyle = getNodeStyle(item.action, isLatest);
|
||||
const icon =
|
||||
ACTION_ICON_MAP[item.action.toUpperCase()] ?? (
|
||||
<Clock className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={item.id} className="relative flex gap-4">
|
||||
{/* Node Circle — REQ-03: active step มี ring */}
|
||||
<div
|
||||
className={`relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 ${nodeStyle.wrapper}`}
|
||||
>
|
||||
<span className={nodeStyle.icon}>{icon}</span>
|
||||
</div>
|
||||
|
||||
{/* เนื้อหา Step */}
|
||||
<div className="flex-1 min-w-0 pt-1.5 pb-2">
|
||||
{/* Action + State Transition */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">
|
||||
{t(getActionLabelKey(item.action))}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{item.fromState}
|
||||
<span className="mx-1 text-muted-foreground">→</span>
|
||||
{item.toState}
|
||||
</Badge>
|
||||
{isLatest && currentState && (
|
||||
<Badge className="text-xs bg-primary text-primary-foreground">
|
||||
{t('workflow.timeline.current')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ความเห็น */}
|
||||
{item.comment && (
|
||||
<p className="text-sm text-muted-foreground mb-1 italic">
|
||||
“{item.comment}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Attachment Chips — ADR-021 US4 / T041: unavailable chip */}
|
||||
{item.attachments && item.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{item.attachments.map((att) => {
|
||||
const isUnavailable = unavailableSet.has(att.publicId);
|
||||
return isUnavailable ? (
|
||||
<span
|
||||
key={att.publicId}
|
||||
className="inline-flex items-center gap-1 h-6 px-2 rounded border text-xs text-muted-foreground/50 line-through cursor-not-allowed select-none"
|
||||
title={t('workflow.timeline.fileUnavailable')}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{t('workflow.timeline.fileUnavailable')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={att.publicId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1"
|
||||
onClick={() => onFileClick?.(att)}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{att.originalFilename}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp + Actor */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(new Date(item.createdAt), 'dd MMM yyyy HH:mm', {
|
||||
locale: th,
|
||||
})}
|
||||
{item.actionByUserId && (
|
||||
<span className="ml-2 text-muted-foreground/70">
|
||||
· User #{item.actionByUserId}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* ADR-021 T028: Upload Zone — แสดงเฉพาะ Step ปัจจุบัน (เมื่อ parent ต้องการเก็บไฟล์แนบ) */}
|
||||
{onAttachmentsChange && (
|
||||
<div className="mt-4 px-2">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-5 text-center transition-colors cursor-pointer
|
||||
${isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/40'}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
void handleFileUpload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isUploading ? t('workflow.timeline.uploading') : t('workflow.timeline.uploadHint')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">{t('workflow.timeline.uploadTypes')}</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.docx,.xlsx,.dwg,.zip"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleFileUpload(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ไฟล์ที่อัปโหลดแล้ว (pending) */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{pendingFiles.map((f) => (
|
||||
<span
|
||||
key={f.publicId}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{f.originalFilename}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); removeUploadedFile(f.publicId); }}
|
||||
aria-label={t('workflow.timeline.uploadError')}
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,36 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { circulationService } from '@/lib/services/circulation.service';
|
||||
import { Circulation } from '@/types/circulation';
|
||||
|
||||
export const circulationKeys = {
|
||||
all: ['circulations'] as const,
|
||||
detail: (uuid: string) => ['circulations', 'detail', uuid] as const,
|
||||
byCorrespondence: (uuid: string) => ['circulations', 'byCorrespondence', uuid] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook สำหรับดึงข้อมูล Circulation รายเอกสาร (พร้อม workflowInstanceId)
|
||||
* ADR-021 / v1.8.7 — ใช้ใน circulation/[uuid]/page.tsx
|
||||
*/
|
||||
export function useCirculation(uuid: string | undefined) {
|
||||
const query = useQuery<Circulation>({
|
||||
queryKey: circulationKeys.detail(uuid ?? ''),
|
||||
queryFn: async () => {
|
||||
const res = await circulationService.getByUuid(uuid!);
|
||||
return (res?.data ?? res) as Circulation;
|
||||
},
|
||||
enabled: !!uuid,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
circulation: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCirculationsByCorrespondence(correspondencePublicId: string) {
|
||||
return useQuery({
|
||||
queryKey: circulationKeys.byCorrespondence(correspondencePublicId),
|
||||
|
||||
@@ -6,6 +6,9 @@ import { SubmitCorrespondenceDto } from '@/types/dto/correspondence/submit-corre
|
||||
import { WorkflowActionDto } from '@/types/dto/correspondence/workflow-action.dto';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ADR-021: Re-export useWorkflowHistory เพื่อให้ page import ได้จาก use-correspondence
|
||||
export { useWorkflowHistory } from './use-workflow-history';
|
||||
|
||||
// Error type for axios errors
|
||||
type ApiError = Error & { response?: { data?: { message?: string } } };
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { WorkflowActionDto } from '@/lib/services/rfa.service';
|
||||
import { toast } from 'sonner';
|
||||
import { getApiErrorMessage } from '@/types/api-error';
|
||||
|
||||
// ADR-021: Re-export useWorkflowHistory เพื่อให้ page import ได้จาก use-rfa
|
||||
export { useWorkflowHistory } from './use-workflow-history';
|
||||
|
||||
// Keys
|
||||
export const rfaKeys = {
|
||||
all: ['rfas'] as const,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// hooks/use-translations.ts
|
||||
// ADR-021 Phase 7: React hook สำหรับ i18n — คืน t() function สำหรับใช้ใน Client Components
|
||||
'use client';
|
||||
|
||||
import { createT } from '@/lib/i18n';
|
||||
|
||||
// ค่า default locale ของโปรเจกต์คือ 'th'
|
||||
// เมื่อต้องการรองรับ multi-locale ให้เชื่อมกับ Context หรือ cookie ในอนาคต
|
||||
const defaultT = createT('th');
|
||||
|
||||
export function useTranslations() {
|
||||
return defaultT;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// File: hooks/use-transmittal.ts
|
||||
// ADR-021 / v1.8.7: TanStack Query hook สำหรับ Transmittal detail page
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { transmittalService } from '@/lib/services/transmittal.service';
|
||||
import { Transmittal } from '@/types/transmittal';
|
||||
|
||||
export const transmittalKeys = {
|
||||
all: ['transmittals'] as const,
|
||||
detail: (uuid: string) => ['transmittals', 'detail', uuid] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook สำหรับดึงข้อมูล Transmittal รายเอกสาร (พร้อม workflowInstanceId)
|
||||
* ใช้ใน transmittals/[uuid]/page.tsx
|
||||
*/
|
||||
export function useTransmittal(uuid: string | undefined) {
|
||||
const query = useQuery<Transmittal>({
|
||||
queryKey: transmittalKeys.detail(uuid ?? ''),
|
||||
queryFn: async () => {
|
||||
const res = await transmittalService.getByUuid(uuid!);
|
||||
return (res?.data ?? res) as Transmittal;
|
||||
},
|
||||
enabled: !!uuid,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
transmittal: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// ADR-021 T027: useWorkflowAction — hook สำหรับส่ง Approve/Reject/Return action
|
||||
// สร้าง Idempotency-Key ครั้งเดียวต่อ action intent (via useState) ป้องกัน duplicate submission
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { toast } from 'sonner';
|
||||
import { workflowEngineService } from '@/lib/services/workflow-engine.service';
|
||||
import type { WorkflowTransitionWithAttachmentsDto } from '@/types/dto/workflow-engine/workflow-engine.dto';
|
||||
|
||||
export function useWorkflowAction(instanceId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// สร้าง idempotency key ครั้งแรก — reset หลัง submit สำเร็จ เพื่อป้องกัน replay
|
||||
const [idempotencyKey, setIdempotencyKey] = useState(() => uuidv4());
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (dto: WorkflowTransitionWithAttachmentsDto) => {
|
||||
if (!instanceId) {
|
||||
return Promise.reject(new Error('ไม่พบ Workflow Instance ID'));
|
||||
}
|
||||
return workflowEngineService.transition(instanceId, dto, idempotencyKey);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Reset key สำหรับ action ครั้งถัดไป
|
||||
setIdempotencyKey(uuidv4());
|
||||
|
||||
// Invalidate ประวัติ Workflow ของ Instance นี้
|
||||
if (instanceId) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['workflow-history', instanceId],
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate รายการเอกสารหลักทั้งหมด (RFA, Correspondence)
|
||||
void queryClient.invalidateQueries({ queryKey: ['rfas'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['correspondences'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['transmittals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['circulations'] });
|
||||
|
||||
toast.success('ดำเนินการเรียบร้อยแล้ว');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่');
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// ADR-021: Hook สำหรับดึงประวัติ Workflow พร้อมไฟล์แนบประจำ Step (US2)
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { workflowEngineService } from '@/lib/services/workflow-engine.service';
|
||||
import type { WorkflowHistoryItem } from '@/types/workflow';
|
||||
|
||||
export const workflowHistoryKeys = {
|
||||
all: ['workflow-history'] as const,
|
||||
instance: (instanceId: string) =>
|
||||
[...workflowHistoryKeys.all, instanceId] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* ดึงประวัติการเดินเรื่องของ Workflow Instance
|
||||
* disabled อัตโนมัติถ้า instanceId ไม่มีค่า
|
||||
*/
|
||||
export function useWorkflowHistory(instanceId: string | undefined) {
|
||||
return useQuery<WorkflowHistoryItem[]>({
|
||||
queryKey: workflowHistoryKeys.instance(instanceId ?? ''),
|
||||
queryFn: () => workflowEngineService.getHistory(instanceId!),
|
||||
enabled: !!instanceId,
|
||||
staleTime: 60_000, // 1 นาที — ประวัติไม่เปลี่ยนบ่อย
|
||||
retry: false, // ถ้า 404 (endpoint ยังไม่มี) ไม่ต้อง retry
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// lib/i18n/index.ts
|
||||
// ADR-021 Phase 7: Minimal i18n utility — Thai เป็น default locale
|
||||
// English ถูก reserve ไว้สำหรับอนาคต (05-08-i18n-guidelines.md)
|
||||
|
||||
import thMessages from '@/public/locales/th/common.json';
|
||||
import enMessages from '@/public/locales/en/common.json';
|
||||
|
||||
type Locale = 'th' | 'en';
|
||||
|
||||
const messages: Record<Locale, Record<string, string>> = {
|
||||
th: thMessages as Record<string, string>,
|
||||
en: enMessages as Record<string, string>,
|
||||
};
|
||||
|
||||
// สร้าง translator function ตาม locale
|
||||
export function createT(locale: Locale = 'th') {
|
||||
const dict = messages[locale];
|
||||
return function t(key: string, params?: Record<string, string | number>): string {
|
||||
const text = dict[key] ?? key;
|
||||
if (!params) return text;
|
||||
// รองรับ template เช่น "ลบ {{filename}}" → "ลบ report.pdf"
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, k: string) => String(params[k] ?? ''));
|
||||
};
|
||||
}
|
||||
|
||||
// Default translator (Thai) — ใช้ได้โดยตรงใน utility functions นอก component
|
||||
export const t = createT('th');
|
||||
@@ -43,6 +43,15 @@ export const transmittalService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit Transmittal to Workflow (EC-RFA-004 validation fires on backend)
|
||||
* POST /transmittals/:uuid/submit
|
||||
*/
|
||||
submit: async (uuid: string): Promise<{ instanceId: string; currentState: string }> => {
|
||||
const response = await apiClient.post(`/transmittals/${uuid}/submit`);
|
||||
return response.data?.data ?? response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* ลบเอกสาร (Soft Delete)
|
||||
*/
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
UpdateWorkflowDefinitionDto,
|
||||
EvaluateWorkflowDto,
|
||||
GetAvailableActionsDto,
|
||||
WorkflowTransitionWithAttachmentsDto,
|
||||
} from '@/types/dto/workflow-engine/workflow-engine.dto';
|
||||
|
||||
import { Workflow, WorkflowType } from '@/types/workflow';
|
||||
import { Workflow, WorkflowHistoryItem, WorkflowType } from '@/types/workflow';
|
||||
|
||||
interface WorkflowResponseShape {
|
||||
data?: unknown;
|
||||
@@ -185,4 +186,35 @@ export const workflowEngineService = {
|
||||
const response = await apiClient.delete(`/workflow-engine/definitions/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
// --- ADR-021: Workflow Transition + History ---
|
||||
|
||||
/**
|
||||
* ส่ง Action เพื่อเปลี่ยนสถานะ Workflow (พร้อมไฟล์แนบและ Idempotency-Key)
|
||||
* POST /workflow-engine/instances/:id/transition
|
||||
*/
|
||||
transition: async (
|
||||
instanceId: string,
|
||||
dto: WorkflowTransitionWithAttachmentsDto,
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
const response = await apiClient.post(
|
||||
`/workflow-engine/instances/${instanceId}/transition`,
|
||||
dto,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
return response.data?.data ?? response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* ดึงประวัติ Workflow พร้อมไฟล์แนบประจำ Step
|
||||
* GET /workflow-engine/instances/:id/history
|
||||
*/
|
||||
getHistory: async (instanceId: string): Promise<WorkflowHistoryItem[]> => {
|
||||
const response = await apiClient.get(
|
||||
`/workflow-engine/instances/${instanceId}/history`
|
||||
);
|
||||
const payload = response.data?.data ?? response.data;
|
||||
return Array.isArray(payload) ? (payload as WorkflowHistoryItem[]) : [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"workflow.priority.URGENT": "Urgent",
|
||||
"workflow.priority.HIGH": "High",
|
||||
"workflow.priority.MEDIUM": "Normal",
|
||||
"workflow.priority.LOW": "Low",
|
||||
|
||||
"workflow.action.APPROVE": "Approve",
|
||||
"workflow.action.REJECT": "Reject",
|
||||
"workflow.action.RETURN": "Return",
|
||||
"workflow.action.ACKNOWLEDGE": "Acknowledge",
|
||||
"workflow.action.COMMENT": "Comment",
|
||||
"workflow.action.commentLabel": "Comment / Reason",
|
||||
"workflow.action.commentPlaceholder": "Enter reason (optional)...",
|
||||
"workflow.action.confirm": "Confirm",
|
||||
"workflow.action.cancel": "Cancel",
|
||||
|
||||
"workflow.status.default": "—",
|
||||
|
||||
"workflow.timeline.noHistory": "No workflow history for this document.",
|
||||
"workflow.timeline.loadError": "Unable to load Workflow Timeline. Please try again.",
|
||||
"workflow.timeline.current": "Current",
|
||||
"workflow.timeline.step.APPROVE": "Approved",
|
||||
"workflow.timeline.step.REJECT": "Rejected",
|
||||
"workflow.timeline.step.RETURN": "Returned",
|
||||
"workflow.timeline.step.ACKNOWLEDGE": "Acknowledged",
|
||||
"workflow.timeline.step.COMMENT": "Comment",
|
||||
"workflow.timeline.step.SUBMIT": "Submitted",
|
||||
"workflow.timeline.uploadHint": "Drag & drop files here, or click to select",
|
||||
"workflow.timeline.uploading": "Uploading...",
|
||||
"workflow.timeline.uploadTypes": "PDF, DOCX, XLSX, DWG, ZIP · Max 50 MB",
|
||||
"workflow.timeline.uploadError": "Unable to upload",
|
||||
|
||||
"filePreview.fallbackTitle": "File",
|
||||
"filePreview.fileUnavailable": "File has been removed from storage.",
|
||||
"workflow.timeline.fileUnavailable": "File unavailable",
|
||||
"filePreview.unsupported": "Preview is not available for this file type.",
|
||||
"filePreview.loadError": "Unable to load file. Please try again.",
|
||||
"filePreview.download": "Download",
|
||||
"filePreview.close": "Close"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"workflow.priority.URGENT": "ด่วนที่สุด",
|
||||
"workflow.priority.HIGH": "ด่วน",
|
||||
"workflow.priority.MEDIUM": "ปกติ",
|
||||
"workflow.priority.LOW": "ไม่ด่วน",
|
||||
|
||||
"workflow.action.APPROVE": "อนุมัติ",
|
||||
"workflow.action.REJECT": "ปฏิเสธ",
|
||||
"workflow.action.RETURN": "ส่งคืน",
|
||||
"workflow.action.ACKNOWLEDGE": "รับทราบ",
|
||||
"workflow.action.COMMENT": "แสดงความเห็น",
|
||||
"workflow.action.commentLabel": "ความเห็นประกอบการดำเนินการ",
|
||||
"workflow.action.commentPlaceholder": "ระบุเหตุผล (ถ้ามี)...",
|
||||
"workflow.action.confirm": "ยืนยัน",
|
||||
"workflow.action.cancel": "ยกเลิก",
|
||||
|
||||
"workflow.status.default": "—",
|
||||
|
||||
"workflow.timeline.noHistory": "ยังไม่มีประวัติ Workflow สำหรับเอกสารนี้",
|
||||
"workflow.timeline.loadError": "ไม่สามารถโหลด Workflow Timeline ได้ กรุณาลองใหม่",
|
||||
"workflow.timeline.current": "ปัจจุบัน",
|
||||
"workflow.timeline.step.APPROVE": "อนุมัติ",
|
||||
"workflow.timeline.step.REJECT": "ปฏิเสธ",
|
||||
"workflow.timeline.step.RETURN": "ส่งคืน",
|
||||
"workflow.timeline.step.ACKNOWLEDGE": "รับทราบ",
|
||||
"workflow.timeline.step.COMMENT": "ความเห็น",
|
||||
"workflow.timeline.step.SUBMIT": "ส่งเรื่อง",
|
||||
"workflow.timeline.uploadHint": "ลากไฟล์มาวาง หรือคลิกเพื่อเลือก",
|
||||
"workflow.timeline.uploading": "กำลังอัปโหลด...",
|
||||
"workflow.timeline.uploadTypes": "PDF, DOCX, XLSX, DWG, ZIP · สูงสุด 50 MB",
|
||||
"workflow.timeline.uploadError": "ไม่สามารถอัปโหลด",
|
||||
|
||||
"filePreview.fallbackTitle": "ไฟล์",
|
||||
"filePreview.fileUnavailable": "ไฟล์ถูกลบออกจาก Storage แล้ว",
|
||||
"workflow.timeline.fileUnavailable": "ไฟล์ไม่พร้อมใช้งาน",
|
||||
"filePreview.unsupported": "ไม่รองรับการแสดงผลสำหรับไฟล์ประเภทนี้",
|
||||
"filePreview.loadError": "ไม่สามารถโหลดไฟล์ได้ กรุณาลองใหม่",
|
||||
"filePreview.download": "ดาวน์โหลด",
|
||||
"filePreview.close": "ปิด"
|
||||
}
|
||||
@@ -48,8 +48,13 @@ export interface Circulation {
|
||||
createdByUserId: number;
|
||||
submittedAt?: string;
|
||||
closedAt?: string;
|
||||
deadlineDate?: string; // v1.8.7: delta-05 EC-CIRC-003
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// ADR-021 / v1.8.7: Workflow context fields
|
||||
workflowInstanceId?: string; // UUID ของ WorkflowInstance
|
||||
workflowState?: string; // สถานะปัจจุบันใน Workflow
|
||||
availableActions?: string[]; // Actions ที่ทำได้ ณ ขณะนี้
|
||||
// Joined relations from API
|
||||
routings?: CirculationRouting[];
|
||||
correspondence?: {
|
||||
|
||||
@@ -88,6 +88,12 @@ export interface Correspondence {
|
||||
codeNameTh?: string;
|
||||
contract?: { publicId: string; contractName: string; contractCode: string };
|
||||
};
|
||||
|
||||
// ADR-021: Workflow Engine fields (optional — populated when workflow is active)
|
||||
workflowInstanceId?: string;
|
||||
workflowState?: string;
|
||||
availableActions?: string[];
|
||||
priority?: import('./workflow').WorkflowPriority;
|
||||
}
|
||||
|
||||
export interface CreateCorrespondenceDto {
|
||||
|
||||
@@ -61,3 +61,18 @@ export interface GetAvailableActionsDto {
|
||||
/** สถานะปัจจุบัน */
|
||||
currentState: string;
|
||||
}
|
||||
|
||||
// --- ADR-021: Workflow Transition with Step Attachments ---
|
||||
export interface WorkflowTransitionWithAttachmentsDto {
|
||||
/** ชื่อ Action (ต้องตรงกับ DSL) เช่น APPROVE, REJECT */
|
||||
action: string;
|
||||
|
||||
/** ความเห็นประกอบการดำเนินการ */
|
||||
comment?: string;
|
||||
|
||||
/** ข้อมูลเพิ่มเติม */
|
||||
payload?: Record<string, unknown>;
|
||||
|
||||
/** รายการ publicId ของไฟล์แนบประจำ Step นี้ (max 20, ADR-016 Two-Phase upload) */
|
||||
attachmentPublicIds?: string[];
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ export interface RFA {
|
||||
|
||||
// Deprecated/Mapped fields
|
||||
correspondenceNumber?: string; // Convenience accessor
|
||||
|
||||
// ADR-021: Workflow Engine fields (optional — populated when workflow is active)
|
||||
workflowInstanceId?: string;
|
||||
workflowState?: string;
|
||||
availableActions?: string[];
|
||||
priority?: import('./workflow').WorkflowPriority;
|
||||
}
|
||||
|
||||
export interface CreateRFADto {
|
||||
|
||||
@@ -36,6 +36,10 @@ export interface Transmittal {
|
||||
purpose?: TransmittalPurpose;
|
||||
remarks?: string;
|
||||
createdAt: string;
|
||||
// ADR-021 / v1.8.7: Workflow context fields
|
||||
workflowInstanceId?: string; // UUID ของ WorkflowInstance (null = Draft ยังไม่ submit)
|
||||
workflowState?: string; // สถานะปัจจุบันใน Workflow เช่น IN_REVIEW, APPROVED
|
||||
availableActions?: string[]; // Actions ที่ทำได้ ณ ขณะนี้ เช่น ['APPROVE', 'REJECT']
|
||||
// Joined relations from API
|
||||
items?: TransmittalItem[];
|
||||
correspondence?: {
|
||||
@@ -90,5 +94,6 @@ export interface SearchTransmittalDto {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
projectId?: number | string; // ADR-019: Accept UUID
|
||||
purpose?: TransmittalPurpose; // v1.8.7: B3 purpose filter
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
export type WorkflowType = 'CORRESPONDENCE' | 'RFA' | 'DRAWING';
|
||||
|
||||
// ADR-021: ระดับความเร่งด่วน (แสดงด้วย Badge สี)
|
||||
export type WorkflowPriority = 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
// ADR-021: ข้อมูลสรุปไฟล์แนบประจำ Step
|
||||
export interface WorkflowAttachmentSummary {
|
||||
publicId: string;
|
||||
originalFilename: string;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
// ADR-021: รายการประวัติการเปลี่ยนสถานะพร้อมไฟล์แนบ
|
||||
export interface WorkflowHistoryItem {
|
||||
id: string;
|
||||
fromState: string;
|
||||
toState: string;
|
||||
action: string;
|
||||
actionByUserId?: number;
|
||||
comment?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
attachments?: WorkflowAttachmentSummary[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
publicId: string; // ADR-019: public identifier
|
||||
stepId?: string; // Internal ID (excluded from API)
|
||||
|
||||
Reference in New Issue
Block a user