summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 17:29:43 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 17:29:43 +0000
commitbc7d627f61a4d055b19d0679b3a4c128b7afcfda (patch)
tree84c765b0334c39246444c0a67916c5174b6e2cc7
parent4bad21ef79fdda5f016e2012ba673d6ee6abb5fc (diff)
(대표님) admin / api / components
-rw-r--r--app/[lng]/admin/page.tsx132
-rw-r--r--app/api/admin/clear-test-data/route.ts60
-rw-r--r--app/api/cron/form-tags/start/route.ts11
-rw-r--r--app/api/cron/tags/start/route.ts2
-rw-r--r--app/api/revision-upload/route.ts345
-rw-r--r--app/api/vendor-investigations/[investigationId]/attachments/route.ts8
-rw-r--r--components/form-data/form-data-report-batch-dialog.tsx283
-rw-r--r--components/form-data/form-data-table-columns.tsx101
-rw-r--r--components/form-data/form-data-table.tsx60
-rw-r--r--config/menuConfig.ts6
-rw-r--r--config/vendorTbeColumnsConfig.ts2
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 템플릿 파일 수