diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 17:29:43 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 17:29:43 +0000 |
| commit | bc7d627f61a4d055b19d0679b3a4c128b7afcfda (patch) | |
| tree | 84c765b0334c39246444c0a67916c5174b6e2cc7 | |
| parent | 4bad21ef79fdda5f016e2012ba673d6ee6abb5fc (diff) | |
(대표님) admin / api / components
| -rw-r--r-- | app/[lng]/admin/page.tsx | 132 | ||||
| -rw-r--r-- | app/api/admin/clear-test-data/route.ts | 60 | ||||
| -rw-r--r-- | app/api/cron/form-tags/start/route.ts | 11 | ||||
| -rw-r--r-- | app/api/cron/tags/start/route.ts | 2 | ||||
| -rw-r--r-- | app/api/revision-upload/route.ts | 345 | ||||
| -rw-r--r-- | app/api/vendor-investigations/[investigationId]/attachments/route.ts | 8 | ||||
| -rw-r--r-- | components/form-data/form-data-report-batch-dialog.tsx | 283 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 101 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 60 | ||||
| -rw-r--r-- | config/menuConfig.ts | 6 | ||||
| -rw-r--r-- | config/vendorTbeColumnsConfig.ts | 2 |
11 files changed, 698 insertions, 312 deletions
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<string | null>(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 ( + <div className="min-h-screen bg-gray-50 py-8"> + <div className="max-w-2xl mx-auto px-4"> + <div className="bg-white rounded-lg shadow-md p-6"> + {/* 헤더 */} + <div className="border-b pb-4 mb-6"> + <h1 className="text-2xl font-bold text-gray-900"> + 🔧 개발 관리자 패널 + </h1> + <p className="text-gray-600 mt-2"> + 테스트 데이터 관리 및 개발 도구 + </p> + <div className="mt-2 inline-block bg-yellow-100 text-yellow-800 px-2 py-1 rounded text-sm"> + Development Mode Only + </div> + </div> + + {/* 테스트 데이터 삭제 섹션 */} + <div className="mb-8"> + <h2 className="text-lg font-semibold mb-4 text-gray-800"> + 🗑️ 테스트 데이터 삭제 + </h2> + + <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4"> + <h3 className="font-medium text-red-800 mb-2">삭제될 테이블:</h3> + <ul className="text-sm text-red-700 space-y-1"> + <li>• forms (양식 정보)</li> + <li>• form_metas (양식 메타데이터)</li> + <li>• form_entries (양식 입력 데이터)</li> + <li>• tags (태그 정보)</li> + </ul> + </div> + + <div className="flex gap-3"> + <button + onClick={clearTestData} + disabled={isLoading} + className={`px-4 py-2 rounded-md font-medium transition-colors ${ + isLoading + ? 'bg-gray-300 text-gray-500 cursor-not-allowed' + : 'bg-red-600 text-white hover:bg-red-700' + }`} + > + {isLoading ? '삭제 중...' : '🗑️ 전체 데이터 삭제'} + </button> + + <button + onClick={checkApiStatus} + disabled={isLoading} + className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition-colors" + > + 🔍 API 상태 확인 + </button> + </div> + </div> + + {/* 결과 표시 */} + {lastResult && ( + <div className="bg-gray-50 border rounded-md p-4"> + <h3 className="font-medium text-gray-800 mb-2">실행 결과:</h3> + <p className="text-sm font-mono whitespace-pre-wrap"> + {lastResult} + </p> + </div> + )} + + {/* 추가 정보 */} + <div className="mt-8 pt-6 border-t"> + <h3 className="font-medium text-gray-800 mb-2">📋 사용법:</h3> + <div className="text-sm text-gray-600 space-y-1"> + <p>• 이 페이지는 개발 환경에서만 접근 가능합니다</p> + <p>• 삭제 전 반드시 확인 창이 표시됩니다</p> + <p>• API 엔드포인트: <code className="bg-gray-100 px-1 rounded">/api/admin/clear-test-data</code></p> + </div> + </div> + </div> + </div> + </div> + ) +}
\ 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<string, { projectCode?: string; formCode?: string; packageId?: number; + mode?: string }>(); 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<FormDataReportBatchDialogProps> = ({ const [selectTemp, setSelectTemp] = useState<string>(""); const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [isUploading, setIsUploading] = useState(false); + + // Add new state for publish dialog + const [publishDialogOpen, setPublishDialogOpen] = useState<boolean>(false); + const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(null); useEffect(() => { updateReportTempList(packageId, formId, setTempList); @@ -125,48 +130,43 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ 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<FormDataReportBatchDialogProps> = ({ } }; + // 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 ( - <Dialog open={open} onOpenChange={onClose}> - <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> - <DialogHeader> - <DialogTitle>Vendor Document Create</DialogTitle> - <DialogDescription> - Vendor Document Template을 선택하신 후 갑지를 업로드하여 주시기 - 바랍니다. - </DialogDescription> - </DialogHeader> - <div className="h-[60px]"> - <Label>Vendor Document Template Select</Label> - <Select value={selectTemp} onValueChange={setSelectTemp}> - <SelectTrigger className="w-[100%]"> - <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." /> - </SelectTrigger> - <SelectContent> - {tempList.map((c) => { - const { fileName, filePath } = c; - - return ( - <SelectItem key={filePath} value={filePath}> - {fileName} - </SelectItem> - ); - })} - </SelectContent> - </Select> - </div> - <div> - <Label>Vendor Document Cover Page Upload(.docx)</Label> - <Dropzone - maxSize={MAX_FILE_SIZE} - multiple={false} - accept={{ accept: [".docx"] }} - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - disabled={isUploading} - > - {({ maxSize }) => ( - <> - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> - <DropzoneDescription> - 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} - {maxSize ? prettyBytes(maxSize) : "무제한"} - </DropzoneDescription> + <> + <Dialog open={open} onOpenChange={onClose}> + <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>Vendor Document Create</DialogTitle> + <DialogDescription> + Vendor Document Template을 선택하신 후 갑지를 업로드하여 주시기 + 바랍니다. + </DialogDescription> + </DialogHeader> + <div className="h-[60px]"> + <Label>Vendor Document Template Select</Label> + <Select value={selectTemp} onValueChange={setSelectTemp}> + <SelectTrigger className="w-[100%]"> + <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." /> + </SelectTrigger> + <SelectContent> + {tempList.map((c) => { + const { fileName, filePath } = c; + + return ( + <SelectItem key={filePath} value={filePath}> + {fileName} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div> + <Label>Vendor Document Cover Page Upload(.docx)</Label> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={false} + accept={{ accept: [".docx"] }} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> </div> - </div> - </DropzoneZone> - <Label className="text-xs text-muted-foreground"> - 여러 파일을 선택할 수 있습니다. - </Label> - </> - )} - </Dropzone> - </div> - - {selectedFiles.length > 0 && ( - <div className="grid gap-2"> - <div className="flex items-center justify-between"> - <h6 className="text-sm font-semibold"> - 선택된 파일 ({selectedFiles.length}) - </h6> - <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> - </div> - <ScrollArea> - <UploadFileItem - selectedFiles={selectedFiles} - removeFile={removeFile} - isUploading={isUploading} - /> - </ScrollArea> + </DropzoneZone> + <Label className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </Label> + </> + )} + </Dropzone> </div> - )} - - <DialogFooter> - <Button - disabled={ - selectedFiles.length === 0 || - selectTemp.length === 0 || - isUploading - } - onClick={submitData} - > - {isUploading && <Loader2 />}Vendor Document Create - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> + </div> + <ScrollArea> + <UploadFileItem + selectedFiles={selectedFiles} + removeFile={removeFile} + isUploading={isUploading} + /> + </ScrollArea> + </div> + )} + + <DialogFooter> + {/* Add the new Publish button */} + <Button + onClick={prepareFileForPublishing} + disabled={ + selectedFiles.length === 0 || + selectTemp.length === 0 || + isUploading + } + variant="outline" + className="mr-2" + > + {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Publish + </Button> + <Button + disabled={ + selectedFiles.length === 0 || + selectTemp.length === 0 || + isUploading + } + onClick={submitData} + > + {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Create Vendor Document + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Add the PublishDialog component */} + <PublishDialog + open={publishDialogOpen} + onOpenChange={setPublishDialogOpen} + packageId={packageId} + formCode={formCode} + fileBlob={generatedFileBlob || undefined} + /> + </> ); }; 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<TData> { row: Row<TData>; @@ -39,8 +41,8 @@ export interface DataTableColumnJSON { uom?: string; uomId?: string; shi?: boolean; - } + /** * getColumns 함수에 필요한 props * - TData: 테이블에 표시할 행(Row)의 타입 @@ -52,20 +54,77 @@ interface GetColumnsProps<TData> { >; setReportData: React.Dispatch<React.SetStateAction<{ [key: string]: any }[]>>; tempCount: number; + // 체크박스 선택 관련 props + selectedRows?: Record<string, boolean>; + onRowSelectionChange?: (updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void; } /** * getColumns 함수 * 1) columnsJSON 배열을 순회하면서 accessorKey / header / cell 등을 설정 - * 2) 마지막에 "Action" 칼럼(예: update 버튼) 추가 + * 2) 체크박스 컬럼 추가 (showBatchSelection이 true일 때) + * 3) 마지막에 "Action" 칼럼(예: update 버튼) 추가 */ export function getColumns<TData extends object>({ columnsJSON, setRowAction, setReportData, tempCount, + selectedRows = {}, + onRowSelectionChange, }: GetColumnsProps<TData>): ColumnDef<TData>[] { - // (1) 기본 컬럼들 + const columns: ColumnDef<TData>[] = []; + + // (1) 체크박스 컬럼 (항상 표시) + const selectColumn: ColumnDef<TData> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => { + table.toggleAllPageRowsSelected(!!value); + + // 모든 행 선택/해제 + if (onRowSelectionChange) { + const allRowsSelection: Record<string, boolean> = {}; + table.getRowModel().rows.forEach((row) => { + allRowsSelection[row.id] = !!value; + }); + onRowSelectionChange(allRowsSelection); + } + }} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + 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<TData>[] = columnsJSON.map((col) => ({ accessorKey: col.key, header: ({ column }) => ( @@ -82,7 +141,7 @@ export function getColumns<TData extends object>({ 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<TData extends object>({ </div> ); - // case "date": - // // 예: 날짜 포맷팅 - // // 실제론 dayjs / date-fns 등으로 포맷 - // if (!cellValue) return <div></div> - // 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<TData extends object>({ }, })); - // (3) 액션 칼럼 - update 버튼 예시 + columns.push(...baseColumns); + + // (4) 액션 칼럼 - update 버튼 예시 const actionColumn: ColumnDef<TData> = { id: "update", header: "", @@ -162,12 +215,6 @@ export function getColumns<TData extends object>({ <DropdownMenuContent align="end" className="w-40"> <DropdownMenuItem onSelect={() => { - // 행에 있는 모든 필드가 읽기 전용인지 확인할 수도 있습니다 (선택 사항) - // const allColumnsReadOnly = columnsJSON.every(col => col.shi === true); - // if(allColumnsReadOnly) { - // toast.info("이 항목은 읽기 전용입니다."); - // return; - // } setRowAction({ row, type: "update" }); }} > @@ -176,11 +223,11 @@ export function getColumns<TData extends object>({ <DropdownMenuItem onSelect={() => { 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<TData extends object>({ 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<DataTableRowAction<GenericData> | null>(null); const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); + // 배치 선택 관련 상태 + const [selectedRows, setSelectedRows] = React.useState<Record<string, boolean>>({}); + // 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<GenericData>({ columnsJSON, setRowAction, setReportData, tempCount }), - [columnsJSON, setRowAction, setReportData, tempCount] + () => getColumns<GenericData>({ + 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 && ( + <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md"> + <p className="text-sm text-blue-700"> + {selectedRowCount}개 항목이 선택되었습니다. 배치 문서는 선택된 항목만으로 생성됩니다. + </p> + </div> + )} + {/* 버튼 그룹 */} <div className="flex items-center gap-2"> {/* 태그 관리 드롭다운 */} @@ -583,6 +624,11 @@ export default function DynamicTable({ <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}> <FileOutput className="mr-2 h-4 w-4" /> Batch Document + {selectedRowCount > 0 && ( + <span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded"> + {selectedRowCount} + </span> + )} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -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 템플릿 파일 수 |
