From 0fddf148402fd6b99a1b3800d73679899bcb2ed3 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 13 Jun 2025 07:11:18 +0000 Subject: (대표님) 20250613 16시 10분 global css, b-rfq, document 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/b-rfq/[id]/initial/page.tsx | 16 +- .../(partners)/document-list-ship/page.tsx | 8 +- app/api/revision-attachment/route.ts | 153 + app/api/revision-upload-ship/route.ts | 281 + app/api/revision-upload/route.ts | 2 + app/globals.css | 8 + .../ship-vendor-document/add-attachment-dialog.tsx | 368 + .../ship-vendor-document/new-revision-dialog.tsx | 622 + .../user-vendor-document-table-container.tsx | 1037 +- db/migrations/0131_orange_ikaris.sql | 184 + db/migrations/0132_mean_mathemanic.sql | 186 + db/migrations/0133_cooing_karnak.sql | 2 + db/migrations/0134_wakeful_mulholland_black.sql | 2 + db/migrations/meta/0131_snapshot.json | 14377 ++++++++++++++++++ db/migrations/meta/0132_snapshot.json | 14377 ++++++++++++++++++ db/migrations/meta/0133_snapshot.json | 14383 ++++++++++++++++++ db/migrations/meta/0134_snapshot.json | 14395 +++++++++++++++++++ db/migrations/meta/_journal.json | 28 + db/schema/procurementRFQ.ts | 1 + db/schema/vendorDocu.ts | 12 +- lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 534 + lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 386 + lib/b-rfq/initial/initial-rfq-detail-table.tsx | 263 + .../initial/initial-rfq-detail-toolbar-actions.tsx | 109 + lib/b-rfq/service.ts | 218 +- lib/b-rfq/validations.ts | 100 + lib/vendor-document-list/import-service.ts | 4 +- .../ship/enhanced-doc-table-columns.tsx | 616 +- .../ship/enhanced-doc-table-toolbar-actions.tsx | 73 +- .../ship/enhanced-document-sheet.tsx | 939 -- .../ship/enhanced-documents-table.tsx | 135 +- .../ship/import-from-dolce-button.tsx | 258 +- .../ship/revision-upload-dialog.tsx | 629 - .../ship/simplified-document-edit-dialog.tsx | 287 - .../ship/stage-revision-expanded-content.tsx | 752 - .../ship/stage-revision-sheet.tsx | 86 - .../ship/swp-workflow-panel.tsx | 370 - lib/vendor-document-list/ship/update-doc-sheet.tsx | 267 - 38 files changed, 62560 insertions(+), 3908 deletions(-) create mode 100644 app/api/revision-attachment/route.ts create mode 100644 app/api/revision-upload-ship/route.ts create mode 100644 components/ship-vendor-document/add-attachment-dialog.tsx create mode 100644 components/ship-vendor-document/new-revision-dialog.tsx create mode 100644 db/migrations/0131_orange_ikaris.sql create mode 100644 db/migrations/0132_mean_mathemanic.sql create mode 100644 db/migrations/0133_cooing_karnak.sql create mode 100644 db/migrations/0134_wakeful_mulholland_black.sql create mode 100644 db/migrations/meta/0131_snapshot.json create mode 100644 db/migrations/meta/0132_snapshot.json create mode 100644 db/migrations/meta/0133_snapshot.json create mode 100644 db/migrations/meta/0134_snapshot.json create mode 100644 lib/b-rfq/initial/add-initial-rfq-dialog.tsx create mode 100644 lib/b-rfq/initial/initial-rfq-detail-columns.tsx create mode 100644 lib/b-rfq/initial/initial-rfq-detail-table.tsx create mode 100644 lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx delete mode 100644 lib/vendor-document-list/ship/enhanced-document-sheet.tsx delete mode 100644 lib/vendor-document-list/ship/revision-upload-dialog.tsx delete mode 100644 lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx delete mode 100644 lib/vendor-document-list/ship/stage-revision-expanded-content.tsx delete mode 100644 lib/vendor-document-list/ship/stage-revision-sheet.tsx delete mode 100644 lib/vendor-document-list/ship/swp-workflow-panel.tsx delete mode 100644 lib/vendor-document-list/ship/update-doc-sheet.tsx diff --git a/app/[lng]/evcp/(evcp)/b-rfq/[id]/initial/page.tsx b/app/[lng]/evcp/(evcp)/b-rfq/[id]/initial/page.tsx index 1a9f4b18..77ebebb1 100644 --- a/app/[lng]/evcp/(evcp)/b-rfq/[id]/initial/page.tsx +++ b/app/[lng]/evcp/(evcp)/b-rfq/[id]/initial/page.tsx @@ -1,9 +1,9 @@ import { Separator } from "@/components/ui/separator" import { type SearchParams } from "@/types/table" import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" +import { InitialRfqDetailTable } from "@/lib/b-rfq/initial/initial-rfq-detail-table" +import { getInitialRfqDetail } from "@/lib/b-rfq/service" +import { searchParamsInitialRfqDetailCache } from "@/lib/b-rfq/validations" interface IndexPageProps { // Next.js 13 App Router에서 기본으로 주어지는 객체들 @@ -24,11 +24,11 @@ export default async function RfqPage(props: IndexPageProps) { // 2) SearchParams 파싱 (Zod) // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) + const search = searchParamsInitialRfqDetailCache.parse(searchParams) const validFilters = getValidFilters(search.filters) const promises = Promise.all([ - getMatchedVendors({ + getInitialRfqDetail({ ...search, filters: validFilters, }, @@ -40,15 +40,15 @@ export default async function RfqPage(props: IndexPageProps) {

- Vendors + Initial RFQ List

- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. + 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다.

- +
) diff --git a/app/[lng]/partners/(partners)/document-list-ship/page.tsx b/app/[lng]/partners/(partners)/document-list-ship/page.tsx index 8b7f61e2..6cbea6fa 100644 --- a/app/[lng]/partners/(partners)/document-list-ship/page.tsx +++ b/app/[lng]/partners/(partners)/document-list-ship/page.tsx @@ -97,15 +97,19 @@ export default async function IndexPage(props: IndexPageProps) { // Promise.all로 감싸서 전달 const allPromises = Promise.all([documentsPromise, statsPromise]) + const statsResult = await documentsPromise + const vendorName = statsResult.vendorInfo?.vendorName || "내 회사" + + return (

- 내 문서 관리 + {vendorName} Document Management

- 소속 회사의 모든 계약 문서를 확인하고 관리합니다. +

diff --git a/app/api/revision-attachment/route.ts b/app/api/revision-attachment/route.ts new file mode 100644 index 00000000..12834085 --- /dev/null +++ b/app/api/revision-attachment/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from "next/server" +import { writeFile } from "fs/promises" +import { join } from "path" +import { v4 as uuidv4 } from "uuid" +import path from "path" +import { revalidateTag } from "next/cache" + +import db from "@/db/db" +import { + documents, + revisions, + documentAttachments, +} from "@/db/schema/vendorDocu" +import { eq } from "drizzle-orm" + +/* change log 유틸 */ +import { + logAttachmentChange, +} from "@/lib/vendor-document-list/sync-service" + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + + /* ------- 파라미터 파싱 ------- */ + const revisionId = Number(formData.get("revisionId")) + const uploaderName = formData.get("uploaderName") as string | null + const targetSystem = (formData.get("targetSystem") as string | null) ?? "DOLCE" + const attachmentFiles = formData.getAll("attachments") as File[] + + /* ------- 검증 ------- */ + if (!revisionId || Number.isNaN(revisionId)) + return NextResponse.json({ error: "Invalid revisionId" }, { status: 400 }) + if (!attachmentFiles.length) + return NextResponse.json({ error: "No files provided" }, { status: 400 }) + + const MAX = 50 * 1024 * 1024 // 50MB + for (const f of attachmentFiles) + if (f.size > MAX) + return NextResponse.json( + { error: `${f.name} exceeds 50MB limit` }, + { status: 400 } + ) + + /* ------- 리비전 및 계약 정보 확보 ------- */ + const [revisionInfo] = await db + .select({ + id: revisions.id, + revision: revisions.revision, + usage: revisions.usage, + usageType: revisions.usageType, + issueStageId: revisions.issueStageId, + contractId: documents.contractId, + }) + .from(revisions) + .leftJoin(documents, eq(documents.id, revisions.issueStageId)) + .where(eq(revisions.id, revisionId)) + .limit(1) + + if (!revisionInfo) { + return NextResponse.json({ error: "Revision not found" }, { status: 404 }) + } + + /* ------- 트랜잭션 ------- */ + const result = await db.transaction(async (tx) => { + /* 첨부파일 처리 */ + const uploadedFiles: any[] = [] + const baseDir = join(process.cwd(), "public", "documents") + + for (const file of attachmentFiles) { + const ext = path.extname(file.name) + const fname = uuidv4() + ext + const dest = join(baseDir, fname) + + await writeFile(dest, Buffer.from(await file.arrayBuffer())) + + const [att] = await tx.insert(documentAttachments) + .values({ + revisionId, + fileName: file.name, + filePath: "/documents/" + fname, + fileSize: file.size, + fileType: ext.slice(1).toLowerCase() || undefined, + updatedAt: new Date(), + }) + .returning() + + uploadedFiles.push({ + id: att.id, + fileName: file.name, + fileSize: file.size, + filePath: att.filePath, + fileType: ext.slice(1).toLowerCase() || null, + }) + + // change_logs: attachment CREATE + await logAttachmentChange( + revisionInfo.contractId, + att.id, + "CREATE", + att, + undefined, + undefined, + uploaderName ?? undefined, + [targetSystem] + ) + } + + /* 리비전 updatedAt 업데이트 */ + await tx.update(revisions) + .set({ updatedAt: new Date() }) + .where(eq(revisions.id, revisionId)) + + return { + revisionId, + revision: revisionInfo.revision, + usage: revisionInfo.usage, + usageType: revisionInfo.usageType, + uploadedFiles, + contractId: revisionInfo.contractId + } + }) + + // 캐시 무효화 + try { + // revalidateTag(`enhanced-documents-${result.contractId}`) + revalidateTag(`sync-status-${result.contractId}`) + + console.log(`✅ Cache invalidated for contract ${result.contractId}`) + } catch (cacheError) { + console.warn('⚠️ Cache invalidation failed:', cacheError) + } + + return NextResponse.json({ + success: true, + message: `${result.uploadedFiles.length}개 첨부파일이 추가되었습니다`, + data: { + revisionId: result.revisionId, + revision: result.revision, + usage: result.usage, + usageType: result.usageType, + uploadedFiles: result.uploadedFiles, + filesCount: result.uploadedFiles.length + }, + }) + } catch (e) { + console.error("revision-attachment error:", e) + return NextResponse.json( + { error: "Failed to upload attachments", details: String(e) }, + { status: 500 }, + ) + } +} \ No newline at end of file diff --git a/app/api/revision-upload-ship/route.ts b/app/api/revision-upload-ship/route.ts new file mode 100644 index 00000000..c68d405e --- /dev/null +++ b/app/api/revision-upload-ship/route.ts @@ -0,0 +1,281 @@ +import { NextRequest, NextResponse } from "next/server" +import { writeFile } from "fs/promises" +import { join } from "path" +import { v4 as uuidv4 } from "uuid" +import path from "path" +import { revalidateTag } from "next/cache" + +import db from "@/db/db" +import { + documents, + issueStages, + revisions, + documentAttachments, +} from "@/db/schema/vendorDocu" +import { and, eq } from "drizzle-orm" + +/* change log 유틸 */ +import { + logRevisionChange, + logAttachmentChange, +} from "@/lib/vendor-document-list/sync-service" + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + + /* ------- 파라미터 파싱 ------- */ + const usage = formData.get("usage") as string | null + const usageType = formData.get("usageType") as string | null + const revision = formData.get("revision") as string | null + const docId = Number(formData.get("documentId")) + const uploaderName = formData.get("uploaderName") as string | null + const comment = formData.get("comment") as string | null + const targetSystem = (formData.get("targetSystem") as string | null) ?? "DOLCE" + const attachmentFiles = formData.getAll("attachments") as File[] + + /* ------- 검증 ------- */ + if (!docId || Number.isNaN(docId)) + return NextResponse.json({ error: "Invalid documentId" }, { status: 400 }) + if (!usage || !revision) + return NextResponse.json({ error: "Missing usage or revision" }, { status: 400 }) + if (!attachmentFiles.length) + return NextResponse.json({ error: "No files provided" }, { status: 400 }) + + const MAX = 50 * 1024 * 1024 // 50MB (다이얼로그 제한과 맞춤) + for (const f of attachmentFiles) + if (f.size > MAX) + return NextResponse.json( + { error: `${f.name} exceeds 50MB limit` }, + { status: 400 } + ) + + /* ------- 계약 ID 확보 ------- */ + const [docInfo] = await db + .select({ contractId: documents.contractId }) + .from(documents) + .where(eq(documents.id, docId)) + .limit(1) + + if (!docInfo) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }) + } + + /* ------- Stage 찾기 로직 ------- */ + // 1. usage 값과 일치하는 stage 찾기 + let targetStage = await db + .select({ id: issueStages.id, stageName: issueStages.stageName }) + .from(issueStages) + .where(and( + eq(issueStages.documentId, docId), + eq(issueStages.stageName, usage) + )) + .limit(1) + + // 2. 없으면 해당 문서의 첫 번째 stage 사용 + if (!targetStage.length) { + targetStage = await db + .select({ id: issueStages.id, stageName: issueStages.stageName }) + .from(issueStages) + .where(eq(issueStages.documentId, docId)) + .orderBy(issueStages.id) // 첫 번째 stage + .limit(1) + } + + if (!targetStage.length) { + return NextResponse.json({ + error: "No stages found for this document" + }, { status: 400 }) + } + + const stage = targetStage[0].stageName + const issueStageId = targetStage[0].id + + /* ------- 트랜잭션 ------- */ + const result = await db.transaction(async (tx) => { + /* Revision 생성 */ + const today = new Date().toISOString().slice(0, 10) + + // 동일한 revision이 이미 있는지 확인 (usage, usageType도 포함) + const whereConditions = [ + eq(revisions.issueStageId, issueStageId), + eq(revisions.revision, revision), + eq(revisions.usage, usage) + ] + + // usageType이 있는 경우에만 조건에 추가 + if (usageType) { + whereConditions.push(eq(revisions.usageType, usageType)) + } + + const [existingRev] = await tx + .select() + .from(revisions) + .where(and(...whereConditions)) + .limit(1) + + let revisionId: number + let revisionData: any + + if (existingRev) { + // 기존 revision 업데이트 + const updateData: any = { + uploaderName: uploaderName ?? existingRev.uploaderName, + comment: comment ?? existingRev.comment, + updatedAt: new Date(), + } + + // usage는 항상 업데이트 + updateData.usage = usage + + // usageType이 있는 경우에만 업데이트 + if (usageType) { + updateData.usageType = usageType + } + + await tx.update(revisions) + .set(updateData) + .where(eq(revisions.id, existingRev.id)) + + const [updated] = await tx + .select() + .from(revisions) + .where(eq(revisions.id, existingRev.id)) + + revisionId = existingRev.id + revisionData = updated + + await logRevisionChange( + docInfo.contractId, + revisionId, + "UPDATE", + updated, + existingRev, + undefined, + uploaderName ?? undefined, + [targetSystem] + ) + } else { + // 새 revision 생성 + const [newRev] = await tx.insert(revisions) + .values({ + issueStageId, + revision, + usage, + usageType, + uploaderType: "vendor", + uploaderName: uploaderName ?? undefined, + revisionStatus: "UPLOADED", + uploadedAt: today, + comment: comment ?? undefined, + updatedAt: new Date(), + }) + .returning() + + revisionId = newRev.id + revisionData = newRev + + await logRevisionChange( + docInfo.contractId, + revisionId, + "CREATE", + newRev, + undefined, + undefined, + uploaderName ?? undefined, + [targetSystem] + ) + } + + /* 첨부파일 처리 */ + const uploadedFiles: any[] = [] + const baseDir = join(process.cwd(), "public", "documents") + + for (const file of attachmentFiles) { + const ext = path.extname(file.name) + const fname = uuidv4() + ext + const dest = join(baseDir, fname) + + await writeFile(dest, Buffer.from(await file.arrayBuffer())) + + const [att] = await tx.insert(documentAttachments) + .values({ + revisionId, + fileName: file.name, + filePath: "/documents/" + fname, + fileSize: file.size, + fileType: ext.slice(1).toLowerCase() || undefined, + updatedAt: new Date(), + }) + .returning() + + uploadedFiles.push({ + id: att.id, + fileName: file.name, + fileSize: file.size, + filePath: att.filePath, + fileType: ext.slice(1).toLowerCase() || null, // ✅ 추가 + }) + + // change_logs: attachment CREATE + await logAttachmentChange( + docInfo.contractId, + att.id, + "CREATE", + att, + undefined, + undefined, + uploaderName ?? undefined, + [targetSystem] + ) + } + + /* documents.updatedAt 업데이트 */ + await tx.update(documents) + .set({ updatedAt: new Date() }) + .where(eq(documents.id, docId)) + + return { + revisionId, + issueStageId, // ✅ 추가 + stage, + revision, + uploadedFiles, + contractId: docInfo.contractId, + usage, + usageType + } + }) + + // 캐시 무효화 + try { + revalidateTag(`sync-status-${result.contractId}`) + + console.log(`✅ Cache invalidated for contract ${result.contractId}`) + } catch (cacheError) { + console.warn('⚠️ Cache invalidation failed:', cacheError) + } + + return NextResponse.json({ + success: true, + message: `리비전 ${result.revision}이 성공적으로 업로드되었습니다`, + data: { + revisionId: result.revisionId, + issueStageId: issueStageId, // ✅ 추가 + stage: result.stage, + revision: result.revision, + usage: result.usage, + usageType: result.usageType, + uploaderName: uploaderName, // ✅ 추가 + uploadedFiles: result.uploadedFiles, + filesCount: result.uploadedFiles.length + }, + }) + } catch (e) { + console.error("revision-upload error:", e) + return NextResponse.json( + { error: "Failed to upload revision", details: String(e) }, + { status: 500 }, + ) + } +} \ No newline at end of file diff --git a/app/api/revision-upload/route.ts b/app/api/revision-upload/route.ts index 35344b4b..1a9666a7 100644 --- a/app/api/revision-upload/route.ts +++ b/app/api/revision-upload/route.ts @@ -29,6 +29,8 @@ export async function POST(request: NextRequest) { const revision = formData.get("revision") as string | null const docId = Number(formData.get("documentId")) const uploaderName = formData.get("uploaderName") as string | null + const usage = formData.get("usage") as string | null + const usageType = formData.get("usageType") as string | null const comment = formData.get("comment") as string | null const mode = (formData.get("mode") || "new") as string // 'new'|'append' const targetSystem = (formData.get("targetSystem") as string | null) ?? "DOLCE" diff --git a/app/globals.css b/app/globals.css index f1d8bb54..fa510ec4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -210,4 +210,12 @@ th[data-read-only="true"] { /* 선택적: 편집 화면으로 이동 시 읽기 전용 필드 강조 */ .edit-mode .read-only-cell { box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); +} + +@layer components { + .tbl-compact thead th { @apply px-2 py-1 text-xs font-semibold; } + .tbl-compact tbody td { @apply px-2 py-1 text-xs align-middle; } + .tbl-compact tbody tr { @apply hover:bg-muted/40; } /* 선택 */ + /* 필요시 행 높이 제한 */ + .tbl-compact tbody tr > * { @apply h-8; } /* 32 px 정도 */ } \ No newline at end of file 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(null) + + const handleFileSelect = (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files || []) + if (selectedFiles.length > 0) { + onFilesChange([...files, ...selectedFiles]) + } + } + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault() + const droppedFiles = Array.from(event.dataTransfer.files) + if (droppedFiles.length > 0) { + onFilesChange([...files, ...droppedFiles]) + } + } + + const handleDragOver = (event: React.DragEvent) => { + 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 ( +
+
fileInputRef.current?.click()} + > + +

+ 추가할 파일을 드래그하여 놓거나 클릭하여 선택하세요 +

+

+ PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) +

+ +
+ + {files.length > 0 && ( +
+

선택된 파일 ({files.length}개)

+
+ {files.map((file, index) => ( +
+
+ +
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
+
+ +
+ ))} +
+
+ )} +
+ ) +} + +/* ------------------------------------------------------------------------------------------------- + * 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 + + const form = useForm({ + 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 ( + + + {/* 고정 헤더 */} + + + + 첨부파일 추가 + + + 리비전 {revisionName}에 추가 첨부파일을 업로드합니다 + + + +
+ + {/* 스크롤 가능한 중간 영역 */} +
+ {/* 파일 업로드 */} + ( + + 첨부파일 + + + + + + )} + /> + + {/* 업로드 진행률 */} + {isUploading && ( +
+
+ 업로드 진행률 + {uploadProgress.toFixed(0)}% +
+ + {uploadProgress === 100 && ( +
+ + 업로드 완료 +
+ )} +
+ )} +
+ + {/* 고정 버튼 영역 */} + + + + +
+ +
+
+ ) + } \ 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(null) + + const handleFileSelect = (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files || []) + if (selectedFiles.length > 0) { + onFilesChange([...files, ...selectedFiles]) + } + } + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault() + const droppedFiles = Array.from(event.dataTransfer.files) + if (droppedFiles.length > 0) { + onFilesChange([...files, ...droppedFiles]) + } + } + + const handleDragOver = (event: React.DragEvent) => { + 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 ( +
+
fileInputRef.current?.click()} + > + +

+ 파일을 드래그하여 놓거나 클릭하여 선택하세요 +

+

+ PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) +

+ +
+ + {files.length > 0 && ( +
+

선택된 파일 ({files.length}개)

+
+ {files.map((file, index) => ( +
+
+ +
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
+
+ +
+ ))} +
+
+ )} +
+ ) +} + +/* ------------------------------------------------------------------------------------------------- + * 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 + + const form = useForm({ + 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 ( + + + {/* 고정 헤더 */} + + + + 새 리비전 업로드 + + {documentTitle && ( + +
문서: {documentTitle}
+
+ )} +
+ +
+ + {/* 스크롤 가능한 중간 영역 */} +
+ {/* 용도 선택 */} + ( + + 용도 + + + + )} + /> + + {/* 용도 타입 선택 (B3만) */} + {showUsageType && watchedUsage && ( + ( + + 용도 타입 + + + + )} + /> + )} + + {/* 리비전 */} + ( + + 리비전 + + + +
+ {revisionGuide} +
+ +
+ )} + /> + + {/* 코멘트 */} + ( + + 코멘트 + +