diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-13 07:11:18 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-13 07:11:18 +0000 |
| commit | 0fddf148402fd6b99a1b3800d73679899bcb2ed3 (patch) | |
| tree | eb51c02e6fa6037ddcc38a3b57d10d8c739125cf /components | |
| parent | c72d0897f7b37843109c86f61d97eba05ba3ca0d (diff) | |
(대표님) 20250613 16시 10분 global css, b-rfq, document 등
Diffstat (limited to 'components')
3 files changed, 1938 insertions, 89 deletions
diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx new file mode 100644 index 00000000..2f2467a3 --- /dev/null +++ b/components/ship-vendor-document/add-attachment-dialog.tsx @@ -0,0 +1,368 @@ +"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, + DialogHeader, + DialogTitle, + DialogFooter +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" +import { + Upload, + FileText, + X, + Loader2, + CheckCircle, + Paperclip +} from "lucide-react" +import { toast } from "sonner" +import { useSession } from "next-auth/react" + +/* ------------------------------------------------------------------------------------------------- + * Schema & Types + * -----------------------------------------------------------------------------------------------*/ + +// 파일 검증 스키마 +const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB +const ACCEPTED_FILE_TYPES = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'image/jpeg', + 'image/png', + 'image/gif', + 'text/plain', + 'application/zip', + 'application/x-zip-compressed' +] + +const attachmentUploadSchema = z.object({ + attachments: z + .array(z.instanceof(File)) + .min(1, "최소 1개의 파일을 업로드해주세요") + .max(10, "최대 10개의 파일까지 업로드 가능합니다") + .refine( + (files) => files.every((file) => file.size <= MAX_FILE_SIZE), + "파일 크기는 50MB 이하여야 합니다" + ) + .refine( + (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), + "지원하지 않는 파일 형식입니다" + ), +}) + +interface AddAttachmentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + revisionId: number + revisionName: string + onSuccess?: (result?: any) => void +} + +/* ------------------------------------------------------------------------------------------------- + * File Upload Component + * -----------------------------------------------------------------------------------------------*/ +function FileUploadArea({ + files, + onFilesChange +}: { + files: File[] + onFilesChange: (files: File[]) => void +}) { + const fileInputRef = React.useRef<HTMLInputElement>(null) + + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = Array.from(event.target.files || []) + if (selectedFiles.length > 0) { + onFilesChange([...files, ...selectedFiles]) + } + } + + const handleDrop = (event: React.DragEvent<HTMLDivElement>) => { + event.preventDefault() + const droppedFiles = Array.from(event.dataTransfer.files) + if (droppedFiles.length > 0) { + onFilesChange([...files, ...droppedFiles]) + } + } + + const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => { + event.preventDefault() + } + + const removeFile = (index: number) => { + onFilesChange(files.filter((_, i) => i !== index)) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + return ( + <div className="space-y-4"> + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-gray-400 transition-colors" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => fileInputRef.current?.click()} + > + <Paperclip className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-sm text-gray-600 mb-2"> + 추가할 파일을 드래그하여 놓거나 클릭하여 선택하세요 + </p> + <p className="text-xs text-gray-500"> + PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) + </p> + <input + ref={fileInputRef} + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.txt,.zip" + onChange={handleFileSelect} + className="hidden" + /> + </div> + + {files.length > 0 && ( + <div className="space-y-2 max-h-40 overflow-y-auto overscroll-contain pr-2"> + <p className="text-sm font-medium">선택된 파일 ({files.length}개)</p> + <div className="max-h-40 overflow-y-auto space-y-2"> + {files.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 bg-gray-50 rounded border" + > + <div className="flex items-center space-x-2 flex-1"> + <FileText className="h-4 w-4 text-gray-500" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate" title={file.name}> + {file.name} + </p> + <p className="text-xs text-gray-500"> + {formatFileSize(file.size)} + </p> + </div> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Dialog Component + * -----------------------------------------------------------------------------------------------*/ +export function AddAttachmentDialog({ + open, + onOpenChange, + revisionId, + revisionName, + onSuccess + }: AddAttachmentDialogProps) { + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const { data: session } = useSession() + + const userName = React.useMemo(() => { + return session?.user?.name ? session.user.name : null; + }, [session]); + + type AttachmentUploadSchema = z.infer<typeof attachmentUploadSchema> + + const form = useForm<AttachmentUploadSchema>({ + resolver: zodResolver(attachmentUploadSchema), + defaultValues: { + attachments: [], + }, + }) + + const watchedFiles = form.watch("attachments") + + const handleDialogClose = () => { + if (!isUploading) { + form.reset() + setUploadProgress(0) + onOpenChange(false) + } + } + + const onSubmit = async (data: AttachmentUploadSchema) => { + setIsUploading(true) + setUploadProgress(0) + + try { + const formData = new FormData() + formData.append("revisionId", String(revisionId)) + formData.append("uploaderName", userName || "evcp") + + // 파일들 추가 + data.attachments.forEach((file) => { + formData.append("attachments", file) + }) + + // 진행률 업데이트 시뮬레이션 + const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0) + let uploadedSize = 0 + + const progressInterval = setInterval(() => { + uploadedSize += totalSize * 0.1 + const progress = Math.min((uploadedSize / totalSize) * 100, 90) + setUploadProgress(progress) + }, 300) + + const response = await fetch('/api/revision-attachment', { + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || errorData.details || '첨부파일 업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success( + result.message || + `${result.data?.uploadedFiles?.length || 0}개 첨부파일이 추가되었습니다.` + ) + + console.log('✅ 첨부파일 업로드 성공:', result) + + setTimeout(() => { + handleDialogClose() + onSuccess?.(result) + }, 1000) + + } catch (error) { + console.error('❌ 첨부파일 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "첨부파일 업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) + } + } + + return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0 pb-4 border-b"> + <DialogTitle className="flex items-center gap-2"> + <Paperclip className="h-5 w-5" /> + 첨부파일 추가 + </DialogTitle> + <DialogDescription className="text-sm"> + 리비전 {revisionName}에 추가 첨부파일을 업로드합니다 + </DialogDescription> + </DialogHeader> + + <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"> + {/* 파일 업로드 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">첨부파일</FormLabel> + <FormControl> + <FileUploadArea + files={watchedFiles || []} + onFilesChange={field.onChange} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 업로드 진행률 */} + {isUploading && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>업로드 진행률</span> + <span>{uploadProgress.toFixed(0)}%</span> + </div> + <Progress value={uploadProgress} className="w-full" /> + {uploadProgress === 100 && ( + <div className="flex items-center gap-2 text-sm text-green-600"> + <CheckCircle className="h-4 w-4" /> + <span>업로드 완료</span> + </div> + )} + </div> + )} + </div> + + {/* 고정 버튼 영역 */} + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || !form.formState.isValid} + className="min-w-[120px]" + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 추가 + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) + }
\ No newline at end of file diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx new file mode 100644 index 00000000..092256c7 --- /dev/null +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -0,0 +1,622 @@ +"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, + DialogHeader, + DialogTitle,DialogFooter +} 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 { Progress } from "@/components/ui/progress" +import { Badge } from "@/components/ui/badge" +import { + Upload, + FileText, + X, + Loader2, + AlertCircle, + CheckCircle +} from "lucide-react" +import { toast } from "sonner" +import { useSession } from "next-auth/react" + +// 기존 메인 컴포넌트에서 추가할 import +// import { NewRevisionDialog } from "./new-revision-dialog" + +/* ------------------------------------------------------------------------------------------------- + * Schema & Types + * -----------------------------------------------------------------------------------------------*/ + +// 파일 검증 스키마 +const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB +const ACCEPTED_FILE_TYPES = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'image/jpeg', + 'image/png', + 'image/gif', + 'text/plain', + 'application/zip', + 'application/x-zip-compressed' +] + +// drawingKind에 따른 동적 스키마 생성 +const createRevisionUploadSchema = (drawingKind: string) => { + const baseSchema = { + usage: z.string().min(1, "용도를 선택해주세요"), + revision: z.string().min(1, "리비전을 입력해주세요").max(50, "리비전은 50자 이내로 입력해주세요"), + comment: z.string().optional(), + attachments: z + .array(z.instanceof(File)) + .min(1, "최소 1개의 파일을 업로드해주세요") + .max(10, "최대 10개의 파일까지 업로드 가능합니다") + .refine( + (files) => files.every((file) => file.size <= MAX_FILE_SIZE), + "파일 크기는 50MB 이하여야 합니다" + ) + .refine( + (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), + "지원하지 않는 파일 형식입니다" + ), + } + + // B3인 경우에만 usageType 필드 추가 + if (drawingKind === 'B3') { + return z.object({ + ...baseSchema, + usageType: z.string().min(1, "용도 타입을 선택해주세요"), + }) + } 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: "Reference", label: "Reference" }, + ] + 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: "Approval Submission Full", label: "Approval Submission Full" }, + { value: "Approval Submission Partial", label: "Approval Submission Partial" }, + { value: "Approval Completion Full", label: "Approval Completion Full" }, + { value: "Approval Completion Partial", label: "Approval Completion Partial" }, + ] + case 'Working': + return [ + { value: "Working Full", label: "Working Full" }, + { value: "Working Partial", label: "Working Partial" }, + ] + case 'Reference': + return [ + { value: "Reference Full", label: "Reference Full" }, + { value: "Reference Partial", label: "Reference Partial" }, + { value: "Reference Series Full", label: "Reference Series Full" }, + { value: "Reference Series Partial", label: "Reference Series Partial" }, + ] + default: + return [] + } +} + +// 리비전 형식 가이드 생성 +const getRevisionGuide = (drawingKind: string, usage: string, usageType: string) => { + if (drawingKind === 'B4') { + return "R00, R01, R02... 형식으로 입력하세요" + } + + if (drawingKind === 'B5') { + return "A, B, C... 형식으로 입력하세요" + } + + if (drawingKind === 'B3') { + if (usage === 'Reference') { + return "00, 01, 02... 형식으로 입력하세요" + } + if (usageType === 'Working Partial') { + return "00, 01, 02... 형식으로 입력하세요" + } + if (usageType?.includes('Full') && (usageType.includes('Working') || usageType.includes('Approval'))) { + return "A, B, C... 형식으로 입력하세요" + } + if (usageType?.includes('Partial') && usageType.includes('Approval')) { + return "리비전 형식은 추후 정의 예정입니다" + } + } + + return "리비전을 입력하세요" +} + +interface NewRevisionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + documentId: number + documentTitle?: string + drawingKind: string + onSuccess?: (result?: any) => void // ✅ result 파라미터 추가 +} + +/* ------------------------------------------------------------------------------------------------- + * File Upload Component + * -----------------------------------------------------------------------------------------------*/ +function FileUploadArea({ + files, + onFilesChange +}: { + files: File[] + onFilesChange: (files: File[]) => void +}) { + const fileInputRef = React.useRef<HTMLInputElement>(null) + + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = Array.from(event.target.files || []) + if (selectedFiles.length > 0) { + onFilesChange([...files, ...selectedFiles]) + } + } + + const handleDrop = (event: React.DragEvent<HTMLDivElement>) => { + event.preventDefault() + const droppedFiles = Array.from(event.dataTransfer.files) + if (droppedFiles.length > 0) { + onFilesChange([...files, ...droppedFiles]) + } + } + + const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => { + event.preventDefault() + } + + const removeFile = (index: number) => { + onFilesChange(files.filter((_, i) => i !== index)) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + return ( + <div className="space-y-4"> + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-gray-400 transition-colors" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => fileInputRef.current?.click()} + > + <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-sm text-gray-600 mb-2"> + 파일을 드래그하여 놓거나 클릭하여 선택하세요 + </p> + <p className="text-xs text-gray-500"> + PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) + </p> + <input + ref={fileInputRef} + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.txt,.zip" + onChange={handleFileSelect} + className="hidden" + /> + </div> + + {files.length > 0 && ( + <div className="space-y-2 max-h-40 overflow-y-auto overscroll-contain pr-2"> + <p className="text-sm font-medium">선택된 파일 ({files.length}개)</p> + <div className="max-h-40 overflow-y-auto space-y-2"> + {files.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 bg-gray-50 rounded border" + > + <div className="flex items-center space-x-2 flex-1"> + <FileText className="h-4 w-4 text-gray-500" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate" title={file.name}> + {file.name} + </p> + <p className="text-xs text-gray-500"> + {formatFileSize(file.size)} + </p> + </div> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Dialog Component + * -----------------------------------------------------------------------------------------------*/ +export function NewRevisionDialog({ + open, + onOpenChange, + documentId, + documentTitle, + drawingKind, + onSuccess +}: NewRevisionDialogProps) { + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const { data: session } = useSession() + + const userName = React.useMemo(() => { + return session?.user?.name ? session.user.name : null; + }, [session]); + + // drawingKind에 따른 동적 스키마 및 옵션 생성 + const revisionUploadSchema = React.useMemo(() => createRevisionUploadSchema(drawingKind), [drawingKind]) + const usageOptions = React.useMemo(() => getUsageOptions(drawingKind), [drawingKind]) + const showUsageType = drawingKind === 'B3' + + type RevisionUploadSchema = z.infer<typeof revisionUploadSchema> + + const form = useForm<RevisionUploadSchema>({ + resolver: zodResolver(revisionUploadSchema), + defaultValues: { + usage: "", + revision: "", + comment: "", + usageType: showUsageType ? "" : undefined, + attachments: [], + }, + }) + + const watchedFiles = form.watch("attachments") + const watchedUsage = form.watch("usage") + const watchedUsageType = form.watch("usageType") + + // 용도 선택에 따른 용도 타입 옵션 업데이트 + const usageTypeOptions = React.useMemo(() => { + if (drawingKind === 'B3' && watchedUsage) { + return getUsageTypeOptions(watchedUsage) + } + return [] + }, [drawingKind, watchedUsage]) + + // 용도 변경 시 용도 타입 초기화 + React.useEffect(() => { + if (showUsageType && watchedUsage) { + form.setValue("usageType", "") + } + }, [watchedUsage, showUsageType, form]) + + // 리비전 가이드 텍스트 + const revisionGuide = React.useMemo(() => { + return getRevisionGuide(drawingKind, watchedUsage, watchedUsageType || "") + }, [drawingKind, watchedUsage, watchedUsageType]) + + const handleDialogClose = () => { + if (!isUploading) { + form.reset() + setUploadProgress(0) + onOpenChange(false) + } + } + + const onSubmit = async (data: RevisionUploadSchema) => { + setIsUploading(true) + setUploadProgress(0) + + try { + const formData = new FormData() + formData.append("documentId", String(documentId)) + formData.append("usage", data.usage) + formData.append("revision", data.revision) + formData.append("uploaderName", userName || "evcp") + + if (data.comment) { + formData.append("comment", data.comment) + } + + // B3인 경우에만 usageType 추가 + if (showUsageType && 'usageType' in data && data.usageType) { + formData.append("usageType", data.usageType) + } + + // 파일들 추가 + data.attachments.forEach((file) => { + formData.append("attachments", file) + }) + + // 진행률 업데이트 시뮬레이션 + const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0) + let uploadedSize = 0 + + const progressInterval = setInterval(() => { + uploadedSize += totalSize * 0.1 + const progress = Math.min((uploadedSize / totalSize) * 100, 90) + setUploadProgress(progress) + }, 300) + + const response = await fetch('/api/revision-upload-ship', { // ✅ 올바른 API 엔드포인트 사용 + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success( + result.message || + `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)` + ) + + console.log('✅ 업로드 성공:', result) + + setTimeout(() => { + handleDialogClose() + onSuccess?.(result) // ✅ API 응답 결과를 콜백에 전달 + }, 1000) + + } catch (error) { + console.error('❌ 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) + } + } + + return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-2xl h-[90vh] flex flex-col overflow-hidden" style={{maxHeight:'90vh'}}> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0 pb-4 border-b"> + <DialogTitle className="flex items-center gap-2"> + <Upload className="h-5 w-5" /> + 새 리비전 업로드 + </DialogTitle> + {documentTitle && ( + <DialogDescription className="text-sm space-y-1"> + <div>문서: {documentTitle}</div> + </DialogDescription> + )} + </DialogHeader> + + <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"> + {/* 용도 선택 */} + <FormField + control={form.control} + name="usage" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">용도</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="용도를 선택하세요" /> + </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">용도 타입</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="용도 타입을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageTypeOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 리비전 */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">리비전</FormLabel> + <FormControl> + <Input + placeholder={revisionGuide} + {...field} + /> + </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {revisionGuide} + </div> + <FormMessage /> + </FormItem> + )} + /> + + {/* 코멘트 */} + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트</FormLabel> + <FormControl> + <Textarea + placeholder="리비전에 대한 설명이나 변경사항을 입력하세요 (선택사항)" + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 업로드 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">첨부파일</FormLabel> + <FormControl> + <FileUploadArea + files={watchedFiles || []} + onFilesChange={field.onChange} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 업로드 진행률 */} + {isUploading && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>업로드 진행률</span> + <span>{uploadProgress.toFixed(0)}%</span> + </div> + <Progress value={uploadProgress} className="w-full" /> + {uploadProgress === 100 && ( + <div className="flex items-center gap-2 text-sm text-green-600"> + <CheckCircle className="h-4 w-4" /> + <span>업로드 완료</span> + </div> + )} + </div> + )} + </div> + + {/* 고정 버튼 영역 */} + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || !form.formState.isValid} + className="min-w-[120px]" + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 업로드 + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </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 0ede3e19..17af5436 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -1,129 +1,988 @@ +// user-vendor-document-display.tsx "use client" import React from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { Building, FileText, AlertCircle } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Building, FileText, AlertCircle, Eye, Download, Loader2, Plus } from "lucide-react" import { SimplifiedDocumentsTable } from "@/lib/vendor-document-list/ship/enhanced-documents-table" -import { getUserVendorDocuments, getUserVendorDocumentStats } from "@/lib/vendor-document-list/enhanced-document-service" +import { + getUserVendorDocuments, + getUserVendorDocumentStats, +} from "@/lib/vendor-document-list/enhanced-document-service" +import { SimplifiedDocumentsView } from "@/db/schema" +import { WebViewerInstance } from "@pdftron/webviewer" +import { NewRevisionDialog } from "./new-revision-dialog" +import { useRouter } from 'next/navigation' +import { AddAttachmentDialog } from "./add-attachment-dialog" // ✅ import 추가 +/* ------------------------------------------------------------------------------------------------- + * Types & Constants + * -----------------------------------------------------------------------------------------------*/ interface UserVendorDocumentDisplayProps { allPromises: Promise<[ - Awaited<ReturnType<typeof getUserVendorDocuments>>, - Awaited<ReturnType<typeof getUserVendorDocumentStats>> + Awaited<ReturnType<typeof getUserVendorDocuments>>, // 문서 목록 + Awaited<ReturnType<typeof getUserVendorDocumentStats>>, // 통계 데이터 ]> } -// DrawingKind별 설명 매핑 -const DRAWING_KIND_INFO = { - B3: { - title: "B3 승인 도면", - description: "Approval → Work 단계로 진행되는 승인 중심 도면", - color: "bg-blue-50 text-blue-700 border-blue-200" - }, - B4: { - title: "B4 작업 도면", - description: "Pre → Work 단계로 진행되는 DOLCE 연동 도면", - color: "bg-green-50 text-green-700 border-green-200" +interface StageInfo { + id: number + stageName: string + stageStatus: string + stageOrder: number + planDate: string | null + actualDate: string | null + assigneeName: string | null + priority: string + revisions: RevisionInfo[] +} + +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 + fileSize: number | null + fileType: string | null + createdAt: Date + updatedAt: Date +} + +interface DocumentSelectionContextType { + selectedDocumentId: number | null + selectedStageId: number | null + selectedRevisionId: number | null + setSelectedDocumentId: (id: number | null) => void + setSelectedStageId: (id: number | null) => void + setSelectedRevisionId: (id: number | null) => void + allData: SimplifiedDocumentsView[] | null + setAllData: (data: SimplifiedDocumentsView[]) => void // ✅ 추가 +} + +export const DocumentSelectionContext = React.createContext<DocumentSelectionContextType>( + { + selectedDocumentId: null, + selectedStageId: null, + selectedRevisionId: null, + setSelectedDocumentId: (_id: number | null) => { }, + setSelectedStageId: (_id: number | null) => { }, + setSelectedRevisionId: (_id: number | null) => { }, + allData: null, + setAllData: (_data: SimplifiedDocumentsView[]) => { }, // ✅ 추가 }, - B5: { - title: "B5 단계 도면", - description: "First → Second 단계로 진행되는 순차적 도면", - color: "bg-purple-50 text-purple-700 border-purple-200" +) + +/* ------------------------------------------------------------------------------------------------- + * Revision & Attachment Tables + * -----------------------------------------------------------------------------------------------*/ +// user-vendor-document-display.tsx의 RevisionTable 컴포넌트 수정 +// B3 용도 타입 축약 표시 함수 추가 + +function getUsageTypeDisplay(usageType: string | null): string { + if (!usageType) return '-' + + // B3 용도 타입 축약 표시 + const abbreviations: Record<string, string> = { + 'Approval Submission Full': 'AS-F', + 'Approval Submission Partial': 'AS-P', + 'Approval Completion Full': 'AC-F', + 'Approval Completion Partial': 'AC-P', + 'Working Full': 'W-F', + 'Working Partial': 'W-P', + 'Reference Full': 'R-F', + 'Reference Partial': 'R-P', + 'Reference Series Full': 'RS-F', + 'Reference Series Partial': 'RS-P', } -} as const + + return abbreviations[usageType] || usageType +} -export function UserVendorDocumentDisplay({ - allPromises -}: UserVendorDocumentDisplayProps) { - // allPromises가 제대로 전달되었는지 확인 - if (!allPromises) { - return ( - <Card> - <CardContent className="flex items-center justify-center py-8"> - <div className="text-center"> - <AlertCircle className="w-8 h-8 text-gray-400 mx-auto mb-2" /> - <p className="text-gray-600">데이터를 불러올 수 없습니다.</p> +function RevisionTable({ + revisions, + onViewRevision, + onNewRevision +}: { + revisions: RevisionInfo[] + onViewRevision: (revision: RevisionInfo) => void + onNewRevision: () => void +}) { + const { selectedRevisionId, setSelectedRevisionId } = + React.useContext(DocumentSelectionContext) + + const toggleSelect = (revisionId: number) => { + setSelectedRevisionId(revisionId === selectedRevisionId ? null : revisionId) + } + + return ( + <Card className="flex-1"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-lg">리비전</CardTitle> + </div> + <Button + onClick={onNewRevision} + size="sm" + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 새 리비전 + </Button> + </div> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <Table className="tbl-compact"> + <TableHeader> + <TableRow> + <TableHead className="w-12">선택</TableHead> + <TableHead>리비전</TableHead> + <TableHead>카테고리</TableHead> + <TableHead>용도</TableHead> + <TableHead>타입</TableHead> {/* ✅ usageType 컬럼 */} + <TableHead>상태</TableHead> + <TableHead>업로더</TableHead> + <TableHead>코멘트</TableHead> + <TableHead>업로드일</TableHead> + <TableHead className="text-center">파일 수</TableHead> + <TableHead>액션</TableHead> + </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 ? + + 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> + )} + </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> + ))} + </TableBody> + </Table> + </div> + </CardContent> + </Card> + ) +} + +function AttachmentTable({ + attachments, + onDownloadFile +}: { + attachments: AttachmentInfo[] + onDownloadFile: (attachment: AttachmentInfo) => void +}) { + const { selectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContext) + const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) // ✅ 추가 + 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[]) { + const revision = stage.revisions.find(r => r.id === selectedRevisionId) + if (revision) return revision + } + } + } + return null + }, [selectedRevisionId, allData]) + + // ✅ 첨부파일 추가 핸들러 + const handleAddAttachment = React.useCallback(() => { + if (selectedRevisionInfo) { + setAddAttachmentDialogOpen(true) + } + }, [selectedRevisionInfo]) + + // ✅ 첨부파일 업로드 성공 핸들러 + const handleAttachmentUploadSuccess = React.useCallback((uploadResult?: any) => { + if (!selectedRevisionId || !allData || !uploadResult?.data) { + console.log('🔄 전체 새로고침') + router.refresh() + return + } + + try { + // 새로운 첨부파일들을 AttachmentInfo 형태로 변환 + const newAttachments: AttachmentInfo[] = uploadResult.data.uploadedFiles?.map((file: any) => ({ + id: file.id, + revisionId: selectedRevisionId, + fileName: file.fileName, + filePath: file.filePath, + fileSize: file.fileSize, + fileType: file.fileType || null, + createdAt: new Date(), + updatedAt: new Date(), + })) || [] + + // 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) { + // 해당 리비전의 첨부파일 배열에 새 파일들 추가 + stage.revisions[revisionIndex] = { + ...stage.revisions[revisionIndex], + attachments: [...stage.revisions[revisionIndex].attachments, ...newAttachments] + } + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + console.log('✅ AttachmentTable 업데이트 완료') + + // 메인 테이블도 업데이트 (약간의 지연 후) + setTimeout(() => { + router.refresh() + }, 1500) + + } catch (error) { + console.error('❌ AttachmentTable 업데이트 실패:', error) + router.refresh() + } + }, [selectedRevisionId, allData, setAllData, router]) + + return ( + <> + <Card className="w-96 flex-shrink-0"> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg">첨부파일</CardTitle> + {/* ✅ + 버튼 추가 */} + {selectedRevisionId && selectedRevisionInfo && ( + <Button + onClick={handleAddAttachment} + size="sm" + variant="outline" + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 추가 + </Button> + )} </div> + </CardHeader> + <CardContent> + <Table className="tbl-compact"> + <TableHeader> + <TableRow> + <TableHead>파일명</TableHead> + <TableHead>액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {!selectedRevisionId || attachments.length === 0 ? ( + <TableRow> + <TableCell colSpan={2} className="h-24 text-center"> + <div className="flex flex-col items-center gap-2 text-muted-foreground"> + <FileText className="h-8 w-8" /> + <span> + {!selectedRevisionId + ? '리비전을 선택해주세요' + : '첨부된 파일이 없습니다'} + </span> + {/* ✅ 리비전이 선택된 경우 추가 버튼 표시 */} + {selectedRevisionId && selectedRevisionInfo && ( + <Button + onClick={handleAddAttachment} + size="sm" + variant="outline" + className="mt-2" + > + <Plus className="h-4 w-4 mr-2" /> + 첫 번째 파일 추가 + </Button> + )} + </div> + </TableCell> + </TableRow> + ) : ( + attachments.map((file) => ( + <TableRow key={file.id}> + <TableCell className="font-medium"> + <div> + <div className="truncate max-w-[180px]" title={file.fileName}> + {file.fileName} + </div> + <div className="text-xs text-muted-foreground"> + {file.fileSize + ? file.fileSize >= 1024 * 1024 + ? `${(file.fileSize / 1024 / 1024).toFixed(1)}MB` + : `${(file.fileSize / 1024).toFixed(1)}KB` + : '-'} + </div> + </div> + </TableCell> + <TableCell> + <Button + variant="ghost" + size="sm" + onClick={() => onDownloadFile(file)} + className="h-8 px-2" + > + <Download className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> </CardContent> </Card> + + {/* ✅ AddAttachmentDialog 추가 */} + {selectedRevisionInfo && ( + <AddAttachmentDialog + open={addAttachmentDialogOpen} + onOpenChange={setAddAttachmentDialogOpen} + revisionId={selectedRevisionId!} + revisionName={selectedRevisionInfo.revision} + onSuccess={handleAttachmentUploadSuccess} + /> + )} + </> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Derived Sub Tables Wrapper + * -----------------------------------------------------------------------------------------------*/ +function SubTables() { + const router = useRouter() + const { selectedDocumentId, selectedRevisionId, allData, setAllData } = // ✅ setAllData 추가 + React.useContext(DocumentSelectionContext) + + // PDF 뷰어 상태 관리 + const [viewerOpen, setViewerOpen] = React.useState(false) + const [selectedRevision, setSelectedRevision] = React.useState<RevisionInfo | null>(null) + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) + const [viewerLoading, setViewerLoading] = React.useState(true) + const [fileSetLoading, setFileSetLoading] = React.useState(true) + const viewer = React.useRef<HTMLDivElement>(null) + const initialized = React.useRef(false) + const isCancelled = React.useRef(false) + + const [newRevisionDialogOpen, setNewRevisionDialogOpen] = React.useState(false) + + const handleNewRevision = React.useCallback(() => { + setNewRevisionDialogOpen(true) + }, []) + + const handleRevisionUploadSuccess = React.useCallback(async (uploadResult?: any) => { + if (!selectedDocumentId || !allData || !uploadResult?.data) { + // fallback: 전체 새로고침 + window.location.reload() + return + } + + try { + // 새로 업로드된 리비전 정보 구성 + const newRevision: RevisionInfo = { + id: uploadResult.data.revisionId, + issueStageId: uploadResult.data.issueStageId, + revision: uploadResult.data.revision, + uploaderType: "vendor", + uploaderId: null, + uploaderName: uploadResult.data.uploaderName || null, + comment: uploadResult.data.comment || null, // ✅ comment도 포함 + usage: uploadResult.data.usage, + usageType: uploadResult.data.usageType || null, + revisionStatus: "UPLOADED", + submittedDate: null, + approvedDate: null, + uploadedAt: new Date().toISOString().slice(0, 10), + reviewStartDate: null, + rejectedDate: null, + reviewerId: null, + reviewerName: null, + reviewComments: null, + createdAt: new Date(), + updatedAt: new Date(), + stageName: uploadResult.data.stage, + attachments: uploadResult.data.uploadedFiles?.map((file: any) => ({ + id: file.id, + revisionId: uploadResult.data.revisionId, + fileName: file.fileName, + filePath: file.filePath, + fileSize: file.fileSize, + fileType: file.fileType || null, + createdAt: new Date(), + updatedAt: new Date(), + })) || [] + } + + // allData에서 해당 문서 찾아서 업데이트 + 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 || + stage.stageName === uploadResult.data.usage + ) + + if (targetStage) { + // 기존 revision과 중복 체크 (같은 revision, usage, usageType) + 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 할당 + } + } else { + // 첫 번째 stage에 추가 (fallback) + if (stages.length > 0) { + stages[0].revisions = [newRevision, ...stages[0].revisions] + updatedDoc.allStages = stages + } + } + } + + return updatedDoc + } + return doc + }) + + // State 업데이트 + setAllData(updatedData) + + console.log('✅ RevisionTable 데이터 업데이트 완료') + + } catch (error) { + console.error('❌ RevisionTable 업데이트 실패:', error) + // 실패 시 전체 새로고침 + window.location.reload() + } + + setTimeout(() => { + router.refresh() // 서버 컴포넌트 재렌더링으로 최신 데이터 가져오기 + }, 1500) // 1.5초 후 새로고침 (사용자가 업데이트를 확인할 시간) + + }, [selectedDocumentId, allData, setAllData]) + + const selectedDocument = React.useMemo(() => { + if (!selectedDocumentId || !allData) return null + return allData.find((d) => d.documentId === selectedDocumentId) || null + }, [selectedDocumentId, allData]) + + // 선택된 문서의 모든 스테이지에서 모든 리비전을 수집 + const allRevisions = React.useMemo(() => { + if (!selectedDocument?.allStages) return [] + + const revisions: RevisionInfo[] = [] + for (const stage of selectedDocument.allStages as StageInfo[]) { + // 각 리비전에 스테이지 이름 추가 + const stageRevisions = stage.revisions.map(revision => ({ + ...revision, + stageName: stage.stageName + })) + revisions.push(...stageRevisions) + } + + // 생성 날짜순으로 정렬 (최신순) + return revisions.sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) - } + }, [selectedDocument]) - // Promise.all로 감싸진 Promise를 사용해서 데이터 가져오기 - const [documentResult, statsResult] = React.use(allPromises) + const selectedRevisionData = React.useMemo(() => { + if (!selectedRevisionId) return null + return allRevisions.find(r => r.id === selectedRevisionId) || null + }, [selectedRevisionId, allRevisions]) - const { data, pageCount, total, drawingKind, vendorInfo } = documentResult - const { stats, totalDocuments, primaryDrawingKind } = statsResult + // PDF 뷰어 정리 함수 + const cleanupHtmlStyle = React.useCallback(() => { + const htmlElement = window.document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) - // 문서가 없는 경우 - if (total === 0) { - return ( - <Card> - <CardContent className="flex items-center justify-center py-8"> - <div className="text-center"> - <FileText className="w-8 h-8 text-gray-400 mx-auto mb-2" /> - <p className="text-gray-600">등록된 문서가 없습니다.</p> + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + }, []) + + // 문서 뷰어 열기 함수 + const handleViewRevision = React.useCallback((revision: RevisionInfo) => { + setSelectedRevision(revision) + setViewerOpen(true) + setViewerLoading(true) + setFileSetLoading(true) + initialized.current = false + }, []) + + // 파일 다운로드 함수 + const handleDownloadFile = React.useCallback(async (attachment: AttachmentInfo) => { + try { + const queryParam = attachment.id + ? `id=${encodeURIComponent(attachment.id)}` + : `path=${encodeURIComponent(attachment.filePath)}` + + const response = await fetch(`/api/document-download?${queryParam}`) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || '파일 다운로드에 실패했습니다.') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const link = window.document.createElement('a') + link.href = url + link.download = attachment.fileName + window.document.body.appendChild(link) + link.click() + window.document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } catch (error) { + console.error('파일 다운로드 오류:', error) + alert(`파일 다운로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + }, []) + + // WebViewer 초기화 + React.useEffect(() => { + if (viewerOpen && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current && !isCancelled.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("WebViewer 초기화 취소됨 (Dialog 닫힘)") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + if (!isCancelled.current) { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]) + setViewerLoading(false) + } + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [viewerOpen, cleanupHtmlStyle]) + + // 문서 로드 + React.useEffect(() => { + const loadDocument = async () => { + if (instance && selectedRevision?.attachments?.length) { + const { UI } = instance + + const tabIds = [] + for (const attachment of selectedRevision.attachments) { + try { + const response = await fetch(attachment.filePath) + const blob = await response.blob() + const options = { + filename: attachment.fileName, + ...(attachment.fileType?.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }), + } + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error("파일 로드 실패:", attachment.filePath, error) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + loadDocument() + }, [instance, selectedRevision]) + + // 뷰어 닫기 + const handleCloseViewer = React.useCallback(async () => { + if (!fileSetLoading) { + isCancelled.current = true + + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setViewerLoading(false) + setViewerOpen(false) + setTimeout(() => cleanupHtmlStyle(), 1000) + } + }, [fileSetLoading, instance, cleanupHtmlStyle]) + + if (!selectedDocument) return null + + return ( + <> + <div className="flex gap-4"> + <RevisionTable + revisions={allRevisions} + onViewRevision={handleViewRevision} + onNewRevision={handleNewRevision} + /> + <AttachmentTable + attachments={selectedRevisionData?.attachments || []} + onDownloadFile={handleDownloadFile} + /> + </div> + + {/* 통합된 문서 뷰어 다이얼로그 */} + <Dialog open={viewerOpen} onOpenChange={handleCloseViewer}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>문서 미리보기</DialogTitle> + <DialogDescription> + 리비전 {selectedRevision?.revision} 첨부파일 + </DialogDescription> + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} </div> - </CardContent> - </Card> + </DialogContent> + </Dialog> + + <NewRevisionDialog + open={newRevisionDialogOpen} + onOpenChange={setNewRevisionDialogOpen} + documentId={selectedDocument.documentId} + documentTitle={selectedDocument.title} + drawingKind={selectedDocument.drawingKind || 'B4'} + onSuccess={handleRevisionUploadSuccess} + /> + </> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * High‑level Selected Document Summary + * -----------------------------------------------------------------------------------------------*/ +function SelectedDocumentInfo() { + const { selectedDocumentId, selectedRevisionId, allData } = + React.useContext(DocumentSelectionContext) + + if (!selectedDocumentId || !allData) return null + + const doc = allData.find((d) => d.documentId === selectedDocumentId) + if (!doc) return null + + const totalRevisions = doc.allStages + ? (doc.allStages as StageInfo[]).reduce( + (acc, s) => acc + s.revisions.length, + 0, ) + : 0 + + let selectedRevision: RevisionInfo | null = null + if (selectedRevisionId && doc.allStages) { + for (const stage of doc.allStages as StageInfo[]) { + const rev = stage.revisions.find((r) => r.id === selectedRevisionId) + if (rev) { + selectedRevision = rev + break + } + } } - // 실제 데이터의 drawingKind 또는 주요 drawingKind 사용 - const activeDrawingKind = drawingKind || primaryDrawingKind - - if (!activeDrawingKind) { + return ( + <div className="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 p-4"> + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-sm"> + 문서: {doc.docNumber} + </Badge> + <span className="max-w-[300px] truncate text-sm font-medium text-gray-700"> + {doc.title} + </span> + </div> + <div className="flex items-center gap-2 text-sm text-gray-600"> + <span>•</span> + <span>총 {totalRevisions}개 리비전</span> + {selectedRevision && ( + <> + <span>•</span> + <Badge variant="outline" className="text-sm"> + 선택된 리비전: {selectedRevision.revision} + </Badge> + <span>({selectedRevision.attachments.length}개 파일)</span> + </> + )} + </div> + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Exported Component + * -----------------------------------------------------------------------------------------------*/ +export function UserVendorDocumentDisplay({ + allPromises, +}: UserVendorDocumentDisplayProps) { + /** + * Selection state + */ + const [selectedDocumentId, setSelectedDocumentId] = + React.useState<number | null>(null) + const [selectedStageId, setSelectedStageId] = React.useState<number | null>( + null, + ) + const [selectedRevisionId, setSelectedRevisionId] = + React.useState<number | null>(null) + const [allData, setAllData] = + React.useState<SimplifiedDocumentsView[] | null>(null) + + const handleDocumentSelect = React.useCallback((id: number | null) => { + setSelectedDocumentId(id) + setSelectedStageId(null) + setSelectedRevisionId(null) + }, []) + + const ctx = React.useMemo<DocumentSelectionContextType>( + () => ({ + selectedDocumentId, + selectedStageId, + selectedRevisionId, + setSelectedDocumentId: handleDocumentSelect, + setSelectedStageId, + setSelectedRevisionId, + allData, + setAllData, // ✅ 추가 + }), + [ + selectedDocumentId, + selectedStageId, + selectedRevisionId, + handleDocumentSelect, + allData, + setAllData, // ✅ 의존성 배열에 추가 + ], + ) + + if (!allPromises) { return ( <Card> <CardContent className="flex items-center justify-center py-8"> <div className="text-center"> - <AlertCircle className="w-8 h-8 text-gray-400 mx-auto mb-2" /> - <p className="text-gray-600">문서 유형을 확인할 수 없습니다.</p> + <AlertCircle className="mx-auto mb-2 h-8 w-8 text-gray-400" /> + <p className="text-gray-600">데이터를 불러올 수 없습니다.</p> </div> </CardContent> </Card> ) } - // SimplifiedDocumentsTable에 전달할 promise (단일 객체로 변경) - const tablePromise = Promise.resolve({ data, pageCount, total }) - - const kindInfo = DRAWING_KIND_INFO[activeDrawingKind] - return ( - <div className="space-y-6"> - {/* 벤더 정보 헤더 */} + <DocumentSelectionContext.Provider value={ctx}> + <div className="space-y-4"> <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Building className="w-5 h-5" /> - {vendorInfo?.vendorName || "내 회사"} 문서 관리 - </CardTitle> - <CardDescription> - {vendorInfo?.vendorCode && `코드: ${vendorInfo.vendorCode} • `} - 총 {totalDocuments}개 문서 - </CardDescription> - </CardHeader> - <CardContent> - <div className="flex gap-4"> - {Object.entries(stats).map(([kind, count]) => ( - <Badge - key={kind} - variant={kind === activeDrawingKind ? "default" : "outline"} - className="flex items-center gap-1" - > - <FileText className="w-3 h-3" /> - {kind}: {count}개 - </Badge> - ))} - </div> - </CardContent> - </Card> + <CardContent className="flex items-center justify-center py-8"> + <SimplifiedDocumentsTable + allPromises={allPromises} + onDataLoaded={setAllData} + onDocumentSelect={handleDocumentSelect} + /> + </CardContent> + </Card> + <SelectedDocumentInfo /> - - <SimplifiedDocumentsTable promises={tablePromise} /> - - </div> + <SubTables /> + </div> + </DocumentSelectionContext.Provider> ) }
\ No newline at end of file |
