diff options
Diffstat (limited to 'components/ship-vendor-document')
| -rw-r--r-- | components/ship-vendor-document/edit-revision-dialog.tsx | 726 | ||||
| -rw-r--r-- | components/ship-vendor-document/user-vendor-document-table-container.tsx | 615 |
2 files changed, 1192 insertions, 149 deletions
diff --git a/components/ship-vendor-document/edit-revision-dialog.tsx b/components/ship-vendor-document/edit-revision-dialog.tsx new file mode 100644 index 00000000..313a27bc --- /dev/null +++ b/components/ship-vendor-document/edit-revision-dialog.tsx @@ -0,0 +1,726 @@ +"use client" + +import React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Edit, + FileText, + Loader2, + AlertTriangle, + Trash2, + CheckCircle, + Clock +} from "lucide-react" +import { toast } from "sonner" +import { updateRevisionAction, deleteRevisionAction } from "@/lib/vendor-document-list/enhanced-document-service" // ✅ 서버 액션 import + +/* ------------------------------------------------------------------------------------------------- + * Schema & Types + * -----------------------------------------------------------------------------------------------*/ + +interface RevisionInfo { + id: number + issueStageId: number + revision: string + uploaderType: string + uploaderId: number | null + uploaderName: string | null + comment: string | null + usage: string | null + usageType: string | null + revisionStatus: string + submittedDate: string | null + approvedDate: string | null + uploadedAt: string | null + reviewStartDate: string | null + rejectedDate: string | null + reviewerId: number | null + reviewerName: string | null + reviewComments: string | null + createdAt: Date + updatedAt: Date + stageName?: string + attachments: AttachmentInfo[] +} + +interface AttachmentInfo { + id: number + revisionId: number + fileName: string + filePath: string + dolceFilePath: string | null + fileSize: number | null + fileType: string | null + createdAt: Date + updatedAt: Date +} + +// drawingKind에 따른 동적 스키마 생성 (수정용) +const createEditRevisionSchema = (drawingKind: string) => { + const baseSchema = { + usage: z.string().min(1, "Please select a usage"), + revision: z.string().min(1, "Please enter a revision").max(50, "Revision must be 50 characters or less"), // ✅ revision 필드 추가 + comment: z.string().optional(), + } + + // B3인 경우에만 usageType 필드 추가 + if (drawingKind === 'B3') { + return z.object({ + ...baseSchema, + usageType: z.string().min(1, "Please select a usage type"), + }) + } else { + return z.object({ + ...baseSchema, + usageType: z.string().optional(), + }) + } +} + +// drawingKind에 따른 용도 옵션 +const getUsageOptions = (drawingKind: string) => { + switch (drawingKind) { + case 'B3': + return [ + { value: "Approval", label: "Approval" }, + { value: "Working", label: "Working" }, + { value: "Comments", label: "Comments" }, + ] + case 'B4': + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + case 'B5': + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + default: + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + } +} + +// B3 전용 용도 타입 옵션 +const getUsageTypeOptions = (usage: string) => { + switch (usage) { + case 'Approval': + return [ + { value: "Full", label: "Full" }, + { value: "Partial", label: "Partial" }, + ] + case 'Working': + return [ + { value: "Full", label: "Full" }, + { value: "Partial", label: "Partial" }, + ] + case 'Comments': + return [ + { value: "Comments", label: "Comments" }, + ] + default: + return [] + } +} + +// ✅ 리비전 가이드 생성 (NewRevisionDialog와 동일) +const getRevisionGuide = () => { + return "Enter in R01, R02, R03... format" +} + +interface EditRevisionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + revision: RevisionInfo | null + drawingKind?: string + onSuccess: (action: 'update' | 'delete', result?: any) => void +} + +/* ------------------------------------------------------------------------------------------------- + * Revision Info Display Component + * -----------------------------------------------------------------------------------------------*/ +function RevisionInfoDisplay({ revision }: { revision: RevisionInfo }) { + const canEdit = React.useMemo(() => { + if (!revision.attachments || revision.attachments.length === 0) { + return true + } + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, [revision.attachments]) + + const processedCount = revision.attachments?.filter(attachment => + attachment.dolceFilePath && attachment.dolceFilePath.trim() !== '' + ).length || 0 + + const getStatusColor = (status: string) => { + switch (status) { + case 'APPROVED': return 'bg-green-100 text-green-800' + case 'UPLOADED': return 'bg-blue-100 text-blue-800' + case 'REJECTED': return 'bg-red-100 text-red-800' + default: return 'bg-gray-100 text-gray-800' + } + } + + return ( + <div className="space-y-3 p-4 bg-gray-50 rounded-lg border"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Badge variant="outline" className="text-base font-mono"> + {revision.revision} + </Badge> + <Badge className={`text-xs ${getStatusColor(revision.revisionStatus)}`}> + {revision.revisionStatus} + </Badge> + {!canEdit && ( + <Badge variant="secondary" className="text-xs"> + Partially Processed + </Badge> + )} + </div> + + <div className="flex items-center gap-2 text-sm text-gray-600"> + <FileText className="h-4 w-4" /> + <span>{revision.attachments?.length || 0} file(s)</span> + {processedCount > 0 && ( + <span className="text-blue-600"> + ({processedCount} processed) + </span> + )} + </div> + </div> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="text-gray-500">Uploader:</span> + <span className="ml-2 font-medium">{revision.uploaderName || '-'}</span> + </div> + <div> + <span className="text-gray-500">Upload Date:</span> + <span className="ml-2"> + {revision.uploadedAt + ? new Date(revision.uploadedAt).toLocaleDateString() + : '-' + } + </span> + </div> + </div> + + {revision.comment && ( + <div className="pt-2 border-t"> + <span className="text-gray-500 text-sm">Current Comment:</span> + <p className="mt-1 text-sm bg-white p-2 rounded border"> + {revision.comment} + </p> + </div> + )} + + {!canEdit && ( + <div className="flex items-center gap-2 p-2 bg-yellow-50 border border-yellow-200 rounded text-sm"> + <AlertTriangle className="h-4 w-4 text-yellow-600" /> + <span className="text-yellow-800"> + Some files have been processed. Editing is limited. + </span> + </div> + )} + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Dialog Component + * -----------------------------------------------------------------------------------------------*/ +export function EditRevisionDialog({ + open, + onOpenChange, + revision, + drawingKind = 'B4', + onSuccess +}: EditRevisionDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isDeleting, setIsDeleting] = React.useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false) + + // drawingKind에 따른 동적 스키마 및 옵션 생성 + const editRevisionSchema = React.useMemo(() => createEditRevisionSchema(drawingKind), [drawingKind]) + const usageOptions = React.useMemo(() => getUsageOptions(drawingKind), [drawingKind]) + const showUsageType = drawingKind === 'B3' + + type EditRevisionSchema = z.infer<typeof editRevisionSchema> + + const form = useForm<EditRevisionSchema>({ + resolver: zodResolver(editRevisionSchema), + defaultValues: { + usage: "", + revision: "", // ✅ revision 기본값 추가 + comment: "", + usageType: showUsageType ? "" : undefined, + }, + }) + + const watchedUsage = form.watch("usage") + + // 용도 선택에 따른 용도 타입 옵션 업데이트 + const usageTypeOptions = React.useMemo(() => { + if (drawingKind === 'B3' && watchedUsage) { + return getUsageTypeOptions(watchedUsage) + } + return [] + }, [drawingKind, watchedUsage]) + + // ✅ 리비전 가이드 텍스트 + const revisionGuide = React.useMemo(() => { + return getRevisionGuide() + }, []) + + // revision이 변경될 때 폼 데이터 초기화 + React.useEffect(() => { + if (revision) { + form.reset({ + usage: revision.usage || "", + revision: revision.revision || "", // ✅ revision 값 설정 + comment: revision.comment || "", + usageType: showUsageType ? (revision.usageType || "") : undefined, + }) + } + }, [revision, showUsageType, form]) + + // 용도 변경 시 용도 타입 초기화 또는 자동 설정 (NewRevisionDialog와 동일한 로직) + React.useEffect(() => { + if (showUsageType && watchedUsage) { + if (watchedUsage === "Comments") { + form.setValue("usageType", "Comments") + } else { + // Comments가 아닌 경우, 초기 로드가 아니라면 초기화 + const currentValue = form.getValues("usageType") + if (revision && watchedUsage !== revision.usage) { + form.setValue("usageType", "") + } + } + } + }, [watchedUsage, showUsageType, form, revision]) + + // 수정 가능 여부 확인 + const canEdit = React.useMemo(() => { + if (!revision?.attachments || revision.attachments.length === 0) { + return true + } + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, [revision?.attachments]) + + // 폼 변경 체크 + const hasChanges = React.useMemo(() => { + if (!revision) return false + const currentValues = form.getValues() + return ( + currentValues.comment !== (revision.comment || '') || + currentValues.usage !== (revision.usage || '') || + currentValues.revision !== (revision.revision || '') || // ✅ revision 변경 체크 추가 + (showUsageType && currentValues.usageType !== (revision.usageType || '')) + ) + }, [revision, form, showUsageType]) + + const handleDialogClose = () => { + if (!isLoading && !isDeleting) { + setShowDeleteConfirm(false) + form.reset() + onOpenChange(false) + } + } + + const onSubmit = async (data: EditRevisionSchema) => { + if (!revision || !canEdit) { + toast.error("Cannot edit this revision") + return + } + + setIsLoading(true) + + try { + // ✅ 서버 액션 호출 - revision 필드 추가 + const result = await updateRevisionAction({ + revisionId: revision.id, + revision: data.revision.trim(), // ✅ revision 추가 + comment: data.comment?.trim() || null, + usage: data.usage.trim(), + usageType: showUsageType && 'usageType' in data ? data.usageType?.trim() || null : null, + }) + + if (!result.success) { + throw new Error(result.error || 'Failed to update revision') + } + + toast.success( + result.message || + `Revision ${data.revision} updated successfully` // ✅ 새 revision 값 사용 + ) + + setTimeout(() => { + handleDialogClose() + onSuccess('update', result) + }, 1000) + + } catch (error) { + console.error('❌ Update error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred during update") + } finally { + setIsLoading(false) + } + } + + const handleDelete = async () => { + if (!revision || !canEdit) { + toast.error("Cannot delete this revision") + return + } + + setIsDeleting(true) + + try { + // ✅ 서버 액션 호출 + const result = await deleteRevisionAction({ + revisionId: revision.id, + }) + + if (!result.success) { + throw new Error(result.error || 'Failed to delete revision') + } + + toast.success( + result.message || + `Revision ${revision.revision} deleted successfully` + ) + + setTimeout(() => { + handleDialogClose() + onSuccess('delete', result) + }, 1000) + + } catch (error) { + console.error('❌ Delete error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred during deletion") + } finally { + setIsDeleting(false) + } + } + + if (!revision) return null + + return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-2xl h-[85vh] flex flex-col overflow-hidden"> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0 pb-4 border-b"> + <DialogTitle className="flex items-center gap-2"> + <Edit className="h-5 w-5" /> + Edit Revision + </DialogTitle> + <DialogDescription className="text-sm"> + Modify revision details and metadata + </DialogDescription> + </DialogHeader> + + {!showDeleteConfirm ? ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden"> + {/* 스크롤 가능한 중간 영역 */} + <div className="flex-1 overflow-y-auto px-1 py-4 space-y-6"> + + {/* 리비전 정보 표시 */} + <RevisionInfoDisplay revision={revision} /> + + {/* ✅ 리비전 필드 추가 */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">Revision</FormLabel> + <FormControl> + <Input + placeholder={revisionGuide} + disabled={!canEdit} + {...field} + /> + </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {revisionGuide} + </div> + <FormMessage /> + </FormItem> + )} + /> + + {/* 용도 선택 */} + <FormField + control={form.control} + name="usage" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">Usage</FormLabel> + <Select onValueChange={field.onChange} value={field.value} disabled={!canEdit}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select usage" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 용도 타입 선택 (B3만) */} + {showUsageType && watchedUsage && ( + <FormField + control={form.control} + name="usageType" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">Usage Type</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value || ""} + disabled={!canEdit || watchedUsage === "Comments"} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select usage type" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageTypeOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + {watchedUsage === "Comments" && ( + <div className="text-xs text-muted-foreground mt-1"> + Automatically set to "Comments" for this usage + </div> + )} + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 코멘트 */} + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter description or changes for this revision (optional)" + className="resize-none" + rows={4} + disabled={!canEdit} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 첨부파일 목록 */} + {revision.attachments && revision.attachments.length > 0 && ( + <div className="space-y-2"> + <FormLabel>Attachments ({revision.attachments.length})</FormLabel> + <div className="space-y-2 max-h-32 overflow-y-auto border rounded-lg p-3"> + {revision.attachments.map((file, index) => ( + <div + key={file.id} + className="flex items-center justify-between p-2 bg-gray-50 rounded text-sm" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-gray-500 flex-shrink-0" /> + <span className="truncate" title={file.fileName}> + {file.fileName} + </span> + </div> + {file.dolceFilePath && file.dolceFilePath.trim() !== '' && ( + <Badge variant="secondary" className="text-xs ml-2"> + Processed + </Badge> + )} + </div> + ))} + </div> + </div> + )} + + {/* 성공/로딩 상태 */} + {isLoading && ( + <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded"> + <Loader2 className="h-4 w-4 animate-spin text-blue-600" /> + <span className="text-blue-800 text-sm">Updating revision...</span> + </div> + )} + </div> + + {/* 고정 버튼 영역 */} + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <div className="flex items-center justify-between w-full"> + {/* 삭제 버튼 */} + <Button + type="button" + variant="outline" + onClick={() => setShowDeleteConfirm(true)} + disabled={isLoading || !canEdit} + className="text-red-600 border-red-200 hover:bg-red-50" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </Button> + + {/* 저장/취소 버튼 */} + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isLoading} + > + Cancel + </Button> + <Button + type="submit" + disabled={isLoading || !hasChanges || !canEdit} + className="min-w-[120px]" + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Updating... + </> + ) : ( + <> + <CheckCircle className="mr-2 h-4 w-4" /> + Update + </> + )} + </Button> + </div> + </div> + </DialogFooter> + </form> + </Form> + ) : ( + // 삭제 확인 화면 + <div className="flex flex-col flex-1 overflow-hidden"> + <div className="flex-1 overflow-y-auto px-1 py-4"> + <div className="space-y-4"> + <div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg"> + <AlertTriangle className="h-6 w-6 text-red-600 flex-shrink-0" /> + <div> + <h4 className="font-medium text-red-900">Delete Revision</h4> + <p className="text-sm text-red-700 mt-1"> + Are you sure you want to delete revision <strong>{revision.revision}</strong>? + </p> + </div> + </div> + + <div className="space-y-3 text-sm text-gray-600"> + <p>This action will permanently delete:</p> + <ul className="list-disc list-inside space-y-1 ml-4"> + <li>Revision metadata and settings</li> + <li>All {revision.attachments?.length || 0} attached file(s)</li> + <li>Upload history and comments</li> + </ul> + <p className="font-medium text-red-600"> + This action cannot be undone. + </p> + </div> + + {/* 성공/로딩 상태 */} + {isDeleting && ( + <div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded"> + <Loader2 className="h-4 w-4 animate-spin text-red-600" /> + <span className="text-red-800 text-sm">Deleting revision...</span> + </div> + )} + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <div className="flex gap-2 w-full justify-end"> + <Button + variant="outline" + onClick={() => setShowDeleteConfirm(false)} + disabled={isDeleting} + > + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={isDeleting} + className="min-w-[120px]" + > + {isDeleting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Deleting... + </> + ) : ( + <> + <Trash2 className="mr-2 h-4 w-4" /> + Delete Revision + </> + )} + </Button> + </div> + </DialogFooter> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/ship-vendor-document/user-vendor-document-table-container.tsx b/components/ship-vendor-document/user-vendor-document-table-container.tsx index 4e133696..61d52c28 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -27,7 +27,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { Building, FileText, AlertCircle, Eye, Download, Loader2, Plus } from "lucide-react" +import { Building, FileText, AlertCircle, Eye, Download, Loader2, Plus, Trash2, Edit } from "lucide-react" import { SimplifiedDocumentsTable } from "@/lib/vendor-document-list/ship/enhanced-documents-table" import { getUserVendorDocuments, @@ -38,6 +38,7 @@ import { WebViewerInstance } from "@pdftron/webviewer" import { NewRevisionDialog } from "./new-revision-dialog" import { useRouter } from 'next/navigation' import { AddAttachmentDialog } from "./add-attachment-dialog" // ✅ import 추가 +import { EditRevisionDialog } from "./edit-revision-dialog" // ✅ 추가 /* ------------------------------------------------------------------------------------------------- * Types & Constants @@ -91,6 +92,7 @@ interface AttachmentInfo { revisionId: number fileName: string filePath: string + dolceFilePath: string | null fileSize: number | null fileType: string | null createdAt: Date @@ -129,7 +131,7 @@ export const DocumentSelectionContext = React.createContext<DocumentSelectionCon function getUsageTypeDisplay(usageType: string | null): string { if (!usageType) return '-' - + // B3 용도 타입 축약 표시 const abbreviations: Record<string, string> = { 'Approval Submission Full': 'AS-F', @@ -143,18 +145,20 @@ function getUsageTypeDisplay(usageType: string | null): string { 'Reference Series Full': 'RS-F', 'Reference Series Partial': 'RS-P', } - + return abbreviations[usageType] || usageType } -function RevisionTable({ - revisions, +function RevisionTable({ + revisions, onViewRevision, - onNewRevision -}: { + onNewRevision, + onEditRevision, // ✅ 수정 함수 prop 추가 +}: { revisions: RevisionInfo[] onViewRevision: (revision: RevisionInfo) => void onNewRevision: () => void + onEditRevision: (revision: RevisionInfo) => void // ✅ 수정 함수 타입 추가 }) { const { selectedRevisionId, setSelectedRevisionId } = React.useContext(DocumentSelectionContext) @@ -163,6 +167,38 @@ function RevisionTable({ setSelectedRevisionId(revisionId === selectedRevisionId ? null : revisionId) } + // ✅ 리비전 수정 가능 여부 확인 함수 + const canEditRevision = React.useCallback((revision: RevisionInfo) => { + // 첨부파일이 없으면 수정 가능 + if ((!revision.attachments || revision.attachments.length === 0)&&revision.uploaderType ==="vendor") { + return true + } + + // 모든 첨부파일의 dolceFilePath가 null이거나 빈값이어야 수정 가능 + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, []) + + // ✅ 리비전 상태 표시 함수 (처리된 파일이 있는지 확인) + const getRevisionProcessStatus = React.useCallback((revision: RevisionInfo) => { + if (!revision.attachments || revision.attachments.length === 0) { + return 'no-files' + } + + const processedCount = revision.attachments.filter(attachment => + attachment.dolceFilePath && attachment.dolceFilePath.trim() !== '' + ).length + + if (processedCount === 0) { + return 'not-processed' + } else if (processedCount === revision.attachments.length) { + return 'fully-processed' + } else { + return 'partially-processed' + } + }, []) + return ( <Card className="flex-1"> <CardHeader> @@ -182,14 +218,14 @@ function RevisionTable({ </CardHeader> <CardContent> <div className="overflow-x-auto"> - <Table className="tbl-compact"> + <Table className="tbl-compact"> <TableHeader> <TableRow> <TableHead className="w-12">Select</TableHead> <TableHead>Revision</TableHead> <TableHead>Category</TableHead> <TableHead>Usage</TableHead> - <TableHead>Type</TableHead> {/* ✅ usageType 컬럼 */} + <TableHead>Type</TableHead> <TableHead>Status</TableHead> <TableHead>Uploader</TableHead> <TableHead>Comment</TableHead> @@ -199,94 +235,144 @@ function RevisionTable({ </TableRow> </TableHeader> <TableBody> - {revisions.map((revision) => ( - <TableRow - key={revision.id} - className={`revision-table-row ${ - selectedRevisionId === revision.id ? 'selected' : '' - }`} - > - <TableCell> - <input - type="checkbox" - checked={selectedRevisionId === revision.id} - onChange={() => toggleSelect(revision.id)} - className="h-4 w-4 cursor-pointer" - /> - </TableCell> - <TableCell className="font-mono font-medium"> - {revision.revision} - </TableCell> - <TableCell className="text-sm"> - {revision.uploaderType === "vendor" ? "To SHI" : "From SHI"} - </TableCell> - <TableCell> - <span className="text-sm"> - {revision.usage || '-'} - </span> - </TableCell> - {/* ✅ usageType 표시 */} - <TableCell> - <span className="text-sm"> - {revision.usageType ? - + {revisions.map((revision) => { + const canEdit = canEditRevision(revision) + const processStatus = getRevisionProcessStatus(revision) + + return ( + <TableRow + key={revision.id} + className={`revision-table-row ${selectedRevisionId === revision.id ? 'selected' : '' + }`} + > + <TableCell> + <input + type="checkbox" + checked={selectedRevisionId === revision.id} + onChange={() => toggleSelect(revision.id)} + className="h-4 w-4 cursor-pointer" + /> + </TableCell> + <TableCell className="font-mono font-medium"> + <div className="flex items-center gap-2"> + {revision.revision} + {/* ✅ 처리 상태 인디케이터 */} + {processStatus === 'fully-processed' && ( + <div + className="w-2 h-2 bg-blue-500 rounded-full" + title="All files processed" + /> + )} + {processStatus === 'partially-processed' && ( + <div + className="w-2 h-2 bg-yellow-500 rounded-full" + title="Some files processed" + /> + )} + </div> + </TableCell> + <TableCell className="text-sm"> + {revision.uploaderType === "vendor" ? "To SHI" : "From SHI"} + </TableCell> + <TableCell> + <span className="text-sm"> + {revision.usage || '-'} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {revision.usageType ? ( revision.usageType - - : ( + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </span> + </TableCell> + <TableCell> + <Badge + variant={ + revision.revisionStatus === 'APPROVED' + ? 'default' + : 'secondary' + } + className="text-xs" + > + {revision.revisionStatus} + </Badge> + </TableCell> + <TableCell> + <span className="text-sm">{revision.uploaderName || '-'}</span> + </TableCell> + <TableCell className="py-1 px-2"> + {revision.comment ? ( + <div className="max-w-24"> + <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> + {revision.comment} + </p> + </div> + ) : ( <span className="text-gray-400 text-xs">-</span> )} - </span> - </TableCell> - <TableCell> - <Badge - variant={ - revision.revisionStatus === 'APPROVED' - ? 'default' - : 'secondary' - } - className="text-xs" - > - {revision.revisionStatus} - </Badge> - </TableCell> - <TableCell> - <span className="text-sm">{revision.uploaderName || '-'}</span> - </TableCell> - <TableCell className="py-1 px-2"> - {revision.comment ? ( - <div className="max-w-24"> - <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> - {revision.comment} - </p> + </TableCell> + <TableCell> + <span className="text-sm"> + {revision.uploadedAt + ? new Date(revision.uploadedAt).toLocaleDateString() + : '-'} + </span> + </TableCell> + <TableCell className="text-center"> + <div className="flex items-center justify-center gap-1"> + <span>{revision.attachments.length}</span> + {/* ✅ 처리된 파일 수 표시 */} + {processStatus === 'partially-processed' && ( + <span className="text-xs text-gray-500"> + ({revision.attachments.filter(att => + att.dolceFilePath && att.dolceFilePath.trim() !== '' + ).length} processed) + </span> + )} </div> - ) : ( - <span className="text-gray-400 text-xs">-</span> - )} - </TableCell> - <TableCell> - <span className="text-sm"> - {revision.uploadedAt - ? new Date(revision.uploadedAt).toLocaleDateString() - : '-'} - </span> - </TableCell> - <TableCell className="text-center"> - {revision.attachments.length} - </TableCell> - <TableCell> - {revision.attachments.length > 0 && ( - <Button - variant="ghost" - size="sm" - onClick={() => onViewRevision(revision)} - className="h-8 px-2" - > - <Eye className="h-4 w-4" /> - </Button> - )} - </TableCell> - </TableRow> - ))} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {/* 보기 버튼 */} + {revision.attachments.length > 0 && ( + <Button + variant="ghost" + size="sm" + onClick={() => onViewRevision(revision)} + className="h-8 px-2" + title="View attachments" + > + <Eye className="h-4 w-4" /> + </Button> + )} + + {/* ✅ 수정 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => onEditRevision(revision)} + className={`h-8 px-2 ${ + canEdit + ? 'text-blue-600 hover:text-blue-700 hover:bg-blue-50' + : 'text-gray-400 cursor-not-allowed' + }`} + disabled={!canEdit} + title={ + canEdit + ? 'Edit revision' + : 'Cannot edit - some files have been processed' + } + > + <Edit className="h-4 w-4" /> + </Button> + </div> + </TableCell> + </TableRow> + ) + })} </TableBody> </Table> </div> @@ -295,21 +381,24 @@ function RevisionTable({ ) } -function AttachmentTable({ - attachments, - onDownloadFile -}: { +function AttachmentTable({ + attachments, + onDownloadFile, + onDeleteFile, // ✅ 삭제 함수 prop 추가 +}: { attachments: AttachmentInfo[] onDownloadFile: (attachment: AttachmentInfo) => void + onDeleteFile: (attachment: AttachmentInfo) => Promise<void> // ✅ 삭제 함수 추가 }) { const { selectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContext) - const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) // ✅ 추가 - const router = useRouter() // ✅ 추가 + const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) + const [deletingFileId, setDeletingFileId] = React.useState<number | null>(null) // ✅ 삭제 중인 파일 ID + const router = useRouter() - // ✅ 선택된 리비전 정보 가져오기 + // 선택된 리비전 정보 가져오기 const selectedRevisionInfo = React.useMemo(() => { if (!selectedRevisionId || !allData) return null - + for (const doc of allData) { if (doc.allStages) { for (const stage of doc.allStages as StageInfo[]) { @@ -321,14 +410,43 @@ function AttachmentTable({ return null }, [selectedRevisionId, allData]) - // ✅ 첨부파일 추가 핸들러 + // 첨부파일 추가 핸들러 const handleAddAttachment = React.useCallback(() => { if (selectedRevisionInfo) { setAddAttachmentDialogOpen(true) } }, [selectedRevisionInfo]) - // ✅ 첨부파일 업로드 성공 핸들러 + // ✅ 삭제 가능 여부 확인 함수 + const canDeleteFile = React.useCallback((attachment: AttachmentInfo) => { + return !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + }, []) + + // ✅ 파일 삭제 핸들러 + const handleDeleteFile = React.useCallback(async (attachment: AttachmentInfo) => { + if (!canDeleteFile(attachment)) { + alert('This file cannot be deleted because it has been processed by the system.') + return + } + + const confirmDelete = window.confirm( + `Are you sure you want to delete "${attachment.fileName}"?\nThis action cannot be undone.` + ) + + if (!confirmDelete) return + + try { + setDeletingFileId(attachment.id) + await onDeleteFile(attachment) + } catch (error) { + console.error('Delete file error:', error) + alert(`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setDeletingFileId(null) + } + }, [canDeleteFile, onDeleteFile]) + + // 첨부파일 업로드 성공 핸들러 const handleAttachmentUploadSuccess = React.useCallback((uploadResult?: any) => { if (!selectedRevisionId || !allData || !uploadResult?.data) { console.log('🔄 Full refresh') @@ -343,6 +461,7 @@ function AttachmentTable({ revisionId: selectedRevisionId, fileName: file.fileName, filePath: file.filePath, + dolceFilePath: null, // ✅ 새 파일은 dolceFilePath가 없음 fileSize: file.fileSize, fileType: file.fileType || null, createdAt: new Date(), @@ -352,10 +471,10 @@ function AttachmentTable({ // allData에서 해당 리비전을 찾아서 첨부파일 추가 const updatedData = allData.map(doc => { const updatedDoc = { ...doc } - + if (updatedDoc.allStages) { const stages = [...updatedDoc.allStages as StageInfo[]] - + for (const stage of stages) { const revisionIndex = stage.revisions.findIndex(r => r.id === selectedRevisionId) if (revisionIndex !== -1) { @@ -369,7 +488,7 @@ function AttachmentTable({ } } } - + return updatedDoc }) @@ -393,7 +512,7 @@ function AttachmentTable({ <CardHeader> <div className="flex items-center justify-between"> <CardTitle className="text-lg">Attachments</CardTitle> - {/* ✅ + 버튼 추가 */} + {/* + 버튼 */} {selectedRevisionId && selectedRevisionInfo && ( <Button onClick={handleAddAttachment} @@ -408,7 +527,7 @@ function AttachmentTable({ </div> </CardHeader> <CardContent> - <Table className="tbl-compact"> + <Table className="tbl-compact"> <TableHeader> <TableRow> <TableHead>File Name</TableHead> @@ -426,7 +545,7 @@ function AttachmentTable({ ? 'Please select a revision' : 'No attached files'} </span> - {/* ✅ 리비전이 선택된 경우 추가 버튼 표시 */} + {/* 리비전이 선택된 경우 추가 버튼 표시 */} {selectedRevisionId && selectedRevisionInfo && ( <Button onClick={handleAddAttachment} @@ -456,17 +575,51 @@ function AttachmentTable({ : `${(file.fileSize / 1024).toFixed(1)}KB` : '-'} </div> + {/* ✅ dolceFilePath 상태 표시 */} + {file.dolceFilePath && file.dolceFilePath.trim() !== '' && ( + <div className="text-xs text-blue-600 font-medium"> + Processed + </div> + )} </div> </TableCell> <TableCell> - <Button - variant="ghost" - size="sm" - onClick={() => onDownloadFile(file)} - className="h-8 px-2" - > - <Download className="h-4 w-4" /> - </Button> + <div className="flex items-center gap-1"> + {/* 다운로드 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => onDownloadFile(file)} + className="h-8 px-2" + title="Download file" + > + <Download className="h-4 w-4" /> + </Button> + + {/* ✅ 삭제 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleDeleteFile(file)} + className={`h-8 px-2 ${ + canDeleteFile(file) + ? 'text-red-600 hover:text-red-700 hover:bg-red-50' + : 'text-gray-400 cursor-not-allowed' + }`} + disabled={!canDeleteFile(file) || deletingFileId === file.id} + title={ + canDeleteFile(file) + ? 'Delete file' + : 'Cannot delete processed file' + } + > + {deletingFileId === file.id ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Trash2 className="h-4 w-4" /> + )} + </Button> + </div> </TableCell> </TableRow> )) @@ -476,7 +629,7 @@ function AttachmentTable({ </CardContent> </Card> - {/* ✅ AddAttachmentDialog 추가 */} + {/* AddAttachmentDialog */} {selectedRevisionInfo && ( <AddAttachmentDialog open={addAttachmentDialogOpen} @@ -490,12 +643,10 @@ function AttachmentTable({ ) } -/* ------------------------------------------------------------------------------------------------- - * Derived Sub Tables Wrapper - * -----------------------------------------------------------------------------------------------*/ +// SubTables 컴포넌트 - 중복 정의 제거 및 통합 function SubTables() { const router = useRouter() - const { selectedDocumentId, selectedRevisionId, allData, setAllData } = // ✅ setAllData 추가 + const { selectedDocumentId, selectedRevisionId, setSelectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContext) // PDF 뷰어 상태 관리 @@ -509,11 +660,166 @@ function SubTables() { const isCancelled = React.useRef(false) const [newRevisionDialogOpen, setNewRevisionDialogOpen] = React.useState(false) + + // ✅ 리비전 수정 다이얼로그 상태 + const [editRevisionDialogOpen, setEditRevisionDialogOpen] = React.useState(false) + const [editingRevision, setEditingRevision] = React.useState<RevisionInfo | null>(null) const handleNewRevision = React.useCallback(() => { setNewRevisionDialogOpen(true) }, []) + // ✅ 리비전 수정 핸들러 + const handleEditRevision = React.useCallback((revision: RevisionInfo) => { + setEditingRevision(revision) + setEditRevisionDialogOpen(true) + }, []) + + // ✅ 리비전 수정 성공 핸들러 + const handleRevisionEditSuccess = React.useCallback((action: 'update' | 'delete', result?: any) => { + if (!allData || !editingRevision) { + // fallback: 전체 새로고침 + setTimeout(() => router.refresh(), 500) + return + } + + try { + if (action === 'delete') { + // 리비전 삭제: allData에서 해당 리비전 제거 + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === editingRevision.id) + if (revisionIndex !== -1) { + // 해당 리비전 제거 + stage.revisions.splice(revisionIndex, 1) + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + + // 삭제된 리비전이 선택되어 있었으면 선택 해제 + if (selectedRevisionId === editingRevision.id) { + setSelectedRevisionId(null) + } + + console.log('✅ Revision deleted and state updated') + + } else if (action === 'update') { + // 리비전 업데이트: allData에서 해당 리비전 정보 수정 + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === editingRevision.id) + if (revisionIndex !== -1) { + // 해당 리비전 업데이트 + stage.revisions[revisionIndex] = { + ...stage.revisions[revisionIndex], + comment: result?.updatedRevision?.comment || stage.revisions[revisionIndex].comment, + usage: result?.updatedRevision?.usage || stage.revisions[revisionIndex].usage, + usageType: result?.updatedRevision?.usageType || stage.revisions[revisionIndex].usageType, + updatedAt: new Date(), + } + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + console.log('✅ Revision updated and state updated') + } + + // 약간의 지연 후 서버 데이터 새로고침 + setTimeout(() => { + router.refresh() + }, 1000) + + } catch (error) { + console.error('❌ Revision edit state update failed:', error) + // 실패 시 전체 새로고침 + setTimeout(() => router.refresh(), 500) + } + }, [allData, editingRevision, setAllData, selectedRevisionId, setSelectedRevisionId, router]) + + // 파일 삭제 함수 + const handleDeleteFile = React.useCallback(async (attachment: AttachmentInfo) => { + try { + const response = await fetch(`/api/attachment-delete`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + attachmentId: attachment.id, + revisionId: attachment.revisionId, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to delete file.') + } + + // 성공시 로컬 상태 업데이트 + if (allData && selectedRevisionId) { + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === selectedRevisionId) + if (revisionIndex !== -1) { + // 해당 리비전에서 첨부파일 제거 + stage.revisions[revisionIndex] = { + ...stage.revisions[revisionIndex], + attachments: stage.revisions[revisionIndex].attachments.filter( + att => att.id !== attachment.id + ) + } + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + console.log('✅ File deleted and state updated') + } + + // 약간의 지연 후 서버 데이터 새로고침 + setTimeout(() => { + router.refresh() + }, 1000) + + } catch (error) { + console.error('Delete file error:', error) + throw error // AttachmentTable에서 에러 핸들링 + } + }, [allData, selectedRevisionId, setAllData, router]) + const handleRevisionUploadSuccess = React.useCallback(async (uploadResult?: any) => { if (!selectedDocumentId || !allData || !uploadResult?.data) { // fallback: 전체 새로고침 @@ -530,7 +836,7 @@ function SubTables() { uploaderType: "vendor", uploaderId: null, uploaderName: uploadResult.data.uploaderName || null, - comment: uploadResult.data.comment || null, // ✅ comment도 포함 + comment: uploadResult.data.comment || null, usage: uploadResult.data.usage, usageType: uploadResult.data.usageType || null, revisionStatus: "UPLOADED", @@ -550,6 +856,7 @@ function SubTables() { revisionId: uploadResult.data.revisionId, fileName: file.fileName, filePath: file.filePath, + dolceFilePath: null, fileSize: file.fileSize, fileType: file.fileType || null, createdAt: new Date(), @@ -561,26 +868,26 @@ function SubTables() { const updatedData = allData.map(doc => { if (doc.documentId === selectedDocumentId) { const updatedDoc = { ...doc } - + // allStages가 있으면 해당 stage에 새 revision 추가 if (updatedDoc.allStages) { - const stages = [...updatedDoc.allStages as StageInfo[]] // ✅ 배열 복사 - const targetStage = stages.find(stage => - stage.stageName === uploadResult.data.stage || + const stages = [...updatedDoc.allStages as StageInfo[]] + const targetStage = stages.find(stage => + stage.stageName === uploadResult.data.stage || stage.stageName === uploadResult.data.usage ) - + if (targetStage) { // 기존 revision과 중복 체크 (같은 revision, usage, usageType) - const isDuplicate = targetStage.revisions.some(rev => + const isDuplicate = targetStage.revisions.some(rev => rev.revision === newRevision.revision && rev.usage === newRevision.usage && rev.usageType === newRevision.usageType ) - + if (!isDuplicate) { targetStage.revisions = [newRevision, ...targetStage.revisions] - updatedDoc.allStages = stages // ✅ 업데이트된 stages 할당 + updatedDoc.allStages = stages } } else { // 첫 번째 stage에 추가 (fallback) @@ -590,7 +897,7 @@ function SubTables() { } } } - + return updatedDoc } return doc @@ -598,9 +905,9 @@ function SubTables() { // State 업데이트 setAllData(updatedData) - + console.log('✅ RevisionTable data update complete') - + } catch (error) { console.error('❌ RevisionTable update failed:', error) // 실패 시 전체 새로고침 @@ -611,7 +918,7 @@ function SubTables() { router.refresh() // 서버 컴포넌트 재렌더링으로 최신 데이터 가져오기 }, 1500) // 1.5초 후 새로고침 (사용자가 업데이트를 확인할 시간) - }, [selectedDocumentId, allData, setAllData]) + }, [selectedDocumentId, allData, setAllData, router]) const selectedDocument = React.useMemo(() => { if (!selectedDocumentId || !allData) return null @@ -738,7 +1045,7 @@ function SubTables() { } setTimeout(() => cleanupHtmlStyle(), 500) } - }, [viewerOpen, cleanupHtmlStyle]) + }, [viewerOpen, cleanupHtmlStyle, instance]) // 문서 로드 React.useEffect(() => { @@ -803,14 +1110,16 @@ function SubTables() { return ( <> <div className="flex gap-4"> - <RevisionTable - revisions={allRevisions} + <RevisionTable + revisions={allRevisions} onViewRevision={handleViewRevision} onNewRevision={handleNewRevision} + onEditRevision={handleEditRevision} // ✅ 수정 함수 전달 /> - <AttachmentTable - attachments={selectedRevisionData?.attachments || []} + <AttachmentTable + attachments={selectedRevisionData?.attachments || []} onDownloadFile={handleDownloadFile} + onDeleteFile={handleDeleteFile} /> </div> @@ -847,6 +1156,14 @@ function SubTables() { drawingKind={selectedDocument.drawingKind || 'B4'} onSuccess={handleRevisionUploadSuccess} /> + + {/* ✅ 리비전 수정 다이얼로그 */} + <EditRevisionDialog + open={editRevisionDialogOpen} + onOpenChange={setEditRevisionDialogOpen} + revision={editingRevision} + onSuccess={handleRevisionEditSuccess} + /> </> ) } @@ -970,15 +1287,15 @@ export function UserVendorDocumentDisplay({ return ( <DocumentSelectionContext.Provider value={ctx}> <div className="space-y-4"> - <Card> - <CardContent className="flex items-center justify-center py-8"> - <SimplifiedDocumentsTable - allPromises={allPromises} - onDataLoaded={setAllData} - onDocumentSelect={handleDocumentSelect} - /> - </CardContent> - </Card> + <Card> + <CardContent className="flex items-center justify-center py-8"> + <SimplifiedDocumentsTable + allPromises={allPromises} + onDataLoaded={setAllData} + onDocumentSelect={handleDocumentSelect} + /> + </CardContent> + </Card> <SelectedDocumentInfo /> <SubTables /> |
