feat(migration): ADR-028 migration architecture refactor

- เพิ่ม POST /api/ai/jobs + GET /api/ai/jobs/:jobId endpoints (FR-001, FR-002)
- เพิ่ม BullMQ Worker MigrateDocumentWorker + OCR auto-detect (FR-003, FR-004)
- เพิ่ม cleanup-temp-files + expire-pending-reviews workers (FR-005, FR-005a/b)
- สร้าง SQL deltas: tags, correspondence_tags, alter migration_review_queue (FR-006, ADR-009)
- เพิ่ม MigrationReviewService.commitRecord() + SELECT FOR UPDATE (FR-007, FR-007a)
- เพิ่ม CASL permission migration.commit + MigrationReviewController (FR-007)
- สร้าง TagsModule + TagsService + TagsController (US3)
- สร้าง Migration Review Queue frontend page + ReviewQueueTable (US2)
- อัปเดต n8n guide: deterministic Idempotency-Key + token pre-flight (FR-001a, FR-010a/b)
- สร้าง spec.md, plan.md, tasks.md, data-model.md, contracts/, quickstart.md
- สร้าง ADR-028 document + validation-report.md (PASS 32/32 tasks, 173/173 tests)
This commit is contained in:
2026-05-22 17:10:07 +07:00
parent 990d80e16d
commit a2973be208
55 changed files with 4256 additions and 107 deletions
@@ -26,6 +26,7 @@ interface MigrationAiIssues {
sourceFilePath?: string;
keyPoints?: string[];
validationResults?: Array<{ message: string; severity: string }>;
tags?: string[];
}
const reviewFormSchema = z.object({
@@ -101,11 +102,9 @@ export default function MigrationReviewPage() {
const onSubmit = async (values: ReviewFormValues) => {
if (!item) return;
try {
setSubmitting(true);
const issues = item.aiIssues || {};
const issues = (item.aiIssues || {}) as unknown as MigrationAiIssues;
const payload = {
documentNumber: values.documentNumber,
subject: values.subject,
@@ -113,7 +112,7 @@ export default function MigrationReviewPage() {
sourceFilePath: issues.sourceFilePath || '',
migratedBy: 'SYSTEM_IMPORT',
batchId: 'MANUAL_REVIEW_BATCH',
projectId: 1, // Assumption or pulled from store
projectId: 1,
documentDate: values.documentDate,
issuedDate: values.issuedDate,
receivedDate: values.receivedDate,
@@ -124,15 +123,12 @@ export default function MigrationReviewPage() {
aiConfidence: item.aiConfidence,
},
};
if (!item?.id) {
toast.error('Invalid item ID');
return;
}
// Mock idempotency key based on timestamp to ensure uniqueness per approval retry
const idempotencyKey = `review-${item.id}-${Date.now()}`;
await migrationService.approveQueueItem(item.id, payload, idempotencyKey);
toast.success('Document approved and imported successfully');
router.push('/admin/migration');
} catch (error: unknown) {
@@ -0,0 +1,167 @@
// File: app/(dashboard)/migration/review/page.tsx
// Change Log:
// - 2026-05-22: Initial creation of Migration Review page with premium UI, pagination, status tabs, and strictly zero blank lines inside function bodies (T024)
'use client';
import React, { useState } from 'react';
import { useMigrationReviewQueue } from '@/hooks/use-migration-review';
import { MigrationReviewStatus } from '@/types/migration';
import { ReviewQueueTable } from '@/components/migration/review-queue-table';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight, RefreshCw, BarChart2, ShieldAlert } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function MigrationReviewPage() {
const [statusFilter, setStatusFilter] = useState<MigrationReviewStatus | 'ALL'>(MigrationReviewStatus.PENDING);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const { data, isLoading, isFetching, refetch } = useMigrationReviewQueue(
statusFilter === 'ALL' ? undefined : statusFilter,
currentPage,
itemsPerPage
);
const items = data?.items || [];
const totalItems = data?.total || 0;
const totalPages = data?.totalPages || 1;
const handleTabChange = (value: string) => {
setStatusFilter(value as MigrationReviewStatus | 'ALL');
setCurrentPage(1);
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
return (
<div className="flex-1 space-y-6 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 bg-clip-text text-transparent">
Migration Review Queue
</h2>
<p className="text-muted-foreground text-sm">
AI Engine
</p>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="h-9 hover:bg-accent/50 transition-colors"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
<span></span>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20 shadow-sm backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold text-yellow-500"> (Pending)</CardTitle>
<BarChart2 className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-black text-yellow-500 font-mono">
{statusFilter === MigrationReviewStatus.PENDING ? totalItems : '-'}
</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-transparent border-green-500/20 shadow-sm backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold text-green-500"> (Imported)</CardTitle>
<BarChart2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-black text-green-500 font-mono">
{statusFilter === MigrationReviewStatus.IMPORTED ? totalItems : '-'}
</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-red-500/10 to-transparent border-red-500/20 shadow-sm backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold text-red-500"> (Rejected)</CardTitle>
<ShieldAlert className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-black text-red-500 font-mono">
{statusFilter === MigrationReviewStatus.REJECTED ? totalItems : '-'}
</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-indigo-500/10 to-transparent border-indigo-500/20 shadow-sm backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold text-indigo-500"> (Total)</CardTitle>
<BarChart2 className="h-4 w-4 text-indigo-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-black text-indigo-500 font-mono">
{statusFilter === 'ALL' ? totalItems : '-'}
</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
</div>
<Card className="border-muted bg-card shadow-lg backdrop-blur-md">
<CardHeader className="pb-3 border-b flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription className="text-xs"></CardDescription>
</div>
<Tabs value={statusFilter} onValueChange={handleTabChange} className="w-[450px]">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="PENDING" className="text-xs font-semibold"></TabsTrigger>
<TabsTrigger value="IMPORTED" className="text-xs font-semibold"></TabsTrigger>
<TabsTrigger value="REJECTED" className="text-xs font-semibold"></TabsTrigger>
<TabsTrigger value="ALL" className="text-xs font-semibold"></TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent className="pt-6">
<ReviewQueueTable items={items} isLoading={isLoading} />
{totalPages > 1 && (
<div className="flex items-center justify-between space-x-2 pt-6">
<div className="text-xs text-muted-foreground font-mono">
{currentPage} {totalPages} ( {totalItems} )
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1 || isLoading}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs font-semibold px-2 font-mono">
{currentPage}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || isLoading}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,506 @@
// File: components/migration/review-queue-table.tsx
// Change Log:
// - 2026-05-22: Initial creation of ReviewQueueTable component for US2 (T024)
// - 2026-05-22: Integrated hybrid identifiers and Radix Sheet panel with zero blank lines inside function bodies (T024)
import React, { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCommitMigrationReview, useRejectMigrationReview } from '@/hooks/use-migration-review';
import { useProjects, useOrganizations } from '@/hooks/use-master-data';
import { MigrationReviewQueueItem, MigrationReviewStatus } from '@/types/migration';
import { Loader2, Calendar, Tag, AlertCircle, Edit, Check, X, Plus } from 'lucide-react';
interface ReviewTag {
name?: string;
tagName?: string;
is_new?: boolean;
isNew?: boolean;
}
interface ProjectOption {
publicId: string;
projectName: string;
projectCode?: string;
}
interface OrganizationOption {
publicId: string;
organizationName: string;
}
const getStringField = (value: Record<string, unknown>, key: string): string | undefined =>
typeof value[key] === 'string' ? value[key] : undefined;
const toReviewTag = (value: Record<string, unknown>): ReviewTag => ({
name: getStringField(value, 'name'),
tagName: getStringField(value, 'tagName'),
is_new: typeof value.is_new === 'boolean' ? value.is_new : undefined,
isNew: typeof value.isNew === 'boolean' ? value.isNew : undefined,
});
const getTagLabel = (tag: Record<string, unknown>): string =>
getStringField(tag, 'name') ?? getStringField(tag, 'tagName') ?? '';
const getIssueText = (issue: Record<string, unknown>): string =>
getStringField(issue, 'description') ?? getStringField(issue, 'message') ?? '';
interface ReviewQueueTableProps {
items: MigrationReviewQueueItem[];
isLoading: boolean;
}
export function ReviewQueueTable({ items, isLoading }: ReviewQueueTableProps) {
const [selectedItem, setSelectedItem] = useState<MigrationReviewQueueItem | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [editSubject, setEditSubject] = useState('');
const [editCategory, setEditCategory] = useState('');
const [editProjectId, setEditProjectId] = useState<string>('');
const [editSenderId, setEditSenderId] = useState<string>('');
const [editReceiverId, setEditReceiverId] = useState<string>('');
const [editIssuedDate, setEditIssuedDate] = useState('');
const [editReceivedDate, setEditReceivedDate] = useState('');
const [editBody, setEditBody] = useState('');
const [editTags, setEditTags] = useState<string[]>([]);
const [newTagInput, setNewTagInput] = useState('');
const commitMutation = useCommitMigrationReview();
const rejectMutation = useRejectMigrationReview();
const { data: projects = [] } = useProjects();
const { data: organizations = [] } = useOrganizations();
const projectOptions = projects as ProjectOption[];
const organizationOptions = organizations as OrganizationOption[];
const handleOpenReview = (item: MigrationReviewQueueItem) => {
setSelectedItem(item);
setEditSubject(item.subject || item.title || '');
setEditCategory(item.aiSuggestedCategory || 'Correspondence');
setEditProjectId(String(item.projectId || ''));
setEditSenderId(String(item.senderOrganizationId || ''));
setEditReceiverId(String(item.receiverOrganizationId || ''));
setEditIssuedDate(item.issuedDate ? item.issuedDate.substring(0, 10) : '');
setEditReceivedDate(item.receivedDate ? item.receivedDate.substring(0, 10) : '');
setEditBody(item.body || '');
const tags = Array.isArray(item.extractedTags)
? item.extractedTags.map((tag) => getTagLabel(tag)).filter(Boolean)
: [];
setEditTags(tags);
setNewTagInput('');
setIsSheetOpen(true);
};
const handleAddTag = () => {
if (newTagInput.trim() && !editTags.includes(newTagInput.trim())) {
setEditTags([...editTags, newTagInput.trim()]);
setNewTagInput('');
}
};
const handleRemoveTag = (tagToRemove: string) => {
setEditTags(editTags.filter((t) => t !== tagToRemove));
};
const handleCommit = async () => {
if (!selectedItem) return;
try {
const idempotencyKey = `migration_review_${selectedItem.publicId}_${Date.now()}`;
await commitMutation.mutateAsync({
publicId: selectedItem.publicId,
idempotencyKey,
subject: editSubject,
category: editCategory,
projectId: editProjectId || undefined,
senderId: editSenderId || undefined,
receiverId: editReceiverId || undefined,
issuedDate: editIssuedDate || undefined,
receivedDate: editReceivedDate || undefined,
tags: editTags,
body: editBody || undefined,
});
setIsSheetOpen(false);
setSelectedItem(null);
} catch {
return;
}
};
const handleReject = async () => {
if (!selectedItem) return;
if (window.confirm('คุณแน่ใจหรือไม่ว่าต้องการปฏิเสธเอกสารนี้?')) {
try {
const queueIntId = selectedItem.id || 0;
await rejectMutation.mutateAsync(queueIntId);
setIsSheetOpen(false);
setSelectedItem(null);
} catch {
return;
}
}
};
const getStatusBadge = (status: MigrationReviewStatus) => {
const configs: Record<MigrationReviewStatus, { label: string; className: string }> = {
[MigrationReviewStatus.PENDING]: {
label: 'รอตรวจสอบ',
className: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/30',
},
[MigrationReviewStatus.APPROVED]: {
label: 'อนุมัติแล้ว',
className: 'bg-blue-500/20 text-blue-500 border-blue-500/30',
},
[MigrationReviewStatus.REJECTED]: {
label: 'ปฏิเสธ',
className: 'bg-red-500/20 text-red-500 border-red-500/30',
},
[MigrationReviewStatus.IMPORTED]: {
label: 'นำเข้าแล้ว',
className: 'bg-green-500/20 text-green-500 border-green-500/30',
},
};
const config = configs[status] || { label: status, className: '' };
return <Badge className={config.className}>{config.label}</Badge>;
};
return (
<div className="w-full">
<div className="rounded-md border bg-card text-card-foreground shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead> (Subject)</TableHead>
<TableHead className="w-[120px]"> AI</TableHead>
<TableHead className="w-[100px] text-center"> AI</TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<div className="flex flex-col items-center justify-center space-y-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
items.map((item) => (
<TableRow key={item.publicId} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-mono text-sm font-semibold">{item.documentNumber}</TableCell>
<TableCell className="max-w-md truncate font-medium">
{item.subject || item.title || 'ไม่มีหัวข้อ'}
</TableCell>
<TableCell>
<Badge variant="outline">{item.aiSuggestedCategory || 'Correspondence'}</Badge>
</TableCell>
<TableCell className="text-center font-mono">
{item.aiConfidence ? `${(Number(item.aiConfidence) * 100).toFixed(1)}%` : '-'}
</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell className="text-right">
<Button
variant={item.status === MigrationReviewStatus.PENDING ? 'default' : 'outline'}
size="sm"
onClick={() => handleOpenReview(item)}
className="inline-flex items-center space-x-1"
>
<Edit className="h-3.5 w-3.5" />
<span>{item.status === MigrationReviewStatus.PENDING ? 'รีวิว' : 'ดูรายละเอียด'}</span>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetContent className="sm:max-w-2xl overflow-y-auto w-[650px] p-6 bg-background border-l shadow-2xl">
<SheetHeader className="mb-6 border-b pb-4">
<SheetTitle className="text-xl font-bold flex items-center space-x-2">
<span></span>
<Badge variant="outline" className="font-mono text-xs">
{selectedItem?.documentNumber}
</Badge>
</SheetTitle>
<SheetDescription>
Metadata
</SheetDescription>
</SheetHeader>
{selectedItem && (
<div className="space-y-6">
{selectedItem.aiIssues && selectedItem.aiIssues.length > 0 && (
<div className="p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-500 space-y-2">
<div className="flex items-center space-x-2 font-semibold">
<AlertCircle className="h-4 w-4" />
<span> AI:</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{selectedItem.aiIssues.map((issue, idx: number) => (
<li key={idx}>
{getIssueText(issue)}
</li>
))}
</ul>
</div>
)}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="subject" className="text-sm font-semibold"> (Subject)</Label>
<Input
id="subject"
value={editSubject}
onChange={(e) => setEditSubject(e.target.value)}
placeholder="ป้อนหัวข้อเรื่องภาษาไทยหรืออังกฤษ"
className="w-full border-input"
/>
{selectedItem.originalSubject && selectedItem.originalSubject !== editSubject && (
<p className="text-xs text-muted-foreground italic">
AI : {selectedItem.originalSubject}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category" className="text-sm font-semibold"></Label>
<Select value={editCategory} onValueChange={setEditCategory}>
<SelectTrigger id="category">
<SelectValue placeholder="เลือกหมวดหมู่" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Correspondence">Correspondence (LETTER)</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="Drawing">Drawing (OTHER)</SelectItem>
<SelectItem value="Report">Report (OTHER)</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="project" className="text-sm font-semibold"> (Project)</Label>
<Select value={editProjectId} onValueChange={setEditProjectId}>
<SelectTrigger id="project">
<SelectValue placeholder="เลือกโครงการ" />
</SelectTrigger>
<SelectContent>
{projectOptions.map((proj) => (
<SelectItem key={proj.publicId} value={proj.publicId}>
{proj.projectName} ({proj.projectCode})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sender" className="text-sm font-semibold"> (Sender)</Label>
<Select value={editSenderId} onValueChange={setEditSenderId}>
<SelectTrigger id="sender">
<SelectValue placeholder="เลือกองค์กรผู้ส่ง" />
</SelectTrigger>
<SelectContent>
{organizationOptions.map((org) => (
<SelectItem key={org.publicId} value={org.publicId}>
{org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="receiver" className="text-sm font-semibold"> (Receiver)</Label>
<Select value={editReceiverId} onValueChange={setEditReceiverId}>
<SelectTrigger id="receiver">
<SelectValue placeholder="เลือกองค์กรผู้รับ" />
</SelectTrigger>
<SelectContent>
{organizationOptions.map((org) => (
<SelectItem key={org.publicId} value={org.publicId}>
{org.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="issuedDate" className="text-sm font-semibold flex items-center space-x-1">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span> (Issued Date)</span>
</Label>
<Input
id="issuedDate"
type="date"
value={editIssuedDate}
onChange={(e) => setEditIssuedDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="receivedDate" className="text-sm font-semibold flex items-center space-x-1">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span> (Received Date)</span>
</Label>
<Input
id="receivedDate"
type="date"
value={editReceivedDate}
onChange={(e) => setEditReceivedDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="body" className="text-sm font-semibold"> (Body)</Label>
<Textarea
id="body"
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
placeholder="ป้อนเนื้อความย่อของจดหมาย"
rows={4}
className="w-full border-input font-sans text-sm resize-y"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold flex items-center space-x-1">
<Tag className="h-4 w-4 text-muted-foreground" />
<span> (Tags)</span>
</Label>
<div className="flex flex-wrap gap-2 p-3 bg-muted/40 rounded-md border min-h-[50px]">
{editTags.map((tag) => {
const origItem = Array.isArray(selectedItem.extractedTags)
? selectedItem.extractedTags
.map((item) => toReviewTag(item))
.find((item) => (item.name || item.tagName) === tag)
: null;
const isNew = origItem?.is_new || origItem?.isNew;
return (
<Badge
key={tag}
variant="secondary"
className={`flex items-center space-x-1 pr-1 font-sans ${isNew ? 'bg-emerald-500/20 text-emerald-500 border-emerald-500/30' : 'bg-secondary'}`}
>
<span>{tag}</span>
<button
onClick={() => handleRemoveTag(tag)}
className="hover:bg-muted rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
{editTags.length === 0 && (
<span className="text-xs text-muted-foreground italic flex items-center">
</span>
)}
</div>
<div className="flex space-x-2 mt-2">
<Input
placeholder="เพิ่มแท็กภาษาไทย..."
value={newTagInput}
onChange={(e) => setNewTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
}}
className="h-8 text-xs max-w-[200px]"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddTag}
className="h-8"
>
<Plus className="h-3.5 w-3.5 mr-1" />
<span></span>
</Button>
</div>
</div>
</div>
{selectedItem.status === MigrationReviewStatus.PENDING && (
<SheetFooter className="border-t pt-4 mt-6 flex justify-between sm:justify-between">
<Button
type="button"
variant="destructive"
onClick={handleReject}
disabled={commitMutation.isPending || rejectMutation.isPending}
className="inline-flex items-center space-x-1"
>
<X className="h-4 w-4" />
<span> (Reject)</span>
</Button>
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setIsSheetOpen(false)}
disabled={commitMutation.isPending || rejectMutation.isPending}
>
</Button>
<Button
type="button"
onClick={handleCommit}
disabled={commitMutation.isPending || rejectMutation.isPending}
className="inline-flex items-center space-x-1"
>
{commitMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
<span> (Commit)</span>
</Button>
</div>
</SheetFooter>
)}
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
// File: hooks/use-migration-review.ts
// Change Log:
// - 2026-05-22: Initial creation for US2 - Staging Migration Review Hooks (T023)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '@/lib/api/client';
import { MigrationReviewQueueItem, MigrationReviewStatus, PaginatedResponse } from '@/types/migration';
import { CommitMigrationReviewDto } from '@/types/dto/migration/migration-review.dto';
import { toast } from 'sonner';
import { getApiErrorMessage } from '@/types/api-error';
interface WrappedData<T> {
data?: T;
}
interface CommitMigrationReviewRequest extends CommitMigrationReviewDto {
idempotencyKey: string;
}
const extractData = <T>(value: unknown): T => {
let current: unknown = value;
for (let index = 0; index < 5; index += 1) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return current as T;
}
current = (current as WrappedData<unknown>).data;
}
return current as T;
};
export const migrationReviewKeys = {
all: ['migration-review'] as const,
queue: (status?: MigrationReviewStatus, page?: number, limit?: number) =>
[...migrationReviewKeys.all, 'queue', status ?? 'ALL', page ?? 1, limit ?? 10] as const,
};
/**
* Hook สำหรับดึงรายการใน Staging Review Queue แบบทำ Pagination และกรองตาม Status
*/
export function useMigrationReviewQueue(status?: MigrationReviewStatus, page: number = 1, limit: number = 10) {
return useQuery({
queryKey: migrationReviewKeys.queue(status, page, limit),
queryFn: async (): Promise<PaginatedResponse<MigrationReviewQueueItem>> => {
const response = await apiClient.get('/migration/queue', {
params: { status, page, limit },
});
return extractData<PaginatedResponse<MigrationReviewQueueItem>>(response.data);
},
placeholderData: (prev) => prev,
staleTime: 10 * 1000,
});
}
/**
* Hook สำหรับยืนยันการนำเข้าข้อมูล (Execute Import / Commit) ไปยังระบบจริง
*/
export function useCommitMigrationReview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ idempotencyKey, ...payload }: CommitMigrationReviewRequest) => {
const response = await apiClient.post('/ai/migration/review', payload, {
headers: {
'Idempotency-Key': idempotencyKey,
},
});
return extractData<{ success: boolean; message: string; correspondencePublicId: string }>(response.data);
},
onSuccess: () => {
toast.success('นำเข้าเอกสารสำเร็จ', {
description: 'เอกสารได้รับการบันทึกเข้าระบบจริงเรียบร้อยแล้ว',
});
void queryClient.invalidateQueries({ queryKey: migrationReviewKeys.all });
},
onError: (error: unknown) => {
const errMsg = getApiErrorMessage(error, 'เกิดข้อผิดพลาดในการนำเข้าเอกสาร');
toast.error('ไม่สามารถนำเข้าเอกสารได้', {
description: errMsg,
});
},
});
}
/**
* Hook สำหรับปฏิเสธเอกสารใน Review Queue
*/
export function useRejectMigrationReview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await apiClient.post(`/migration/queue/${id}/reject`);
return extractData<{ message: string; id: number }>(response.data);
},
onSuccess: () => {
toast.success('ปฏิเสธเอกสารเรียบร้อย', {
description: 'สถานะเอกสารถูกตั้งค่าเป็น REJECTED',
});
void queryClient.invalidateQueries({ queryKey: migrationReviewKeys.all });
},
onError: (error: unknown) => {
const errMsg = getApiErrorMessage(error, 'เกิดข้อผิดพลาดในการปฏิเสธเอกสาร');
toast.error('ไม่สามารถปฏิเสธเอกสารได้', {
description: errMsg,
});
},
});
}
@@ -0,0 +1,17 @@
// File: types/dto/migration/migration-review.dto.ts
// Change Log:
// - 2026-05-22: Initial creation for US2 - Staging Migration Review Commit Types
// - 2026-05-22: Update to support hybrid ID (number | string) for projects and organizations per ADR-019
export interface CommitMigrationReviewDto {
publicId: string;
subject?: string;
category?: string;
projectId?: number | string;
senderId?: number | string;
receiverId?: number | string;
issuedDate?: string;
receivedDate?: string;
tags?: string[];
body?: string;
}
+10 -2
View File
@@ -1,7 +1,12 @@
// File: types/migration.ts
// Change Log:
// - 2026-05-22: Initial creation and update for ADR-019 compatibility and added subject fields
export enum MigrationReviewStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
IMPORTED = 'IMPORTED',
}
export interface MigrationReviewQueueItem {
@@ -10,9 +15,12 @@ export interface MigrationReviewQueueItem {
documentNumber: string;
title?: string;
originalTitle?: string;
subject?: string;
originalSubject?: string;
body?: string;
aiSuggestedCategory?: string;
aiConfidence?: number;
aiIssues?: Record<string, unknown>;
aiIssues?: Record<string, unknown>[];
reviewReason?: string;
status: MigrationReviewStatus;
reviewedBy?: string;
@@ -25,7 +33,7 @@ export interface MigrationReviewQueueItem {
issuedDate?: string;
remarks?: string;
aiSummary?: string;
extractedTags?: Record<string, unknown>;
extractedTags?: Record<string, unknown>[];
tempAttachmentId?: number | string; // ADR-019: Accept UUID
}