diff options
Diffstat (limited to 'components/ship-vendor-document/edit-revision-dialog.tsx')
| -rw-r--r-- | components/ship-vendor-document/edit-revision-dialog.tsx | 726 |
1 files changed, 726 insertions, 0 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 |
