diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/b-rfq/[id]/initial/page.tsx | 16 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/document-list-ship/page.tsx | 8 | ||||
| -rw-r--r-- | app/api/revision-attachment/route.ts | 153 | ||||
| -rw-r--r-- | app/api/revision-upload-ship/route.ts | 281 | ||||
| -rw-r--r-- | app/api/revision-upload/route.ts | 2 | ||||
| -rw-r--r-- | app/globals.css | 8 |
6 files changed, 458 insertions, 10 deletions
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) { <div className="space-y-6"> <div> <h3 className="text-lg font-medium"> - Vendors + Initial RFQ List </h3> <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. + 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다. </p> </div> <Separator /> <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/> + <InitialRfqDetailTable promises={promises} rfqId={idAsNumber}/> </div> </div> ) 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 ( <Shell className="gap-2"> <div className="flex items-center justify-between space-y-2"> <div> <h2 className="text-2xl font-bold tracking-tight"> - 내 문서 관리 + {vendorName} Document Management </h2> <p className="text-muted-foreground"> - 소속 회사의 모든 계약 문서를 확인하고 관리합니다. + </p> </div> </div> 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 |
