Files
lcbp3/frontend/components/migration/review-queue-table.tsx
T
admin a2973be208 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)
2026-05-22 17:10:07 +07:00

507 lines
23 KiB
TypeScript

// 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>
);
}