From bc7d627f61a4d055b19d0679b3a4c128b7afcfda Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 17:29:43 +0000 Subject: (대표님) admin / api / components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/admin/page.tsx | 132 ++++++++ app/api/admin/clear-test-data/route.ts | 60 ++++ app/api/cron/form-tags/start/route.ts | 11 +- app/api/cron/tags/start/route.ts | 2 +- app/api/revision-upload/route.ts | 345 +++++++++++---------- .../[investigationId]/attachments/route.ts | 8 +- .../form-data/form-data-report-batch-dialog.tsx | 283 +++++++++++------ components/form-data/form-data-table-columns.tsx | 101 ++++-- components/form-data/form-data-table.tsx | 60 +++- config/menuConfig.ts | 6 +- config/vendorTbeColumnsConfig.ts | 2 +- 11 files changed, 698 insertions(+), 312 deletions(-) create mode 100644 app/[lng]/admin/page.tsx create mode 100644 app/api/admin/clear-test-data/route.ts diff --git a/app/[lng]/admin/page.tsx b/app/[lng]/admin/page.tsx new file mode 100644 index 00000000..04679342 --- /dev/null +++ b/app/[lng]/admin/page.tsx @@ -0,0 +1,132 @@ +// app/admin/page.tsx +'use client' + +import { useState } from 'react' + +export default function AdminPage() { + const [isLoading, setIsLoading] = useState(false) + const [lastResult, setLastResult] = useState(null) + + + const clearTestData = async () => { + const confirmation = window.confirm( + '⚠️ 정말로 모든 테스트 데이터를 삭제하시겠습니까?\n\n삭제될 데이터:\n- Forms\n- Form Metas\n- Form Entries\n- Tags\n\n이 작업은 되돌릴 수 없습니다!' + ) + + if (!confirmation) return + + setIsLoading(true) + setLastResult(null) + + try { + const response = await fetch('/api/admin/clear-test-data', { + method: 'DELETE', + }) + + const result = await response.json() + + if (result.success) { + setLastResult(`✅ 성공: ${result.message}`) + console.log('Deleted counts:', result.deleted) + } else { + setLastResult(`❌ 실패: ${result.error}`) + console.error('Error details:', result.details) + } + } catch (error) { + setLastResult(`❌ 네트워크 오류: ${error}`) + console.error('Network error:', error) + } finally { + setIsLoading(false) + } + } + + const checkApiStatus = async () => { + try { + const response = await fetch('/api/admin/clear-test-data') + const result = await response.json() + setLastResult(`ℹ️ API 상태: ${result.message}`) + } catch (error) { + setLastResult(`❌ API 체크 실패: ${error}`) + } + } + + return ( +
+
+
+ {/* 헤더 */} +
+

+ 🔧 개발 관리자 패널 +

+

+ 테스트 데이터 관리 및 개발 도구 +

+
+ Development Mode Only +
+
+ + {/* 테스트 데이터 삭제 섹션 */} +
+

+ 🗑️ 테스트 데이터 삭제 +

+ +
+

삭제될 테이블:

+
    +
  • • forms (양식 정보)
  • +
  • • form_metas (양식 메타데이터)
  • +
  • • form_entries (양식 입력 데이터)
  • +
  • • tags (태그 정보)
  • +
+
+ +
+ + + +
+
+ + {/* 결과 표시 */} + {lastResult && ( +
+

실행 결과:

+

+ {lastResult} +

+
+ )} + + {/* 추가 정보 */} +
+

📋 사용법:

+
+

• 이 페이지는 개발 환경에서만 접근 가능합니다

+

• 삭제 전 반드시 확인 창이 표시됩니다

+

• API 엔드포인트: /api/admin/clear-test-data

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/api/admin/clear-test-data/route.ts b/app/api/admin/clear-test-data/route.ts new file mode 100644 index 00000000..9a5c8458 --- /dev/null +++ b/app/api/admin/clear-test-data/route.ts @@ -0,0 +1,60 @@ +// app/api/admin/clear-test-data/route.ts +import db from '@/db/db' +import { formEntries, forms, formMetas, tags } from '@/db/schema/vendorData' +import { NextRequest } from 'next/server' + +export async function DELETE(request: NextRequest) { + + try { + // 외래키 참조 순서를 고려하여 삭제 (자식 테이블부터) + console.log('Clearing test data...') + + // 1. form_entries 삭제 + const deletedEntries = await db.delete(formEntries) + console.log('Deleted form entries') + + // 2. tags 삭제 + const deletedTags = await db.delete(tags) + console.log('Deleted tags') + + // 3. forms 삭제 + const deletedForms = await db.delete(forms) + console.log('Deleted forms') + + // 4. form_metas 삭제 + const deletedMetas = await db.delete(formMetas) + console.log('Deleted form metas') + + return Response.json({ + success: true, + message: 'All test data cleared successfully', + deleted: { + formEntries: deletedEntries, + tags: deletedTags, + forms: deletedForms, + formMetas: deletedMetas + } + }) + } catch (error) { + console.error('Error clearing test data:', error) + return Response.json({ + error: 'Failed to clear data', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }) + } +} + +// GET 요청도 지원 (브라우저에서 직접 접근 시) +export async function GET() { + if (process.env.NODE_ENV !== 'development') { + return Response.json({ + error: 'Not allowed in production' + }, { status: 403 }) + } + + return Response.json({ + message: 'Use DELETE method to clear test data', + endpoint: '/api/admin/clear-test-data', + method: 'DELETE' + }) +} \ No newline at end of file diff --git a/app/api/cron/form-tags/start/route.ts b/app/api/cron/form-tags/start/route.ts index 6a029c4c..67fbd73b 100644 --- a/app/api/cron/form-tags/start/route.ts +++ b/app/api/cron/form-tags/start/route.ts @@ -1,4 +1,4 @@ -// app/api/cron/tags/start/route.ts +// app/api/cron/form-tags/start/route.ts import { NextRequest } from 'next/server'; import { v4 as uuidv4 } from 'uuid'; import { revalidateTag } from 'next/cache'; @@ -15,6 +15,7 @@ const syncJobs = new Map(); export async function POST(request: NextRequest) { @@ -23,12 +24,14 @@ export async function POST(request: NextRequest) { let projectCode: string | undefined; let formCode: string | undefined; let packageId: number | undefined; + let mode: string | undefined; const body = await request.json(); projectCode = body.projectCode; formCode = body.formCode; packageId = body.contractItemId; + mode = body.mode; // 모드 정보 추출 // 고유 ID 생성 @@ -40,7 +43,8 @@ export async function POST(request: NextRequest) { startTime: new Date(), formCode, projectCode, - packageId + packageId, + mode }); @@ -78,6 +82,7 @@ async function processTagImport(syncId: string) { const formCode = jobInfo.formCode; const projectCode = jobInfo.projectCode; const packageId = jobInfo.packageId || 0; + const mode = jobInfo.mode || 0; // 상태 업데이트: 처리 중 syncJobs.set(syncId, { @@ -105,7 +110,7 @@ async function processTagImport(syncId: string) { const result = await importTagsFromSEDP(formCode, projectCode, packageId, updateProgress); // 명시적으로 캐시 무효화 - revalidateTag(`forms-${packageId}`); + revalidateTag(`forms-${packageId}-${mode}`); // 상태 업데이트: 완료 syncJobs.set(syncId, { diff --git a/app/api/cron/tags/start/route.ts b/app/api/cron/tags/start/route.ts index 3312aad8..f97d36c5 100644 --- a/app/api/cron/tags/start/route.ts +++ b/app/api/cron/tags/start/route.ts @@ -110,7 +110,7 @@ async function processTagImport(syncId: string) { // 명시적으로 캐시 무효화 revalidateTag(`tags-${packageId}`); - revalidateTag(`forms-${packageId}`); + revalidateTag(`forms-${packageId}-${mode}`); // 상태 업데이트: 완료 syncJobs.set(syncId, { diff --git a/app/api/revision-upload/route.ts b/app/api/revision-upload/route.ts index 2138d674..35344b4b 100644 --- a/app/api/revision-upload/route.ts +++ b/app/api/revision-upload/route.ts @@ -1,213 +1,224 @@ -// app/api/revision-upload/route.ts -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 db from '@/db/db' -import { documents, issueStages, revisions, documentAttachments } from '@/db/schema/vendorDocu' -import { and, eq } from 'drizzle-orm' +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() - - // FormData에서 데이터 추출 - const stage = formData.get("stage") as string | null - const revision = formData.get("revision") as string | null - const docIdStr = formData.get("documentId") as string - const docId = parseInt(docIdStr, 10) - const uploaderName = formData.get("uploaderName") as string | null - const comment = formData.get("comment") as string | null - const mode = formData.get("mode") as string || "new" // 'new' | 'append' - - // 파일들 추출 - const attachmentFiles = formData.getAll("attachments") as File[] - - // 유효성 검증 - if (!docId || Number.isNaN(docId)) { - return NextResponse.json( - { error: "Invalid or missing documentId" }, - { status: 400 } - ) - } - - if (!stage || !revision) { - return NextResponse.json( - { error: "Missing stage or revision" }, - { status: 400 } - ) - } - - if (attachmentFiles.length === 0) { - return NextResponse.json( - { error: "No files provided" }, - { status: 400 } - ) - } - // 파일 크기 검증 (각 파일 최대 3GB) - const maxFileSize = 3 * 1024 * 1024 * 1024 // 3GB - for (const file of attachmentFiles) { - if (file.size > maxFileSize) { + /* ------- 파라미터 파싱 ------- */ + const stage = formData.get("stage") 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 mode = (formData.get("mode") || "new") as string // 'new'|'append' + 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 (!stage || !revision) + return NextResponse.json({ error: "Missing stage or revision" }, { status: 400 }) + if (!attachmentFiles.length) + return NextResponse.json({ error: "No files provided" }, { status: 400 }) + + const MAX = 3 * 1024 * 1024 * 1024 // 3 GB + for (const f of attachmentFiles) + if (f.size > MAX) return NextResponse.json( - { error: `파일 ${file.name}이 너무 큽니다. 최대 3GB까지 허용됩니다.` }, + { error: `${f.name} > 3 GB` }, { status: 400 } ) - } - } - // 트랜잭션 실행 + /* ------- 계약 ID 확보 ------- */ + const [{ contractId }] = await db + .select({ contractId: documents.contractId }) + .from(documents) + .where(eq(documents.id, docId)) + .limit(1) + + /* ------- 트랜잭션 ------- */ const result = await db.transaction(async (tx) => { - // (1) issueStageId 찾기 또는 생성 + /* 1) Stage */ let issueStageId: number - const stageRecord = await tx - .select() + const [stageRow] = await tx + .select({ id: issueStages.id }) .from(issueStages) - .where(and(eq(issueStages.stageName, stage), eq(issueStages.documentId, docId))) + .where(and( + eq(issueStages.stageName, stage), + eq(issueStages.documentId, docId) + )) .limit(1) - - if (!stageRecord.length) { - // Stage가 없으면 새로 생성 - const [newStage] = await tx - .insert(issueStages) - .values({ - documentId: docId, - stageName: stage, - updatedAt: new Date(), - }) - .returning() - - issueStageId = newStage.id - } else { - issueStageId = stageRecord[0].id - } - - // (2) Revision 찾기 또는 생성 + + if (!stageRow) { + const [s] = await tx.insert(issueStages) + .values({ documentId: docId, stageName: stage, updatedAt: new Date() }) + .returning({ id: issueStages.id }) + issueStageId = s.id + } else issueStageId = stageRow.id + + /* 2) Revision */ + const today = new Date().toISOString().slice(0, 10) let revisionId: number - const revisionRecord = await tx + const [revRow] = await tx .select() .from(revisions) - .where(and(eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision))) + .where(and( + eq(revisions.issueStageId, issueStageId), + eq(revisions.revision, revision) + )) .limit(1) - - const currentDate = new Date().toISOString().split('T')[0] // YYYY-MM-DD 형식 - - if (!revisionRecord.length || mode === 'new') { - // 새 리비전 생성 - const [newRevision] = await tx - .insert(revisions) + + if (!revRow || mode === "new") { + /* --- CREATE --- */ + const [newRev] = await tx.insert(revisions) .values({ issueStageId, revision, - uploaderType: "vendor", // 항상 vendor로 고정 - uploaderName: uploaderName || undefined, - revisionStatus: "SUBMITTED", // 기본 상태 - submittedDate: currentDate, // 제출일 설정 - comment: comment || undefined, + uploaderType: "vendor", + uploaderName: uploaderName ?? undefined, + revisionStatus: "UPLOADED", + uploadedAt: today, + comment: comment ?? undefined, updatedAt: new Date(), }) .returning() - - revisionId = newRevision.id - console.log("✅ 새 리비전 생성:", { revisionId, revision }) + revisionId = newRev.id + + // change_logs: CREATE + await logRevisionChange( + contractId, + revisionId, + "CREATE", + newRev, + undefined, + undefined, + uploaderName ?? undefined, + [targetSystem] + ) } else { - // 기존 리비전에 파일 추가 (append 모드) - revisionId = revisionRecord[0].id - - // 기존 리비전 정보 업데이트 (코멘트나 업로더명 변경 가능) - await tx - .update(revisions) + /* --- UPDATE --- */ + await tx.update(revisions) .set({ - uploaderName: uploaderName || revisionRecord[0].uploaderName, - comment: comment || revisionRecord[0].comment, + uploaderName: uploaderName ?? revRow.uploaderName, + comment: comment ?? revRow.comment, updatedAt: new Date(), }) - .where(eq(revisions.id, revisionId)) - - console.log("✅ 기존 리비전에 파일 추가:", { revisionId, revision }) + .where(eq(revisions.id, revRow.id)) + + const [updated] = await tx + .select() + .from(revisions) + .where(eq(revisions.id, revRow.id)) + + revisionId = revRow.id + + await logRevisionChange( + contractId, + revisionId, + "UPDATE", + updated, + revRow, + undefined, + uploaderName ?? undefined, + [targetSystem] + ) } - - // (3) 파일들 저장 및 DB 기록 - const uploadedFiles = [] + + /* 3) Attachments */ + const uploadedFiles: any[] = [] const baseDir = join(process.cwd(), "public", "documents") - + for (const file of attachmentFiles) { - if (file.size > 0) { - const originalName = file.name - const ext = path.extname(originalName) - const uniqueName = uuidv4() + ext - const savePath = join(baseDir, uniqueName) - - // 파일 저장 - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - await writeFile(savePath, buffer) - - // DB에 첨부파일 정보 저장 - const [attachmentRecord] = await tx - .insert(documentAttachments) - .values({ - revisionId, - fileName: originalName, - filePath: "/documents/" + uniqueName, - fileSize: file.size, - fileType: ext.replace('.', '').toLowerCase() || undefined, - updatedAt: new Date(), - }) - .returning() - - uploadedFiles.push({ - id: attachmentRecord.id, - fileName: originalName, + 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, - filePath: attachmentRecord.filePath, + fileType: ext.slice(1).toLowerCase() || undefined, + updatedAt: new Date(), }) - - console.log("✅ 파일 저장 완료:", originalName) - } + .returning() + + uploadedFiles.push({ + id: att.id, + fileName: file.name, + fileSize: file.size, + filePath: att.filePath, + }) + + // change_logs: attachment CREATE + await logAttachmentChange( + contractId, + att.id, + "CREATE", + att, + undefined, + undefined, + uploaderName ?? undefined, + [targetSystem] + ) } - - // (4) Documents 테이블의 updatedAt 갱신 - await tx - .update(documents) + + /* 4) documents.updatedAt */ + await tx.update(documents) .set({ updatedAt: new Date() }) .where(eq(documents.id, docId)) - - return { - revisionId, - stage, - revision, - uploaderName, - comment, - uploadedFiles, - mode, - } - }) - console.log("✅ 리비전 업로드 완료:", { - documentId: docId, - stage, - revision, - filesCount: result.uploadedFiles.length, - mode + return { revisionId, stage, revision, uploadedFiles, mode, contractId } }) + // ✅ 캐시 무효화 - 트랜잭션 완료 후에 실행 + try { + // enhanced documents 캐시 무효화 + revalidateTag(`enhanced-documents-${result.contractId}`) + + // sync status 관련 캐시도 무효화 (필요시) + 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}개 파일이 성공적으로 업로드되었습니다.`, + message: `${result.uploadedFiles.length}개 파일 업로드 완료`, data: result, }) - - } catch (error) { - console.error('❌ 리비전 업로드 오류:', error) - + } catch (e) { + console.error("revision-upload error:", e) return NextResponse.json( - { - error: 'Failed to upload revision', - details: error instanceof Error ? error.message : String(error), - }, - { status: 500 } + { error: "Failed to upload revision", details: String(e) }, + { status: 500 }, ) } } \ No newline at end of file diff --git a/app/api/vendor-investigations/[investigationId]/attachments/route.ts b/app/api/vendor-investigations/[investigationId]/attachments/route.ts index 80513e28..6787de7e 100644 --- a/app/api/vendor-investigations/[investigationId]/attachments/route.ts +++ b/app/api/vendor-investigations/[investigationId]/attachments/route.ts @@ -7,12 +7,16 @@ import db from "@/db/db" import { vendorInvestigationAttachments } from "@/db/schema" import { eq } from "drizzle-orm"; +type Ctx = { params: Promise<{ investigationId: string }> }; + export async function POST( req: NextRequest, - { params }: { params: { investigationId: string } } + context: Ctx // ① 두 번째 인자를 통째로 받는다 ) { try { - const investigationId = parseInt(params.investigationId) + const { investigationId: idParam } = await context.params; // ② 여기서 await + const investigationId = Number(idParam); + if (!investigationId) { return NextResponse.json({ error: "Invalid investigation ID" }, { status: 400 }) } diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx index ef921a91..53f8c489 100644 --- a/components/form-data/form-data-report-batch-dialog.tsx +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -51,6 +51,7 @@ import { import { Button } from "@/components/ui/button"; import { getReportTempList, getOrigin } from "@/lib/forms/services"; import { DataTableColumnJSON } from "./form-data-table-columns"; +import { PublishDialog } from "./publish-dialog"; const MAX_FILE_SIZE = 3000000; @@ -87,6 +88,10 @@ export const FormDataReportBatchDialog: FC = ({ const [selectTemp, setSelectTemp] = useState(""); const [selectedFiles, setSelectedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); + + // Add new state for publish dialog + const [publishDialogOpen, setPublishDialogOpen] = useState(false); + const [generatedFileBlob, setGeneratedFileBlob] = useState(null); useEffect(() => { updateReportTempList(packageId, formId, setTempList); @@ -125,48 +130,43 @@ export const FormDataReportBatchDialog: FC = ({ setSelectedFiles(updatedFiles); }; + // Create and download document const submitData = async () => { setIsUploading(true); try { const origin = await getOrigin(); - const targetFiles = selectedFiles[0]; const reportDatas = reportData.map((c) => { const reportValue = stringifyAllValues(c); - const reportValueMapping: { [key: string]: any } = {}; columnsJSON.forEach((c2) => { const { key } = c2; - - // const objKey = label.split(" ").join("_"); - reportValueMapping[key] = reportValue?.[key] ?? ""; }); return reportValueMapping; }); + const formData = new FormData(); formData.append("file", targetFiles); formData.append("customFileName", `${formCode}.pdf`); formData.append("reportDatas", JSON.stringify(reportDatas)); formData.append("reportTempPath", selectTemp); - const reqeustCreateReport = await fetch( + const requestCreateReport = await fetch( `${origin}/api/pdftron/createVendorDataReports`, { method: "POST", body: formData } ); - if (reqeustCreateReport.ok) { - const blob = await reqeustCreateReport.blob(); - + if (requestCreateReport.ok) { + const blob = await requestCreateReport.blob(); saveAs(blob, `${formCode}.pdf`); - toastMessage.success("Report 다운로드 완료!"); } else { - const err = await reqeustCreateReport.json(); + const err = await requestCreateReport.json(); console.error("에러:", err); throw new Error(err.message); } @@ -184,100 +184,179 @@ export const FormDataReportBatchDialog: FC = ({ } }; + // New function to prepare the file for publishing + const prepareFileForPublishing = async () => { + setIsUploading(true); + + try { + const origin = await getOrigin(); + const targetFiles = selectedFiles[0]; + + const reportDatas = reportData.map((c) => { + const reportValue = stringifyAllValues(c); + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c2) => { + const { key } = c2; + reportValueMapping[key] = reportValue?.[key] ?? ""; + }); + + return reportValueMapping; + }); + + const formData = new FormData(); + formData.append("file", targetFiles); + formData.append("customFileName", `${formCode}.pdf`); + formData.append("reportDatas", JSON.stringify(reportDatas)); + formData.append("reportTempPath", selectTemp); + + const requestCreateReport = await fetch( + `${origin}/api/pdftron/createVendorDataReports`, + { method: "POST", body: formData } + ); + + if (requestCreateReport.ok) { + const blob = await requestCreateReport.blob(); + setGeneratedFileBlob(blob); + setPublishDialogOpen(true); + toastMessage.success("문서가 생성되었습니다. 발행 정보를 입력해주세요."); + } else { + const err = await requestCreateReport.json(); + console.error("에러:", err); + throw new Error(err.message); + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "문서 생성 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + } + }; + return ( - - - - Vendor Document Create - - Vendor Document Template을 선택하신 후 갑지를 업로드하여 주시기 - 바랍니다. - - -
- - -
-
- - - {({ maxSize }) => ( - <> - - -
- -
- 파일을 여기에 드롭하세요 - - 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} - {maxSize ? prettyBytes(maxSize) : "무제한"} - + <> + + + + Vendor Document Create + + Vendor Document Template을 선택하신 후 갑지를 업로드하여 주시기 + 바랍니다. + + +
+ + +
+
+ + + {({ maxSize }) => ( + <> + + +
+ +
+ 파일을 여기에 드롭하세요 + + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + +
-
- - - - )} - -
- - {selectedFiles.length > 0 && ( -
-
-
- 선택된 파일 ({selectedFiles.length}) -
- {selectedFiles.length}개 파일 -
- - - + + + + )} +
- )} - - - - - -
+ + {selectedFiles.length > 0 && ( +
+
+
+ 선택된 파일 ({selectedFiles.length}) +
+ {selectedFiles.length}개 파일 +
+ + + +
+ )} + + + {/* Add the new Publish button */} + + + + + + + {/* Add the PublishDialog component */} + + ); }; diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index a1fbcae1..de479efb 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -1,6 +1,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table"; import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Ellipsis } from "lucide-react"; import { formatDate } from "@/lib/utils"; import { @@ -17,6 +18,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { toast } from 'sonner'; + /** row 액션 관련 타입 */ export interface DataTableRowAction { row: Row; @@ -39,8 +41,8 @@ export interface DataTableColumnJSON { uom?: string; uomId?: string; shi?: boolean; - } + /** * getColumns 함수에 필요한 props * - TData: 테이블에 표시할 행(Row)의 타입 @@ -52,20 +54,77 @@ interface GetColumnsProps { >; setReportData: React.Dispatch>; tempCount: number; + // 체크박스 선택 관련 props + selectedRows?: Record; + onRowSelectionChange?: (updater: Record | ((prev: Record) => Record)) => void; } /** * getColumns 함수 * 1) columnsJSON 배열을 순회하면서 accessorKey / header / cell 등을 설정 - * 2) 마지막에 "Action" 칼럼(예: update 버튼) 추가 + * 2) 체크박스 컬럼 추가 (showBatchSelection이 true일 때) + * 3) 마지막에 "Action" 칼럼(예: update 버튼) 추가 */ export function getColumns({ columnsJSON, setRowAction, setReportData, tempCount, + selectedRows = {}, + onRowSelectionChange, }: GetColumnsProps): ColumnDef[] { - // (1) 기본 컬럼들 + const columns: ColumnDef[] = []; + + // (1) 체크박스 컬럼 (항상 표시) + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + { + table.toggleAllPageRowsSelected(!!value); + + // 모든 행 선택/해제 + if (onRowSelectionChange) { + const allRowsSelection: Record = {}; + table.getRowModel().rows.forEach((row) => { + allRowsSelection[row.id] = !!value; + }); + onRowSelectionChange(allRowsSelection); + } + }} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + { + row.toggleSelected(!!value); + + // 개별 행 선택 상태 업데이트 + if (onRowSelectionChange) { + onRowSelectionChange(prev => ({ + ...prev, + [row.id]: !!value + })); + } + }} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }; + columns.push(selectColumn); + + // (2) 기본 컬럼들 const baseColumns: ColumnDef[] = columnsJSON.map((col) => ({ accessorKey: col.key, header: ({ column }) => ( @@ -82,7 +141,7 @@ export function getColumns({ maxWidth: col.key === "TAG_NO" ? 120 : 150, isReadOnly: col.shi === true, // shi 정보를 메타데이터에 저장 }, - // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 + // (3) 실제 셀(cell) 렌더링: type에 따라 분기 가능 cell: ({ row }) => { const cellValue = row.getValue(col.key); @@ -109,14 +168,6 @@ export function getColumns({ ); - // case "date": - // // 예: 날짜 포맷팅 - // // 실제론 dayjs / date-fns 등으로 포맷 - // if (!cellValue) return
- // const dateString = cellValue as string - // if (!dateString) return null - // return formatDate(new Date(dateString)) - case "LIST": // 예: select인 경우 label만 표시 return ( @@ -144,7 +195,9 @@ export function getColumns({ }, })); - // (3) 액션 칼럼 - update 버튼 예시 + columns.push(...baseColumns); + + // (4) 액션 칼럼 - update 버튼 예시 const actionColumn: ColumnDef = { id: "update", header: "", @@ -162,12 +215,6 @@ export function getColumns({ { - // 행에 있는 모든 필드가 읽기 전용인지 확인할 수도 있습니다 (선택 사항) - // const allColumnsReadOnly = columnsJSON.every(col => col.shi === true); - // if(allColumnsReadOnly) { - // toast.info("이 항목은 읽기 전용입니다."); - // return; - // } setRowAction({ row, type: "update" }); }} > @@ -176,11 +223,11 @@ export function getColumns({ { if(tempCount > 0){ - const { original } = row; - setReportData([original]); - } else { - toast.error("업로드된 Template File이 없습니다."); - } + const { original } = row; + setReportData([original]); + } else { + toast.error("업로드된 Template File이 없습니다."); + } }} > Create Document @@ -192,6 +239,8 @@ export function getColumns({ enablePinning: true, }; - // (4) 최종 반환 - return [...baseColumns, actionColumn]; + columns.push(actionColumn); + + // (5) 최종 반환 + return columns; } \ No newline at end of file diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 05278375..0a76e145 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -131,9 +131,14 @@ export default function DynamicTable({ React.useState | null>(null); const [tableData, setTableData] = React.useState(dataJSON); + // 배치 선택 관련 상태 + const [selectedRows, setSelectedRows] = React.useState>({}); + // Update tableData when dataJSON changes React.useEffect(() => { setTableData(dataJSON); + // 데이터가 변경되면 선택 상태 초기화 + setSelectedRows({}); }, [dataJSON]); // 폴링 상태 관리를 위한 ref @@ -207,9 +212,27 @@ export default function DynamicTable({ } }, [projectId]); + // 선택된 행들의 실제 데이터 가져오기 + const getSelectedRowsData = React.useCallback(() => { + const selectedIndices = Object.keys(selectedRows).filter(key => selectedRows[key]); + return selectedIndices.map(index => tableData[parseInt(index)]).filter(Boolean); + }, [selectedRows, tableData]); + + // 선택된 행 개수 계산 + const selectedRowCount = React.useMemo(() => { + return Object.values(selectedRows).filter(Boolean).length; + }, [selectedRows]); + const columns = React.useMemo( - () => getColumns({ columnsJSON, setRowAction, setReportData, tempCount }), - [columnsJSON, setRowAction, setReportData, tempCount] + () => getColumns({ + columnsJSON, + setRowAction, + setReportData, + tempCount, + selectedRows, + onRowSelectionChange: setSelectedRows + }), + [columnsJSON, setRowAction, setReportData, tempCount, selectedRows] ); function mapColumnTypeToAdvancedFilterType( @@ -518,13 +541,22 @@ export default function DynamicTable({ } } - // Handle batch document check + // Handle batch document with smart selection logic const handleBatchDocument = () => { - if (tempCount > 0) { - setBatchDownDialog(true); - } else { + if (tempCount === 0) { toast.error("업로드된 Template File이 없습니다."); + return; } + + // 선택된 항목이 있으면 선택된 것만, 없으면 전체 사용 + const selectedData = getSelectedRowsData(); + if (selectedData.length > 0) { + toast.info(`선택된 ${selectedData.length}개 항목으로 배치 문서를 생성합니다.`); + } else { + toast.info(`전체 ${tableData.length}개 항목으로 배치 문서를 생성합니다.`); + } + + setBatchDownDialog(true); }; return ( @@ -534,6 +566,15 @@ export default function DynamicTable({ columns={columns} advancedFilterFields={advancedFilterFields} > + {/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */} + {selectedRowCount > 0 && ( +
+

+ {selectedRowCount}개 항목이 선택되었습니다. 배치 문서는 선택된 항목만으로 생성됩니다. +

+
+ )} + {/* 버튼 그룹 */}
{/* 태그 관리 드롭다운 */} @@ -583,6 +624,11 @@ export default function DynamicTable({ Batch Document + {selectedRowCount > 0 && ( + + {selectedRowCount} + + )} @@ -748,7 +794,7 @@ export default function DynamicTable({ open={batchDownDialog} setOpen={setBatchDownDialog} columnsJSON={columnsJSON} - reportData={tableData} + reportData={selectedRowCount > 0 ? getSelectedRowsData() : tableData} packageId={contractItemId} formCode={formCode} formId={formId} diff --git a/config/menuConfig.ts b/config/menuConfig.ts index a67efc35..f1f5af63 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -187,19 +187,19 @@ export const mainNav: MenuSection[] = [ }, { title: "Budgetary Quote", - href: "/evcp/budgetary", + href: "/evcp/rfq-tech", description: "RFQ 작성을 할 수 있고 현황을 파악", group: "해양" }, { title: "기술(품질) 평가 (TBE) - Budgetary Quote", - href: "/evcp/bqtbe", + href: "/evcp/tbe-tech", description: "사양 충족 여부, 품질 요건 확인", group: "해양" }, { title: "상업(가격) 평가 (CBE) - Budgetary Quote", - href: "/evcp/bqcbe", + href: "/evcp/cbe-tech", description: "가격(네고), 납기, 계약조건(Incoterms 등) 종합 검토", group: "해양" }, diff --git a/config/vendorTbeColumnsConfig.ts b/config/vendorTbeColumnsConfig.ts index 4d8755d7..8b6d51bb 100644 --- a/config/vendorTbeColumnsConfig.ts +++ b/config/vendorTbeColumnsConfig.ts @@ -33,7 +33,7 @@ export interface VendorWithTbeFields { tbeUpdated: Date | null tbeId: number | null - + technicalResponseId: number | null technicalResponseStatus:string | null templateFileCount?: number; // TBE 템플릿 파일 수 -- cgit v1.2.3