summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-13 07:11:18 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-13 07:11:18 +0000
commit0fddf148402fd6b99a1b3800d73679899bcb2ed3 (patch)
treeeb51c02e6fa6037ddcc38a3b57d10d8c739125cf /components
parentc72d0897f7b37843109c86f61d97eba05ba3ca0d (diff)
(대표님) 20250613 16시 10분 global css, b-rfq, document 등
Diffstat (limited to 'components')
-rw-r--r--components/ship-vendor-document/add-attachment-dialog.tsx368
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx622
-rw-r--r--components/ship-vendor-document/user-vendor-document-table-container.tsx1037
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