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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user