690414:1113 Update README.md /.agents/skills, /.windsurf/workflows

This commit is contained in:
2026-04-14 11:13:42 +07:00
parent 02400fd88c
commit 6d45bdaeb5
194 changed files with 12708 additions and 8762 deletions
@@ -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;
}
}
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
&ldquo;{item.comment}&rdquo;
</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>
);
}