690414:1113 Update README.md /.agents/skills, /.windsurf/workflows
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
// ADR-021: IntegratedBanner — แสดง metadata เอกสาร + สถานะ Workflow + ปุ่ม Action ในแถวเดียว
|
||||
// ใช้ใน RFA, Correspondence, Transmittal, Circulation detail pages
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CheckCircle2, XCircle, RotateCcw, MessageSquare, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { WorkflowPriority } from '@/types/workflow';
|
||||
import { useWorkflowAction } from '@/hooks/use-workflow-action';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
|
||||
// สีของ Priority Badge (label ถูก resolve ผ่าน t() ใน component)
|
||||
const PRIORITY_CONFIG: Record<WorkflowPriority, { labelKey: string; className: string }> = {
|
||||
URGENT: { labelKey: 'workflow.priority.URGENT', className: 'bg-red-600 text-white animate-pulse' },
|
||||
HIGH: { labelKey: 'workflow.priority.HIGH', className: 'bg-orange-500 text-white' },
|
||||
MEDIUM: { labelKey: 'workflow.priority.MEDIUM', className: 'bg-yellow-500 text-white' },
|
||||
LOW: { labelKey: 'workflow.priority.LOW', className: 'bg-green-600 text-white' },
|
||||
};
|
||||
|
||||
// สีของ Status Badge
|
||||
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
const s = status?.toUpperCase();
|
||||
if (['APPROVED', 'COMPLETED', 'ISSUED'].includes(s)) return 'default';
|
||||
if (['REJECTED', 'CANCELLED'].includes(s)) return 'destructive';
|
||||
if (['DRAFT', 'DFT'].includes(s)) return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
// แสดงป้ายสีตาม Workflow State
|
||||
function getStateColor(state?: string): string {
|
||||
if (!state) return 'text-muted-foreground';
|
||||
const s = state.toUpperCase();
|
||||
if (s.includes('APPROV') || s.includes('COMPLET') || s.includes('ISSUED')) return 'text-green-600';
|
||||
if (s.includes('REJECT') || s.includes('CANCEL')) return 'text-red-600';
|
||||
if (s.includes('REVIEW') || s.includes('PENDING') || s.includes('IN_')) return 'text-blue-600';
|
||||
return 'text-amber-600';
|
||||
}
|
||||
|
||||
// Action button config (label ถูก resolve ผ่าน t() ใน component)
|
||||
const ACTION_CONFIG: Record<string, { labelKey: string; icon: React.ReactNode; variant: 'default' | 'destructive' | 'outline' | 'secondary'; requiresComment: boolean }> = {
|
||||
APPROVE: { labelKey: 'workflow.action.APPROVE', icon: <CheckCircle2 className="h-4 w-4" />, variant: 'default', requiresComment: false },
|
||||
REJECT: { labelKey: 'workflow.action.REJECT', icon: <XCircle className="h-4 w-4" />, variant: 'destructive', requiresComment: true },
|
||||
RETURN: { labelKey: 'workflow.action.RETURN', icon: <RotateCcw className="h-4 w-4" />, variant: 'outline', requiresComment: true },
|
||||
ACKNOWLEDGE: { labelKey: 'workflow.action.ACKNOWLEDGE', icon: <CheckCircle2 className="h-4 w-4" />, variant: 'secondary', requiresComment: false },
|
||||
COMMENT: { labelKey: 'workflow.action.COMMENT', icon: <MessageSquare className="h-4 w-4" />, variant: 'outline', requiresComment: true },
|
||||
};
|
||||
|
||||
export interface IntegratedBannerProps {
|
||||
docNo: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
priority?: WorkflowPriority;
|
||||
workflowState?: string;
|
||||
availableActions?: string[];
|
||||
/** Legacy prop — ใช้เมื่อไม่มี instanceId (Transmittal, Circulation) */
|
||||
onAction?: (action: string, comment?: string) => void;
|
||||
isLoading?: boolean;
|
||||
// ADR-021 T029: Workflow action wiring
|
||||
instanceId?: string;
|
||||
/** publicIds ของไฟล์ที่ user อัปโหลดใน WorkflowLifecycle Upload Zone */
|
||||
pendingAttachmentIds?: string[];
|
||||
/** เรียกเมื่อ action สำเร็จ (optional — React Query เปิด invalidate อัตโนมัติ) */
|
||||
onActionSuccess?: () => void;
|
||||
}
|
||||
|
||||
// Action button พร้อม Popover สำหรับ Comment
|
||||
function ActionButton({
|
||||
actionKey,
|
||||
onAction,
|
||||
disabled,
|
||||
t,
|
||||
}: {
|
||||
actionKey: string;
|
||||
onAction: (action: string, comment?: string) => void;
|
||||
disabled?: boolean;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const config = ACTION_CONFIG[actionKey] ?? {
|
||||
labelKey: actionKey,
|
||||
icon: null,
|
||||
variant: 'outline' as const,
|
||||
requiresComment: false,
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onAction(actionKey, comment || undefined);
|
||||
setComment('');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (!config.requiresComment) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={config.variant}
|
||||
disabled={disabled}
|
||||
onClick={() => onAction(actionKey)}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="ml-1">{t(config.labelKey)}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant={config.variant} disabled={disabled}>
|
||||
{config.icon}
|
||||
<span className="ml-1">{t(config.labelKey)}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t('workflow.action.commentLabel')}</p>
|
||||
<Textarea
|
||||
placeholder={t('workflow.action.commentPlaceholder')}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('workflow.action.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant={config.variant} onClick={handleSubmit}>
|
||||
{t('workflow.action.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntegratedBanner({
|
||||
docNo,
|
||||
subject,
|
||||
status,
|
||||
priority,
|
||||
workflowState,
|
||||
availableActions,
|
||||
onAction,
|
||||
isLoading = false,
|
||||
instanceId,
|
||||
pendingAttachmentIds,
|
||||
onActionSuccess,
|
||||
}: IntegratedBannerProps) {
|
||||
const t = useTranslations();
|
||||
// ADR-021 T029: hook สำหรับ Workflow transition (disabled อัตโนมัติถ้าไม่มี instanceId)
|
||||
const wfMutation = useWorkflowAction(instanceId);
|
||||
|
||||
// ถ้ามี instanceId ใช้ hook, ถ้าไม่มี fallback ไปยัง legacy onAction prop
|
||||
const handleAction = (action: string, comment?: string) => {
|
||||
if (instanceId) {
|
||||
wfMutation.mutate(
|
||||
{ action, comment, attachmentPublicIds: pendingAttachmentIds ?? [] },
|
||||
{ onSuccess: onActionSuccess }
|
||||
);
|
||||
} else {
|
||||
onAction?.(action, comment);
|
||||
}
|
||||
};
|
||||
|
||||
const isBusy = isLoading || wfMutation.isPending;
|
||||
const priorityConfig = priority ? PRIORITY_CONFIG[priority] : undefined;
|
||||
const hasActions = availableActions && availableActions.length > 0 && (instanceId || onAction);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-card px-4 py-3 shadow-sm">
|
||||
{/* แถวหลัก */}
|
||||
<div className="flex flex-wrap items-center gap-3 min-w-0">
|
||||
{/* เลขที่เอกสาร + หัวข้อ */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground shrink-0">{docNo || '—'}</span>
|
||||
<Separator orientation="vertical" className="h-4 hidden sm:block" />
|
||||
<span className="text-sm text-muted-foreground truncate">{subject || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges + Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
{/* Status Badge */}
|
||||
<Badge variant={getStatusVariant(status)} className="text-xs">
|
||||
{status || '—'}
|
||||
</Badge>
|
||||
|
||||
{/* Priority Badge */}
|
||||
{priorityConfig && (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${priorityConfig.className}`}>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t(priorityConfig.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Workflow State */}
|
||||
{workflowState && (
|
||||
<span className={`text-xs font-medium ${getStateColor(workflowState)}`}>
|
||||
[{workflowState}]
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action Buttons — แสดง spinner เมื่อ mutation กำลัง pending */}
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
hasActions &&
|
||||
availableActions!.map((actionKey) => (
|
||||
<ActionButton
|
||||
key={actionKey}
|
||||
actionKey={actionKey}
|
||||
onAction={handleAction}
|
||||
disabled={isBusy}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
// ADR-021: WorkflowLifecycle — แสดง Timeline การเดินเรื่องเอกสาร (US2, REQ-02, REQ-03)
|
||||
// แสดง Step ที่เสร็จแล้ว, Step ปัจจุบัน (active, มีสีพิเศษ), และ Step ที่ยังรอ
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { th } from 'date-fns/locale';
|
||||
import { CheckCircle2, XCircle, RotateCcw, MessageSquare, Clock, Loader2, Paperclip, Upload, X as XIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
import type { WorkflowAttachmentSummary, WorkflowHistoryItem } from '@/types/workflow';
|
||||
|
||||
// รูปแบบ response จาก POST /files/upload
|
||||
interface UploadedAttachment {
|
||||
publicId: string;
|
||||
originalFilename: string;
|
||||
}
|
||||
|
||||
interface WorkflowLifecycleProps {
|
||||
history?: WorkflowHistoryItem[];
|
||||
currentState?: string;
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
// ADR-021 US4: callback เมื่อ User คลิก attachment chip เพื่อ preview
|
||||
onFileClick?: (attachment: WorkflowAttachmentSummary) => void;
|
||||
// ADR-021 T028: callback เมื่อ publicIds ของไฟล์แนบ Step ปัจจุบันเปลี่ยน
|
||||
onAttachmentsChange?: (publicIds: string[]) => void;
|
||||
// ADR-021 T041: บอก publicIds ที่ API คืน 404 (ไฟล์ถูกลบออกจาก Storage)
|
||||
unavailableAttachmentIds?: string[];
|
||||
}
|
||||
|
||||
// Icon ตาม Action ประเภท (labels resolve ผ่าน t() ใน component)
|
||||
const ACTION_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
APPROVE: <CheckCircle2 className="h-4 w-4" />,
|
||||
REJECT: <XCircle className="h-4 w-4" />,
|
||||
RETURN: <RotateCcw className="h-4 w-4" />,
|
||||
COMMENT: <MessageSquare className="h-4 w-4" />,
|
||||
ACKNOWLEDGE: <CheckCircle2 className="h-4 w-4" />,
|
||||
SUBMIT: <Clock className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
// สีของ Node ตาม Action (REQ-03: Active Step Color)
|
||||
function getNodeStyle(action: string, isLatest: boolean): { wrapper: string; icon: string } {
|
||||
const a = action.toUpperCase();
|
||||
if (isLatest) {
|
||||
// Step ปัจจุบัน — เน้นด้วย primary ring
|
||||
return {
|
||||
wrapper: 'bg-primary/10 border-primary ring-2 ring-primary/30 ring-offset-2',
|
||||
icon: 'text-primary',
|
||||
};
|
||||
}
|
||||
if (a === 'APPROVE' || a === 'ACKNOWLEDGE') {
|
||||
return { wrapper: 'bg-green-50 border-green-300', icon: 'text-green-600' };
|
||||
}
|
||||
if (a === 'REJECT') {
|
||||
return { wrapper: 'bg-red-50 border-red-300', icon: 'text-red-600' };
|
||||
}
|
||||
if (a === 'RETURN') {
|
||||
return { wrapper: 'bg-amber-50 border-amber-300', icon: 'text-amber-600' };
|
||||
}
|
||||
return { wrapper: 'bg-blue-50 border-blue-200', icon: 'text-blue-600' };
|
||||
}
|
||||
|
||||
// แปลง Action key เป็น i18n key
|
||||
function getActionLabelKey(action: string): string {
|
||||
return `workflow.timeline.step.${action.toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function WorkflowLifecycle({
|
||||
history,
|
||||
currentState,
|
||||
isLoading = false,
|
||||
error,
|
||||
onFileClick,
|
||||
onAttachmentsChange,
|
||||
unavailableAttachmentIds,
|
||||
}: WorkflowLifecycleProps) {
|
||||
const unavailableSet = new Set(unavailableAttachmentIds ?? []);
|
||||
const t = useTranslations();
|
||||
// ADR-021 T028: สถานะการอัปโหลดไฟล์แนบประจำ Step ปัจจุบัน
|
||||
const [pendingFiles, setPendingFiles] = useState<UploadedAttachment[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// อัปโหลดไฟล์ผ่าน Two-Phase upload (POST /files/upload → temp)
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setIsUploading(true);
|
||||
|
||||
const newUploaded: UploadedAttachment[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res = await apiClient.post<{ data?: UploadedAttachment } & UploadedAttachment>(
|
||||
'/files/upload',
|
||||
formData
|
||||
);
|
||||
const att: UploadedAttachment = (res.data as { data?: UploadedAttachment }).data ?? (res.data as UploadedAttachment);
|
||||
if (att?.publicId) newUploaded.push(att);
|
||||
} catch {
|
||||
toast.error(`${t('workflow.timeline.uploadError')} "${file.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = [...pendingFiles, ...newUploaded];
|
||||
setPendingFiles(updated);
|
||||
onAttachmentsChange?.(updated.map((f) => f.publicId));
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
||||
function removeUploadedFile(publicId: string) {
|
||||
const updated = pendingFiles.filter((f) => f.publicId !== publicId);
|
||||
setPendingFiles(updated);
|
||||
onAttachmentsChange?.(updated.map((f) => f.publicId));
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 p-4 text-center text-sm text-muted-foreground">
|
||||
{t('workflow.timeline.loadError')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||
{t('workflow.timeline.noHistory')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-4 px-2">
|
||||
<div className="relative">
|
||||
{/* เส้นแนวตั้งเชื่อม Node */}
|
||||
<div
|
||||
className="absolute left-5 top-5 bottom-5 w-px bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<ol className="space-y-6">
|
||||
{history.map((item, index) => {
|
||||
const isLatest = index === history.length - 1;
|
||||
const nodeStyle = getNodeStyle(item.action, isLatest);
|
||||
const icon =
|
||||
ACTION_ICON_MAP[item.action.toUpperCase()] ?? (
|
||||
<Clock className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={item.id} className="relative flex gap-4">
|
||||
{/* Node Circle — REQ-03: active step มี ring */}
|
||||
<div
|
||||
className={`relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 ${nodeStyle.wrapper}`}
|
||||
>
|
||||
<span className={nodeStyle.icon}>{icon}</span>
|
||||
</div>
|
||||
|
||||
{/* เนื้อหา Step */}
|
||||
<div className="flex-1 min-w-0 pt-1.5 pb-2">
|
||||
{/* Action + State Transition */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">
|
||||
{t(getActionLabelKey(item.action))}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{item.fromState}
|
||||
<span className="mx-1 text-muted-foreground">→</span>
|
||||
{item.toState}
|
||||
</Badge>
|
||||
{isLatest && currentState && (
|
||||
<Badge className="text-xs bg-primary text-primary-foreground">
|
||||
{t('workflow.timeline.current')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ความเห็น */}
|
||||
{item.comment && (
|
||||
<p className="text-sm text-muted-foreground mb-1 italic">
|
||||
“{item.comment}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Attachment Chips — ADR-021 US4 / T041: unavailable chip */}
|
||||
{item.attachments && item.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{item.attachments.map((att) => {
|
||||
const isUnavailable = unavailableSet.has(att.publicId);
|
||||
return isUnavailable ? (
|
||||
<span
|
||||
key={att.publicId}
|
||||
className="inline-flex items-center gap-1 h-6 px-2 rounded border text-xs text-muted-foreground/50 line-through cursor-not-allowed select-none"
|
||||
title={t('workflow.timeline.fileUnavailable')}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{t('workflow.timeline.fileUnavailable')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={att.publicId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1"
|
||||
onClick={() => onFileClick?.(att)}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{att.originalFilename}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp + Actor */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(new Date(item.createdAt), 'dd MMM yyyy HH:mm', {
|
||||
locale: th,
|
||||
})}
|
||||
{item.actionByUserId && (
|
||||
<span className="ml-2 text-muted-foreground/70">
|
||||
· User #{item.actionByUserId}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* ADR-021 T028: Upload Zone — แสดงเฉพาะ Step ปัจจุบัน (เมื่อ parent ต้องการเก็บไฟล์แนบ) */}
|
||||
{onAttachmentsChange && (
|
||||
<div className="mt-4 px-2">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-5 text-center transition-colors cursor-pointer
|
||||
${isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/40'}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
void handleFileUpload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isUploading ? t('workflow.timeline.uploading') : t('workflow.timeline.uploadHint')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">{t('workflow.timeline.uploadTypes')}</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.docx,.xlsx,.dwg,.zip"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleFileUpload(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ไฟล์ที่อัปโหลดแล้ว (pending) */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{pendingFiles.map((f) => (
|
||||
<span
|
||||
key={f.publicId}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{f.originalFilename}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); removeUploadedFile(f.publicId); }}
|
||||
aria-label={t('workflow.timeline.uploadError')}
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user