From 10f90dc68dec42e9a64e081cc0dce6a484447290 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 29 Jul 2025 11:48:59 +0000 Subject: (대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu, docu-list-rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../add-basic-contract-template-dialog.tsx | 2 +- .../template/basic-contract-template-columns.tsx | 21 +- .../template/update-basicContract-sheet.tsx | 2 +- lib/docu-list-rule/combo-box-settings/service.ts | 33 +- .../table/combo-box-options-add-dialog.tsx | 23 +- lib/export.ts | 111 +- lib/exportFullData.ts | 189 ++++ .../gtc-clauses/table/clause-preview-viewer.tsx | 681 +++++++----- .../gtc-clauses/table/excel-import.tsx | 340 ++++++ .../table/gtc-clauses-table-toolbar-actions.tsx | 200 +++- .../gtc-clauses/table/import-excel-dialog.tsx | 381 +++++++ .../gtc-clauses/table/preview-document-dialog.tsx | 115 +- lib/gtc-contract/service.ts | 520 +++++++++- .../status/clone-gtc-document-dialog.tsx | 383 +++++++ .../status/create-gtc-document-dialog.tsx | 98 +- lib/gtc-contract/status/gtc-contract-table.tsx | 13 + .../status/gtc-documents-table-columns.tsx | 35 +- lib/gtc-contract/validations.ts | 33 +- .../enhanced-document-service.ts | 10 +- .../plant/document-stage-actions.ts | 0 .../plant/document-stage-dialogs.tsx | 789 ++++++++++++++ .../plant/document-stage-validations.ts | 339 ++++++ .../plant/document-stages-columns.tsx | 521 ++++++++++ .../plant/document-stages-expanded-content.tsx | 136 +++ .../plant/document-stages-service.ts | 1097 ++++++++++++++++++++ .../plant/document-stages-table.tsx | 449 ++++++++ .../plant/excel-import-export.ts | 788 ++++++++++++++ lib/vendor-document-list/repository.ts | 36 +- lib/vendor-document-list/validations.ts | 2 - 29 files changed, 6936 insertions(+), 411 deletions(-) create mode 100644 lib/exportFullData.ts create mode 100644 lib/gtc-contract/gtc-clauses/table/excel-import.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx create mode 100644 lib/gtc-contract/status/clone-gtc-document-dialog.tsx create mode 100644 lib/vendor-document-list/plant/document-stage-actions.ts create mode 100644 lib/vendor-document-list/plant/document-stage-dialogs.tsx create mode 100644 lib/vendor-document-list/plant/document-stage-validations.ts create mode 100644 lib/vendor-document-list/plant/document-stages-columns.tsx create mode 100644 lib/vendor-document-list/plant/document-stages-expanded-content.tsx create mode 100644 lib/vendor-document-list/plant/document-stages-service.ts create mode 100644 lib/vendor-document-list/plant/document-stages-table.tsx create mode 100644 lib/vendor-document-list/plant/excel-import-export.ts (limited to 'lib') diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx index 6b6ab105..43c19e67 100644 --- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx +++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx @@ -51,7 +51,7 @@ const TEMPLATE_NAME_OPTIONS = [ "기술자료 요구서", "비밀유지 계약서", "표준하도급기본 계약서", - "General GTC", + "GTC", "안전보건관리 약정서", "동반성장", "윤리규범 준수 서약서", diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx index 5783ca27..446112db 100644 --- a/lib/basic-contract/template/basic-contract-template-columns.tsx +++ b/lib/basic-contract/template/basic-contract-template-columns.tsx @@ -120,7 +120,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const handleViewDetails = () => { // templateName이 "General GTC"인 경우 특별한 라우팅 - if (template.templateName === "General GTC") { + if (template.templateName === "GTC") { router.push(`/evcp/basic-contract-template/gtc`); } else { // 일반적인 경우는 기존과 동일 @@ -141,8 +141,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef - - View Details + {/* */} + 상세보기 @@ -150,8 +150,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef setRowAction({ row, type: "createRevision" })} > - - 리비전 생성 + {/* */} + 리비전 생성하기 @@ -182,8 +182,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef setRowAction({ row, type: "delete" })} > - Delete - ⌘⌫ + 삭제하기 + {/* ⌘⌫ */} @@ -221,7 +221,12 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const template = row.original; const handleClick = () => { - router.push(`/evcp/basic-contract-template/${template.id}`); + if (template.templateName === "GTC") { + router.push(`/evcp/basic-contract-template/gtc`); + } else { + // 일반적인 경우는 기존과 동일 + router.push(`/evcp/basic-contract-template/${template.id}`); + } }; return ( diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx index 66037601..07bac31b 100644 --- a/lib/basic-contract/template/update-basicContract-sheet.tsx +++ b/lib/basic-contract/template/update-basicContract-sheet.tsx @@ -58,7 +58,7 @@ const TEMPLATE_NAME_OPTIONS = [ "기술자료 요구서", "비밀유지 계약서", "표준하도급기본 계약서", - "General GTC", + "GTC", "안전보건관리 약정서", "동반성장", "윤리규범 준수 서약서", diff --git a/lib/docu-list-rule/combo-box-settings/service.ts b/lib/docu-list-rule/combo-box-settings/service.ts index 70046828..2c5ee42b 100644 --- a/lib/docu-list-rule/combo-box-settings/service.ts +++ b/lib/docu-list-rule/combo-box-settings/service.ts @@ -229,39 +229,34 @@ export async function createComboBoxOption(input: { } } - const codeGroupDescription = codeGroup[0].description - - // 해당 Code Group의 마지막 옵션 번호 찾기 - const lastOption = await db - .select({ code: comboBoxSettings.code }) + // 코드 중복 체크 + const existingOption = await db + .select({ id: comboBoxSettings.id }) .from(comboBoxSettings) - .where(eq(comboBoxSettings.codeGroupId, input.codeGroupId)) - .orderBy(sql`CAST(SUBSTRING(${comboBoxSettings.code} FROM ${codeGroupDescription.length + 2}) AS INTEGER) DESC`) + .where( + sql`${comboBoxSettings.codeGroupId} = ${input.codeGroupId} AND ${comboBoxSettings.code} = ${input.code}` + ) .limit(1) - let nextNumber = 1 - if (lastOption.length > 0 && lastOption[0].code) { - const prefix = `${codeGroupDescription}_` - if (lastOption[0].code.startsWith(prefix)) { - const lastNumber = parseInt(lastOption[0].code.replace(prefix, '')) - if (!isNaN(lastNumber)) { - nextNumber = lastNumber + 1 - } + if (existingOption.length > 0) { + return { + success: false, + error: "이미 존재하는 코드입니다." } } - const newCode = `${codeGroupDescription}_${nextNumber}` - const [newOption] = await db .insert(comboBoxSettings) .values({ codeGroupId: input.codeGroupId, - code: newCode, - description: input.description, + code: input.code, + description: input.description || "-", remark: input.remark, }) .returning({ id: comboBoxSettings.id }) + + revalidatePath("/evcp/docu-list-rule/combo-box-settings") return { diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx index 1fb8950c..a5a8af2f 100644 --- a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx +++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx @@ -30,7 +30,8 @@ import { Input } from "@/components/ui/input" import { createComboBoxOption } from "../service" const createOptionSchema = z.object({ - description: z.string().min(1, "값은 필수입니다."), + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().default("-"), remark: z.string().optional(), }) @@ -48,7 +49,8 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt const form = useForm({ resolver: zodResolver(createOptionSchema), defaultValues: { - description: "", + code: "", + description: "-", remark: "", }, }) @@ -58,8 +60,8 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt try { const result = await createComboBoxOption({ codeGroupId, - code: "", // 서비스에서 자동 생성 - description: data.description, + code: data.code, + description: data.description || "-", remark: data.remark, }) @@ -100,6 +102,19 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt
+ ( + + 코드 + + + + + + )} + /> any) + group?: string +} + +/** + * `exportTableToExcel`: 기존 테이블 기반 내보내기 (페이지네이션된 데이터) * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) * - onlySelected: 선택된 행만 내보낼지 여부 * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"]) @@ -89,12 +99,100 @@ export async function exportTableToExcel( sheetData = [headerRow, ...dataRows] } + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename, useGroupHeader) +} + +/** + * `exportFullDataToExcel`: 전체 데이터를 Excel로 내보내기 + * - data: 전체 데이터 배열 + * - columns: 컬럼 정의 배열 + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + */ +export async function exportFullDataToExcel( + data: TData[], + columns: ExcelColumnDef[], + { + filename = "export", + useGroupHeader = true, + }: { + filename?: string + useGroupHeader?: boolean + } = {} +): Promise { + let sheetData: any[][] + + if (useGroupHeader) { + // ────────────── 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) ────────────── + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + // group + row1.push(col.group ?? "") + // header + row2.push(col.header) + }) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => col.header) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename, useGroupHeader) +} + +/** + * 공통 Excel 파일 생성 및 다운로드 함수 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string, + useGroupHeader: boolean +): Promise { // ────────────── ExcelJS 워크북/시트 생성 ────────────── const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet("Sheet1") - // (추가) 칼럼별 최대 길이 추적 - const maxColumnLengths = columns.map(() => 0) + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) sheetData.forEach((row) => { row.forEach((cellValue, colIdx) => { const cellText = cellValue?.toString() ?? "" @@ -141,7 +239,6 @@ export async function exportTableToExcel( // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── if (useGroupHeader) { // row1 (인덱스 1) = 그룹명 행 - // row2 (인덱스 2) = 실제 컬럼 헤더 행 const groupRowIndex = 1 const groupRow = worksheet.getRow(groupRowIndex) @@ -149,7 +246,7 @@ export async function exportTableToExcel( let start = 1 // 시작 열 인덱스 (1-based) let prevValue = groupRow.getCell(start).value - for (let c = 2; c <= columns.length; c++) { + for (let c = 2; c <= columnCount; c++) { const cellVal = groupRow.getCell(c).value if (cellVal !== prevValue) { // 이전 그룹명이 빈 문자열이 아니면 병합 @@ -173,12 +270,12 @@ export async function exportTableToExcel( groupRowIndex, start, groupRowIndex, - columns.length + columnCount ) } } - // ────────────── (추가) 칼럼 너비 자동 조정 ────────────── + // ────────────── 칼럼 너비 자동 조정 ────────────── maxColumnLengths.forEach((len, idx) => { // 최소 너비 10, +2 여백 worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) diff --git a/lib/exportFullData.ts b/lib/exportFullData.ts new file mode 100644 index 00000000..fde5aac2 --- /dev/null +++ b/lib/exportFullData.ts @@ -0,0 +1,189 @@ +import ExcelJS from "exceljs" + +/** + * 컬럼 정의 인터페이스 + */ +export interface ExcelColumnDef { + id: string + header: string + accessor: string | ((row: any) => any) + group?: string +} + +/** + * `exportFullDataToExcel`: 전체 데이터를 Excel로 내보내기 + * - data: 전체 데이터 배열 + * - columns: 컬럼 정의 배열 + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + */ +export async function exportFullDataToExcel( + data: TData[], + columns: ExcelColumnDef[], + { + filename = "export", + useGroupHeader = true, + }: { + filename?: string + useGroupHeader?: boolean + } = {} +): Promise { + let sheetData: any[][] + + if (useGroupHeader) { + // ────────────── 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) ────────────── + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + // group + row1.push(col.group ?? "") + // header + row2.push(col.header) + }) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => col.header) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ────────────── ExcelJS 워크북/시트 생성 ────────────── + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = columns.map(() => 0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + // 2줄 헤더 + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } else { + // 1줄 헤더 + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } + }) + + // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── + if (useGroupHeader) { + // row1 (인덱스 1) = 그룹명 행 + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + // 같은 값이 연속되는 열을 병합 + let start = 1 // 시작 열 인덱스 (1-based) + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + // 이전 그룹명이 빈 문자열이 아니면 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + c - 1 + ) + } + // 다음 구간 시작 + start = c + prevValue = cellVal + } + } + + // 마지막 구간까지 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + columns.length + ) + } + } + + // ────────────── 칼럼 너비 자동 조정 ────────────── + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // ────────────── 최종 파일 다운로드 ────────────── + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} \ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx index 30e369b4..f979f0ea 100644 --- a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx +++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx @@ -18,6 +18,8 @@ interface ClausePreviewViewerProps { document: any instance: WebViewerInstance | null setInstance: Dispatch> + onSuccess?: () => void + onError?: () => void } export function ClausePreviewViewer({ @@ -25,140 +27,263 @@ export function ClausePreviewViewer({ document, instance, setInstance, + onSuccess, + onError, }: ClausePreviewViewerProps) { const [fileLoading, setFileLoading] = useState(true) + const [loadingStage, setLoadingStage] = useState("뷰어 준비 중...") const viewer = useRef(null) const initialized = useRef(false) const isCancelled = useRef(false) - // WebViewer 초기화 + // WebViewer 초기화 (단계별) useEffect(() => { if (!initialized.current && viewer.current) { initialized.current = true isCancelled.current = false - requestAnimationFrame(() => { - if (viewer.current) { - import("@pdftron/webviewer").then(({ default: WebViewer }) => { - if (isCancelled.current) { - console.log("📛 WebViewer 초기화 취소됨") - return - } - - const viewerElement = viewer.current - if (!viewerElement) return - - WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - enableOfficeEditing: true, - l: "ko", - // 미리보기 모드로 설정 - enableReadOnlyMode: false, - }, - viewerElement - ).then(async (instance: WebViewerInstance) => { - setInstance(instance) - - try { - const { disableElements, enableElements, setToolbarGroup } = instance.UI - - // 미리보기에 필요한 도구만 활성화 - enableElements([ - "toolbarGroup-View", - "zoomInButton", - "zoomOutButton", - "fitButton", - "rotateCounterClockwiseButton", - "rotateClockwiseButton", - ]) - - // 편집 도구는 비활성화 - disableElements([ - "toolbarGroup-Edit", - "toolbarGroup-Insert", - "toolbarGroup-Annotate", - "toolbarGroup-Shapes", - "toolbarGroup-Forms", - ]) - - setToolbarGroup("toolbarGroup-View") - - // 조항 데이터로 문서 생성 - await generateDocumentFromClauses(instance, clauses, document) - - } catch (uiError) { - console.warn("⚠️ UI 설정 중 오류:", uiError) - } finally { - setFileLoading(false) - } - }).catch((error) => { - console.error("❌ WebViewer 초기화 실패:", error) - setFileLoading(false) - toast.error("뷰어 초기화에 실패했습니다.") - }) - }) - } - }) + initializeViewerStepByStep() } return () => { if (instance) { - instance.UI.dispose() + try { + instance.UI.dispose() + } catch (error) { + console.warn("뷰어 정리 중 오류:", error) + } } - isCancelled.current = true + isCancelled.current = true; + setTimeout(() => cleanupHtmlStyle(), 500); } }, []) - // 조항 데이터로 워드 문서 생성 + const initializeViewerStepByStep = async () => { + try { + setLoadingStage("라이브러리 로딩 중...") + + // 1단계: 라이브러리 동적 import (지연 추가) + await new Promise(resolve => setTimeout(resolve, 300)) + const { default: WebViewer } = await import("@pdftron/webviewer") + + if (isCancelled.current || !viewer.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setLoadingStage("뷰어 초기화 중...") + + // 2단계: WebViewer 인스턴스 생성 + const webviewerInstance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + l: "ko", + enableReadOnlyMode: false, + }, + viewer.current + ) + + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setInstance(webviewerInstance) + setLoadingStage("UI 설정 중...") + + // 3단계: UI 설정 (약간의 지연 후) + await new Promise(resolve => setTimeout(resolve, 500)) + await configureViewerUI(webviewerInstance) + + setLoadingStage("문서 생성 중...") + + // 4단계: 문서 생성 (충분한 지연 후) + await new Promise(resolve => setTimeout(resolve, 800)) + await generateDocumentFromClauses(webviewerInstance, clauses, document) + + } catch (error) { + console.error("❌ WebViewer 단계별 초기화 실패:", error) + setFileLoading(false) + onError?.() // 초기화 실패 콜백 호출 + toast.error(`뷰어 초기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + + const configureViewerUI = async (webviewerInstance: WebViewerInstance) => { + try { + const { disableElements, enableElements, setToolbarGroup } = webviewerInstance.UI + + // 미리보기에 필요한 도구만 활성화 + enableElements([ + "toolbarGroup-View", + "zoomInButton", + "zoomOutButton", + "fitButton", + "rotateCounterClockwiseButton", + "rotateClockwiseButton", + ]) + + // 편집 도구는 비활성화 + disableElements([ + "toolbarGroup-Edit", + "toolbarGroup-Insert", + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Forms", + ]) + + setToolbarGroup("toolbarGroup-View") + + console.log("✅ UI 설정 완료") + } catch (uiError) { + console.warn("⚠️ UI 설정 중 오류:", uiError) + // UI 설정 실패해도 계속 진행 + } + } + + // 문서 생성 함수 (재시도 로직 포함) const generateDocumentFromClauses = async ( - instance: WebViewerInstance, - clauses: GtcClauseTreeView[], - document: any + webviewerInstance: WebViewerInstance, + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 ) => { + const MAX_RETRIES = 3 + try { console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length) - + // 활성화된 조항만 필터링하고 정렬 const activeClauses = clauses .filter(clause => clause.isActive !== false) .sort((a, b) => { - // sortOrder 또는 itemNumber로 정렬 if (a.sortOrder && b.sortOrder) { return parseFloat(a.sortOrder) - parseFloat(b.sortOrder) } return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true }) }) - // ✅ DOCX 문서 생성 - const docxBlob = await generateDocxDocument(activeClauses, document) - - // ✅ DOCX 파일로 변환 - const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { - type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + if (activeClauses.length === 0) { + throw new Error("활성화된 조항이 없습니다.") + } + + setLoadingStage(`문서 생성 중... (${activeClauses.length}개 조항 처리)`) + + // DOCX 문서 생성 (재시도 로직 포함) + const docxBlob = await generateDocxDocumentWithRetry(activeClauses, document) + + // 파일 생성 + const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }) + + setLoadingStage("문서 로딩 중...") + + // WebViewer가 완전히 준비된 상태인지 확인 + await waitForViewerReady(webviewerInstance) + + // DOCX 문서 로드 (재시도 포함) + await loadDocumentWithRetry(webviewerInstance, docxFile) + + console.log("✅ DOCX 기반 문서 생성 완료") + toast.success("Word 문서 미리보기가 생성되었습니다.") + setFileLoading(false) + onSuccess?.() // 성공 콜백 호출 + + } catch (err) { + console.error(`❌ DOCX 문서 생성 중 오류 (시도 ${retryCount + 1}/${MAX_RETRIES + 1}):`, err) - // ✅ PDFTron에서 DOCX 문서로 로드 - await instance.UI.loadDocument(docxFile, { - filename: `${document?.title || 'GTC계약서'}_미리보기.docx`, - enableOfficeEditing: true, // DOCX 편집 모드 활성화 + if (retryCount < MAX_RETRIES) { + console.log(`🔄 ${(retryCount + 1) * 1000}ms 후 재시도...`) + setLoadingStage(`재시도 중... (${retryCount + 1}/${MAX_RETRIES})`) + + await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000)) + + if (!isCancelled.current) { + return generateDocumentFromClauses(webviewerInstance, clauses, document, retryCount + 1) + } + } else { + setFileLoading(false) + onError?.() // 실패 콜백 호출 + toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + } + } + } + + // WebViewer 준비 상태 확인 + const waitForViewerReady = async (webviewerInstance: WebViewerInstance, timeout = 5000) => { + const startTime = Date.now() + + while (Date.now() - startTime < timeout) { + try { + // UI가 준비되었는지 확인 + if (webviewerInstance.UI && webviewerInstance.Core) { + console.log("✅ WebViewer 준비 완료") + return + } + } catch (error) { + // 아직 준비되지 않음 + } + + await new Promise(resolve => setTimeout(resolve, 100)) + } + + throw new Error("WebViewer 준비 시간 초과") + } + + // 문서 로드 재시도 함수 + const loadDocumentWithRetry = async ( + webviewerInstance: WebViewerInstance, + file: File, + retryCount = 0 + ) => { + const MAX_LOAD_RETRIES = 2 + + try { + await webviewerInstance.UI.loadDocument(file, { + filename: file.name, + enableOfficeEditing: true, }) + console.log("✅ 문서 로드 성공") + } catch (error) { + console.error(`문서 로드 실패 (시도 ${retryCount + 1}):`, error) - console.log("✅ DOCX 기반 문서 생성 완료") - toast.success("Word 문서 미리보기가 생성되었습니다.") + if (retryCount < MAX_LOAD_RETRIES) { + await new Promise(resolve => setTimeout(resolve, 1000)) + return loadDocumentWithRetry(webviewerInstance, file, retryCount + 1) + } else { + throw new Error(`문서 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + } + + // DOCX 생성 재시도 함수 + const generateDocxDocumentWithRetry = async ( + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 + ): Promise => { + try { + return await generateDocxDocument(clauses, document) + } catch (error) { + console.error(`DOCX 생성 실패 (시도 ${retryCount + 1}):`, error) - } catch (err) { - console.error("❌ DOCX 문서 생성 중 오류:", err) - toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + if (retryCount < 2) { + await new Promise(resolve => setTimeout(resolve, 500)) + return generateDocxDocumentWithRetry(clauses, document, retryCount + 1) + } else { + throw error + } } } return (
-
-

문서 생성 중...

+

{loadingStage}

{clauses.filter(c => c.isActive !== false).length}개 조항 처리 중

+
+ 초기화에 시간이 걸릴 수 있습니다... +
)}
@@ -180,82 +308,81 @@ export function ClausePreviewViewer({ ) } +// ===== 유틸리티 함수들 ===== - -// ===== data URL 판별 및 디코딩 유틸 ===== +// data URL 판별 및 디코딩 유틸 function isDataUrl(url: string) { - return /^data:/.test(url); - } - - function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { - // 형식: data:;base64, - const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/); - if (!match) { - // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원 - throw new Error("지원하지 않는 data URL 형식입니다."); - } - const mime = match[1]; - const base64 = match[2]; - const binary = atob(base64); - const len = binary.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); - return { bytes, mime }; + return /^data:/.test(url); +} + +function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { + // 형식: data:;base64, + const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/); + if (!match) { + // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원 + throw new Error("지원하지 않는 data URL 형식입니다."); } - - // ===== helper: 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) ===== - async function fetchImageData(url: string, maxWidthPx = 500) { - let blob: Blob; - let bytes: Uint8Array; - - if (isDataUrl(url)) { - // data URL → Uint8Array, Blob - const { bytes: arr, mime } = dataUrlToUint8Array(url); - bytes = arr; - blob = new Blob([bytes], { type: mime }); - } else { - // http(s) 또는 상대 경로 - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`); - blob = await res.blob(); - const arrayBuffer = await blob.arrayBuffer(); - bytes = new Uint8Array(arrayBuffer); - } - - // 원본 크기 파악 (공통) - const dims = await new Promise<{ width: number; height: number }>((resolve) => { - const img = new Image(); - const objectUrl = URL.createObjectURL(blob); - img.onload = () => { - const width = img.naturalWidth || 800; - const height = img.naturalHeight || 600; - URL.revokeObjectURL(objectUrl); - resolve({ width, height }); - }; - img.onerror = () => { - URL.revokeObjectURL(objectUrl); - resolve({ width: 800, height: 600 }); // 실패 시 기본값 - }; - img.src = objectUrl; - }); - - // 비율 유지 축소 - const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx)); - const width = Math.round((dims.width || maxWidthPx) * scale); - const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale); - - return { data: bytes, width, height }; + const mime = match[1]; + const base64 = match[2]; + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + return { bytes, mime }; +} + +// 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) +async function fetchImageData(url: string, maxWidthPx = 500) { + let blob: Blob; + let bytes: Uint8Array; + + if (isDataUrl(url)) { + // data URL → Uint8Array, Blob + const { bytes: arr, mime } = dataUrlToUint8Array(url); + bytes = arr; + blob = new Blob([bytes], { type: mime }); + } else { + // http(s) 또는 상대 경로 + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`); + blob = await res.blob(); + const arrayBuffer = await blob.arrayBuffer(); + bytes = new Uint8Array(arrayBuffer); } + // 원본 크기 파악 (공통) + const dims = await new Promise<{ width: number; height: number }>((resolve) => { + const img = new Image(); + const objectUrl = URL.createObjectURL(blob); + img.onload = () => { + const width = img.naturalWidth || 800; + const height = img.naturalHeight || 600; + URL.revokeObjectURL(objectUrl); + resolve({ width, height }); + }; + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + resolve({ width: 800, height: 600 }); // 실패 시 기본값 + }; + img.src = objectUrl; + }); + + // 비율 유지 축소 + const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx)); + const width = Math.round((dims.width || maxWidthPx) * scale); + const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale); + + return { data: bytes, width, height }; +} + // DOCX 문서 생성 (docx 라이브러리 사용) async function generateDocxDocument( - clauses: GtcClauseTreeView[], - document: any - ): Promise { - const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx"); - - -function textToParagraphs(text: string, indentLeft: number) { + clauses: GtcClauseTreeView[], + document: any +): Promise { + const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx"); + + function textToParagraphs(text: string, indentLeft: number) { const lines = text.split("\n"); return [ new Paragraph({ @@ -269,11 +396,10 @@ function textToParagraphs(text: string, indentLeft: number) { }), ]; } - - const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] + const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] -async function pushContentWithInlineImages( + async function pushContentWithInlineImages( content: string, indentLeft: number, children: any[], @@ -284,135 +410,135 @@ async function pushContentWithInlineImages( const start = match.index ?? 0; const end = start + match[0].length; const imageId = match[1]; - + // 앞부분 텍스트 if (start > lastIndex) { const txt = content.slice(lastIndex, start); children.push(...textToParagraphs(txt, indentLeft)); } - + // 이미지 삽입 const imgMeta = imageMap.get(imageId); if (imgMeta?.url) { - const { data, width, height } = await fetchImageData(imgMeta.url, 520); - children.push( - new Paragraph({ - children: [ - new ImageRun({ - data, - transformation: { width, height }, - }), - ], - indent: { left: indentLeft }, - }) - ); - // 사용된 이미지 표시(뒤에서 중복 추가 방지) - imageMap.delete(imageId); - } - // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) - - lastIndex = end; - } - - // 남은 꼬리 텍스트 - if (lastIndex < content.length) { - const tail = content.slice(lastIndex); - children.push(...textToParagraphs(tail, indentLeft)); - } - } - - - const documentTitle = document?.title || "GTC 계약서"; - const currentDate = new Date().toLocaleDateString("ko-KR"); - - // depth 추정/정렬 - const structuredClauses = organizeClausesByHierarchy(clauses); - - const children: any[] = [ - new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: documentTitle, bold: true, size: 32 })], - }), - new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })], - }), - new Paragraph({ text: "" }), - new Paragraph({ text: "" }), - ]; - - for (const clause of structuredClauses) { - const depth = Math.min(clause.estimatedDepth || 0, 3); - const indentLeft = depth * 400; // 번호/제목 - const indentContent = indentLeft + 200; // 본문/이미지 - - // 번호 + 제목 - children.push( - new Paragraph({ - children: [ - new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }), - ...(clause.subtitle - ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })] - : []), - ], - indent: { left: indentLeft }, - }) - ); - - const imageMap = new Map( - Array.isArray((clause as any).images) - ? (clause as any).images.map((im: any) => [String(im.id), im]) - : [] - ); - - // 내용 - const hasContent = clause.content && clause.content.trim(); - if (hasContent) { - await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap); - } - - // else { - // children.push( - // new Paragraph({ - // // children: [new TextRun({ text: "(상세 내용 없음)", italics: true, color: "6b7280", size: 20 })], - // indent: { left: indentContent }, - // }) - // ); - // } - - // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) - - for (const [, imgMeta] of imageMap) { try { const { data, width, height } = await fetchImageData(imgMeta.url, 520); children.push( new Paragraph({ - children: [new ImageRun({ data, transformation: { width, height } })], - indent: { left: indentContent }, + children: [ + new ImageRun({ + data, + transformation: { width, height }, + }), + ], + indent: { left: indentLeft }, }) ); - } catch (e) { + // 사용된 이미지 표시(뒤에서 중복 추가 방지) + imageMap.delete(imageId); + } catch (imgError) { + console.warn("이미지 로드 실패:", imgMeta, imgError); + // 이미지 로드 실패시 텍스트로 대체 children.push( new Paragraph({ - children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], - indent: { left: indentContent }, + children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })], + indent: { left: indentLeft }, }) ); - console.warn("이미지 로드 실패(잔여):", imgMeta, e); } } - - // 조항 간 간격 - children.push(new Paragraph({ text: "" })); + // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) + + lastIndex = end; + } + + // 남은 꼬리 텍스트 + if (lastIndex < content.length) { + const tail = content.slice(lastIndex); + children.push(...textToParagraphs(tail, indentLeft)); + } + } + + const documentTitle = document?.title || "GTC 계약서"; + const currentDate = new Date().toLocaleDateString("ko-KR"); + + // depth 추정/정렬 + const structuredClauses = organizeClausesByHierarchy(clauses); + + const children: any[] = [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: documentTitle, bold: true, size: 32 })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })], + }), + new Paragraph({ text: "" }), + new Paragraph({ text: "" }), + ]; + + for (const clause of structuredClauses) { + const depth = Math.min(clause.estimatedDepth || 0, 3); + const indentLeft = depth * 400; // 번호/제목 + const indentContent = indentLeft + 200; // 본문/이미지 + + // 번호 + 제목 + children.push( + new Paragraph({ + children: [ + new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }), + ...(clause.subtitle + ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })] + : []), + ], + indent: { left: indentLeft }, + }) + ); + + const imageMap = new Map( + Array.isArray((clause as any).images) + ? (clause as any).images.map((im: any) => [String(im.id), im]) + : [] + ); + + // 내용 + const hasContent = clause.content && clause.content.trim(); + if (hasContent) { + await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap); } - - const doc = new Document({ - sections: [{ properties: {}, children }], - }); - - return await Packer.toBlob(doc); + + // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) + for (const [, imgMeta] of imageMap) { + try { + const { data, width, height } = await fetchImageData(imgMeta.url, 520); + children.push( + new Paragraph({ + children: [new ImageRun({ data, transformation: { width, height } })], + indent: { left: indentContent }, + }) + ); + } catch (e) { + children.push( + new Paragraph({ + children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], + indent: { left: indentContent }, + }) + ); + console.warn("이미지 로드 실패(잔여):", imgMeta, e); + } + } + + // 조항 간 간격 + children.push(new Paragraph({ text: "" })); } + const doc = new Document({ + sections: [{ properties: {}, children }], + }); + + return await Packer.toBlob(doc); +} + // 조항들을 계층구조로 정리 function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { // depth가 없는 경우 itemNumber로 depth 추정 @@ -421,9 +547,9 @@ function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber) })).sort((a, b) => { // itemNumber 기준 자연 정렬 - return a.itemNumber.localeCompare(b.itemNumber, undefined, { - numeric: true, - sensitivity: 'base' + return a.itemNumber.localeCompare(b.itemNumber, undefined, { + numeric: true, + sensitivity: 'base' }) }) } @@ -433,3 +559,12 @@ function estimateDepthFromItemNumber(itemNumber: string): number { const parts = itemNumber.split('.') return Math.max(0, parts.length - 1) } + +// WebViewer 정리 함수 +const cleanupHtmlStyle = () => { + // iframe 스타일 정리 (WebViewer가 추가한 스타일) + const elements = document.querySelectorAll('.Document_container'); + elements.forEach((elem) => { + elem.remove(); + }); +}; \ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/excel-import.tsx b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx new file mode 100644 index 00000000..d8f435f7 --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx @@ -0,0 +1,340 @@ +import { ExcelColumnDef } from "@/lib/export" +import ExcelJS from "exceljs" + +/** + * Excel 템플릿 다운로드 함수 + */ +export async function downloadExcelTemplate( + columns: ExcelColumnDef[], + { + filename = "template", + includeExampleData = true, + useGroupHeader = true, + }: { + filename?: string + includeExampleData?: boolean + useGroupHeader?: boolean + } = {} +): Promise { + let sheetData: any[][] + + if (useGroupHeader) { + // 2줄 헤더 생성 + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + row1.push(col.group ?? "") + row2.push(col.header) + }) + + sheetData = [row1, row2] + + // 예시 데이터 추가 + if (includeExampleData) { + // 빈 행 3개 추가 (사용자가 데이터 입력할 공간) + for (let i = 0; i < 3; i++) { + const exampleRow = columns.map((col) => { + // 컬럼 타입에 따른 예시 데이터 + if (col.id === "itemNumber") return i === 0 ? `1.${i + 1}` : i === 1 ? "2.1" : "" + if (col.id === "subtitle") return i === 0 ? "예시 조항 소제목" : i === 1 ? "하위 조항 예시" : "" + if (col.id === "content") return i === 0 ? "조항의 상세 내용을 입력합니다." : i === 1 ? "하위 조항의 내용" : "" + if (col.id === "category") return i === 0 ? "일반조항" : i === 1 ? "특별조항" : "" + if (col.id === "sortOrder") return i === 0 ? "10" : i === 1 ? "20" : "" + if (col.id === "parentId") return i === 1 ? "1" : "" + if (col.id === "isActive") return i === 0 ? "활성" : i === 1 ? "활성" : "" + if (col.id === "editReason") return i === 0 ? "신규 작성" : "" + return "" + }) + sheetData.push(exampleRow) + } + } + } else { + // 1줄 헤더 + const headerRow = columns.map((col) => col.header) + sheetData = [headerRow] + + if (includeExampleData) { + // 예시 데이터 행 추가 + const exampleRow = columns.map(() => "") + sheetData.push(exampleRow) + } + } + + // ExcelJS로 워크북 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("GTC조항템플릿") + + // 데이터 추가 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, // 연한 파란색 + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + }) + } + } else { + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, + } + }) + } + } + + // 예시 데이터 행 스타일 + if (includeExampleData && idx === (useGroupHeader ? 2 : 1)) { + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFEAA7" }, // 연한 노란색 + } + cell.font = { italic: true, color: { argb: "FF666666" } } + }) + } + }) + + // 그룹 헤더 병합 + if (useGroupHeader) { + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + let start = 1 + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1) + } + start = c + prevValue = cellVal + } + } + + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length) + } + } + + // 컬럼 너비 자동 조정 + columns.forEach((col, idx) => { + let width = Math.max(col.header.length + 5, 15) + + // 특정 컬럼은 더 넓게 + if (col.id === "content" || col.id === "subtitle") { + width = 30 + } else if (col.id === "itemNumber") { + width = 15 + } else if (col.id === "editReason") { + width = 20 + } + + worksheet.getColumn(idx + 1).width = width + }) + + // 사용 안내 시트 추가 + const instructionSheet = workbook.addWorksheet("사용안내") + const instructions = [ + ["GTC 조항 Excel 가져오기 사용 안내"], + [""], + ["1. 기본 규칙"], + [" - 첫 번째 시트(GTC조항템플릿)에 데이터를 입력하세요"], + [" - 헤더 행은 수정하지 마세요"], + [" - 예시 데이터(노란색 행)는 삭제하고 실제 데이터를 입력하세요"], + [""], + ["2. 필수 입력 항목"], + [" - 채번: 필수 입력 (예: 1.1, 2.3.1)"], + [" - 소제목: 필수 입력"], + [""], + ["3. 선택 입력 항목"], + [" - 상세항목: 조항의 구체적인 내용"], + [" - 분류: 조항의 카테고리 (예: 일반조항, 특별조항)"], + [" - 순서: 숫자 (기본값: 10, 20, 30...)"], + [" - 상위 조항 ID: 계층 구조를 만들 때 사용"], + [" - 활성 상태: '활성' 또는 '비활성' (기본값: 활성)"], + [" - 편집 사유: 작성/수정 이유"], + [""], + ["4. 자동 처리 항목"], + [" - ID, 생성일, 수정일: 시스템에서 자동 생성"], + [" - 계층 깊이: 상위 조항 ID를 기반으로 자동 계산"], + [" - 전체 경로: 시스템에서 자동 생성"], + [""], + ["5. 채번 규칙"], + [" - 같은 부모 하에서 채번은 유일해야 합니다"], + [" - 예: 상위 조항이 같으면 1.1, 1.2는 가능하지만 1.1이 중복되면 오류"], + [""], + ["6. 계층 구조 만들기"], + [" - 상위 조항 ID: 기존 조항의 ID를 입력"], + [" - 예: ID가 5인 조항 하위에 조항을 만들려면 상위 조항 ID에 5 입력"], + [" - 최상위 조항은 상위 조항 ID를 비워두세요"], + [""], + ["7. 주의사항"], + [" - 순서는 숫자로 입력하세요 (소수점 가능: 10, 15.5, 20)"], + [" - 상위 조항 ID는 반드시 존재하는 조항의 ID여야 합니다"], + [" - 파일 저장 시 .xlsx 형식으로 저장하세요"], + ] + + instructions.forEach((instruction, idx) => { + const row = instructionSheet.addRow(instruction) + if (idx === 0) { + row.font = { bold: true, size: 14 } + row.alignment = { horizontal: "center" } + } else if (instruction[0]?.match(/^\d+\./)) { + row.font = { bold: true } + } + }) + + instructionSheet.getColumn(1).width = 80 + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + +/** + * Excel 파일에서 데이터 파싱 + */ +export async function parseExcelFile( + file: File, + columns: ExcelColumnDef[], + { + hasGroupHeader = true, + sheetName = "GTC조항템플릿", + }: { + hasGroupHeader?: boolean + sheetName?: string + } = {} +): Promise<{ + data: Partial[] + errors: string[] +}> { + const errors: string[] = [] + const data: Partial[] = [] + + try { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.getWorksheet(sheetName) || workbook.worksheets[0] + + if (!worksheet) { + errors.push("워크시트를 찾을 수 없습니다.") + return { data, errors } + } + + // 헤더 행 인덱스 결정 + const headerRowIndex = hasGroupHeader ? 2 : 1 + const dataStartRowIndex = headerRowIndex + 1 + + // 헤더 검증 + const headerRow = worksheet.getRow(headerRowIndex) + const expectedHeaders = columns.map(col => col.header) + + for (let i = 0; i < expectedHeaders.length; i++) { + const cellValue = headerRow.getCell(i + 1).value?.toString() || "" + if (cellValue !== expectedHeaders[i]) { + errors.push(`헤더가 일치하지 않습니다. 예상: "${expectedHeaders[i]}", 실제: "${cellValue}"`) + } + } + + if (errors.length > 0) { + return { data, errors } + } + + // 데이터 파싱 + let rowIndex = dataStartRowIndex + while (rowIndex <= worksheet.actualRowCount) { + const row = worksheet.getRow(rowIndex) + + // 빈 행 체크 (모든 셀이 비어있으면 스킵) + const isEmpty = columns.every((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + return !cellValue || cellValue.toString().trim() === "" + }) + + if (isEmpty) { + rowIndex++ + continue + } + + const rowData: Partial = {} + let hasError = false + + columns.forEach((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + let processedValue: any = cellValue + + // 데이터 타입별 처리 + if (cellValue !== null && cellValue !== undefined) { + const strValue = cellValue.toString().trim() + + // 특별한 처리가 필요한 컬럼들 + if (col.id === "isActive") { + processedValue = strValue === "활성" + } else if (col.id === "sortOrder") { + const numValue = parseFloat(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else if (col.id === "parentId") { + const numValue = parseInt(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else { + processedValue = strValue + } + } + + // 필수 필드 검증 + if ((col.id === "itemNumber" || col.id === "subtitle") && (!processedValue || processedValue === "")) { + errors.push(`${rowIndex}행: ${col.header}은(는) 필수 입력 항목입니다.`) + hasError = true + } + + if (processedValue !== null && processedValue !== undefined && processedValue !== "") { + (rowData as any)[col.id] = processedValue + } + }) + + if (!hasError) { + data.push(rowData) + } + + rowIndex++ + } + + } catch (error) { + errors.push(`파일 파싱 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`) + } + + return { data, errors } +} \ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx index 2a7452ef..ea516f49 100644 --- a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx @@ -32,42 +32,170 @@ import { type GtcClauseTreeView } from "@/db/schema/gtc" import { CreateGtcClauseDialog } from "./create-gtc-clause-dialog" import { PreviewDocumentDialog } from "./preview-document-dialog" import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog" +import { exportTableToExcel } from "@/lib/export" +import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export" +import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "../../service" +import { ImportExcelDialog } from "./import-excel-dialog" +import { toast } from "@/hooks/use-toast" interface GtcClausesTableToolbarActionsProps { table: Table documentId: number document: any + currentUserId?: number // 현재 사용자 ID 추가 } +// GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화) +const gtcClauseExcelColumns: ExcelColumnDef[] = [ + { + id: "itemNumber", + header: "채번", + accessor: "itemNumber", + group: "필수 정보" + }, + { + id: "subtitle", + header: "소제목", + accessor: "subtitle", + group: "필수 정보" + }, + { + id: "content", + header: "상세항목", + accessor: "content", + group: "기본 정보" + }, + { + id: "category", + header: "분류", + accessor: "category", + group: "기본 정보" + }, + { + id: "sortOrder", + header: "순서", + accessor: "sortOrder", + group: "순서" + }, + { + id: "parentId", + header: "상위 조항 ID", + accessor: "parentId", + group: "계층 구조" + }, + { + id: "isActive", + header: "활성 상태", + accessor: (row) => row.isActive ? "활성" : "비활성", + group: "상태" + }, + { + id: "editReason", + header: "편집 사유", + accessor: "editReason", + group: "추가 정보" + } +] + export function GtcClausesTableToolbarActions({ table, documentId, document, + currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) const [showBulkUpdateDialog, setShowBulkUpdateDialog] = React.useState(false) const [showGenerateVariablesDialog, setShowGenerateVariablesDialog] = React.useState(false) - const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) // ✅ 미리보기 다이얼로그 상태 + const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) const selectedRows = table.getSelectedRowModel().rows const selectedCount = selectedRows.length - // ✅ 테이블의 모든 데이터 가져오기 + // 테이블의 모든 데이터 가져오기 (현재 페이지만) const allClauses = table.getRowModel().rows.map(row => row.original) - const handleExportToExcel = () => { - // Excel 내보내기 로직 - console.log("Export to Excel") + // 현재 페이지 데이터만 Excel로 내보내기 + const handleExportCurrentPageToExcel = () => { + exportTableToExcel(table, { + filename: `gtc-clauses-page-${new Date().toISOString().split('T')[0]}`, + excludeColumns: ["select", "actions"], + }) + } + + // 전체 데이터를 Excel로 내보내기 + const handleExportAllToExcel = async () => { + try { + setIsExporting(true) + + // 서버에서 전체 데이터 가져오기 + const allData = await getAllGtcClausesForExport(documentId) + + // 전체 데이터를 Excel로 내보내기 + await exportFullDataToExcel( + allData, + gtcClauseExcelColumns, + { + filename: `gtc-clauses-all-${new Date().toISOString().split('T')[0]}`, + useGroupHeader: true + } + ) + + toast({ + title: "내보내기 완료", + description: `총 ${allData.length}개의 조항이 Excel 파일로 내보내졌습니다.`, + }) + } catch (error) { + console.error("Excel export failed:", error) + toast({ + title: "내보내기 실패", + description: "Excel 파일 내보내기 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsExporting(false) + } } - const handleImportFromExcel = () => { - // Excel 가져오기 로직 - console.log("Import from Excel") + // Excel 데이터 가져오기 처리 + const handleImportExcelData = async (data: Partial[]) => { + try { + const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + + if (result.success) { + toast({ + title: "가져오기 성공", + description: `${result.importedCount}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 테이블 새로고침 + handleRefreshTable() + } else { + const errorMessage = result.errors.length > 0 + ? `오류: ${result.errors.slice(0, 3).join(', ')}${result.errors.length > 3 ? '...' : ''}` + : "알 수 없는 오류가 발생했습니다." + + toast({ + title: "가져오기 실패", + description: errorMessage, + variant: "destructive" + }) + + // 오류가 있어도 일부는 성공했을 수 있음 + if (result.importedCount > 0) { + handleRefreshTable() + } + + throw new Error("Import failed with errors") + } + } catch (error) { + console.error("Excel import failed:", error) + throw error // ImportExcelDialog에서 처리하도록 다시 throw + } } const handlePreviewDocument = () => { - // ✅ 미리보기 다이얼로그 열기 setShowPreviewDialog(true) } @@ -108,9 +236,8 @@ export function GtcClausesTableToolbarActions({ {selectedCount > 0 && ( <> table.toggleAllRowsSelected(false)} - + gtcClauses={allClauses} + onSuccess={() => table.toggleAllRowsSelected(false)} /> )} @@ -118,33 +245,39 @@ export function GtcClausesTableToolbarActions({ {/* 관리 도구 드롭다운 */} - - - {/* - - 조항 순서 변경 + + + + 현재 페이지 Excel로 내보내기 - - - PDFTron 변수명 일괄 생성 - */} - - - - + - Excel로 내보내기 + {isExporting ? "내보내는 중..." : "전체 데이터 Excel로 내보내기"} - - - Excel에서 가져오기 - + + + e.preventDefault()}> + + Excel에서 가져오기 + + } + /> @@ -152,11 +285,6 @@ export function GtcClausesTableToolbarActions({ 문서 미리보기 - - {/* - - 최종 문서 생성 - */} @@ -180,7 +308,7 @@ export function GtcClausesTableToolbarActions({ )} - {/* ✅ 미리보기 다이얼로그 */} + {/* 미리보기 다이얼로그 */} void + onImport?: (data: Partial[]) => Promise + trigger?: React.ReactNode +} + +type ImportStep = "upload" | "preview" | "importing" | "complete" + +export function ImportExcelDialog({ + documentId, + columns, + onSuccess, + onImport, + trigger, +}: ImportExcelDialogProps) { + const [open, setOpen] = React.useState(false) + const [step, setStep] = React.useState("upload") + const [selectedFile, setSelectedFile] = React.useState(null) + const [parsedData, setParsedData] = React.useState[]>([]) + const [errors, setErrors] = React.useState([]) + const [isProcessing, setIsProcessing] = React.useState(false) + const fileInputRef = React.useRef(null) + + // 다이얼로그 열기/닫기 시 상태 초기화 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + // 다이얼로그 닫을 때 상태 초기화 + setStep("upload") + setSelectedFile(null) + setParsedData([]) + setErrors([]) + setIsProcessing(false) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + } + + // 템플릿 다운로드 + const handleDownloadTemplate = async () => { + try { + await downloadExcelTemplate(columns, { + filename: `gtc-clauses-template-${new Date().toISOString().split('T')[0]}`, + includeExampleData: true, + useGroupHeader: true, + }) + + toast({ + title: "템플릿 다운로드 완료", + description: "Excel 템플릿이 다운로드되었습니다. 템플릿에 데이터를 입력한 후 업로드해주세요.", + }) + } catch (error) { + toast({ + title: "템플릿 다운로드 실패", + description: "템플릿 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } + + // 파일 선택 + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + if (file.type !== "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" && + file.type !== "application/vnd.ms-excel") { + toast({ + title: "잘못된 파일 형식", + description: "Excel 파일(.xlsx, .xls)만 업로드할 수 있습니다.", + variant: "destructive", + }) + return + } + setSelectedFile(file) + } + } + + // 파일 파싱 + const handleParseFile = async () => { + if (!selectedFile) return + + setIsProcessing(true) + try { + const result = await parseExcelFile( + selectedFile, + columns, + { + hasGroupHeader: true, + sheetName: "GTC조항템플릿", + } + ) + + setParsedData(result.data) + setErrors(result.errors) + + if (result.errors.length > 0) { + toast({ + title: "파싱 완료 (오류 있음)", + description: `${result.data.length}개의 행을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.`, + variant: "destructive", + }) + } else { + toast({ + title: "파싱 완료", + description: `${result.data.length}개의 행이 성공적으로 파싱되었습니다.`, + }) + } + + setStep("preview") + } catch (error) { + toast({ + title: "파싱 실패", + description: "파일 파싱 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsProcessing(false) + } + } + + // 데이터 가져오기 실행 + const handleImportData = async () => { + if (parsedData.length === 0 || !onImport) return + + setStep("importing") + try { + await onImport(parsedData) + setStep("complete") + + toast({ + title: "가져오기 완료", + description: `${parsedData.length}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 성공 콜백 호출 후 잠시 후 다이얼로그 닫기 + setTimeout(() => { + onSuccess?.() + setOpen(false) + }, 2000) + } catch (error) { + toast({ + title: "가져오기 실패", + description: "데이터 가져오기 중 오류가 발생했습니다.", + variant: "destructive", + }) + setStep("preview") + } + } + + const renderUploadStep = () => ( +
+
+ +

Excel 파일로 조항 가져오기

+

+ 먼저 템플릿을 다운로드하여 데이터를 입력한 후 업로드해주세요. +

+
+ +
+
+ +

+ 템플릿에는 입력 가이드와 예시 데이터가 포함되어 있습니다. +

+
+ + + +
+ + +
+ + {selectedFile && ( + + )} +
+
+ ) + + const renderPreviewStep = () => ( +
+
+

데이터 미리보기

+
+ + {parsedData.length}개 행 + + {errors.length > 0 && ( + + {errors.length}개 오류 + + )} +
+
+ + {errors.length > 0 && ( + + + +
+
다음 오류들을 확인해주세요:
+
    + {errors.slice(0, 5).map((error, index) => ( +
  • {error}
  • + ))} + {errors.length > 5 && ( +
  • ... 및 {errors.length - 5}개 추가 오류
  • + )} +
+
+
+
+ )} + + + + + + # + 채번 + 소제목 + 상세항목 + 분류 + 상태 + + + + {parsedData.map((item, index) => ( + + {index + 1} + + {item.itemNumber || "-"} + + + {item.subtitle || "-"} + + + {item.content || "-"} + + {item.category || "-"} + + + {item.isActive ? "활성" : "비활성"} + + + + ))} + +
+
+ +
+ + +
+
+ ) + + const renderImportingStep = () => ( +
+
+

가져오는 중...

+

+ {parsedData.length}개의 조항을 데이터베이스에 저장하고 있습니다. +

+
+ ) + + const renderCompleteStep = () => ( +
+ +

가져오기 완료!

+

+ {parsedData.length}개의 조항이 성공적으로 가져와졌습니다. +

+
+ ) + + return ( + + + {trigger || ( + + )} + + + + Excel에서 조항 가져오기 + + Excel 파일을 사용하여 여러 조항을 한 번에 가져올 수 있습니다. + + + +
+ {step === "upload" && renderUploadStep()} + {step === "preview" && renderPreviewStep()} + {step === "importing" && renderImportingStep()} + {step === "complete" && renderCompleteStep()} +
+
+
+ ) +} \ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx index 29ab1b5a..3639c0f3 100644 --- a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx +++ b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx @@ -12,7 +12,8 @@ import { Loader2, FileText, RefreshCw, - Settings + Settings, + AlertCircle } from "lucide-react" import { toast } from "sonner" @@ -35,6 +36,7 @@ export function PreviewDocumentDialog({ const [isGenerating, setIsGenerating] = React.useState(false) const [documentGenerated, setDocumentGenerated] = React.useState(false) const [viewerInstance, setViewerInstance] = React.useState(null) + const [hasError, setHasError] = React.useState(false) // 조항 통계 계산 const stats = React.useMemo(() => { @@ -52,38 +54,84 @@ export function PreviewDocumentDialog({ const handleGeneratePreview = async () => { setIsGenerating(true) + setHasError(false) + setDocumentGenerated(false) + try { - // 잠시 후 문서 생성 완료로 설정 (실제로는 뷰어에서 처리) - setTimeout(() => { + // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로 + // 여기서는 상태만 관리 + console.log("🚀 문서 미리보기 생성 시작") + + // ClausePreviewViewer가 완전히 로드될 때까지 기다림 + await new Promise(resolve => setTimeout(resolve, 2000)) + + if (!hasError) { setDocumentGenerated(true) - setIsGenerating(false) toast.success("문서 미리보기가 생성되었습니다.") - }, 1500) + } } catch (error) { - setIsGenerating(false) + console.error("문서 생성 중 오류:", error) + setHasError(true) toast.error("문서 생성 중 오류가 발생했습니다.") + } finally { + setIsGenerating(false) } } const handleExportDocument = () => { if (viewerInstance) { - // PDFTron의 다운로드 기능 실행 - viewerInstance.UI.downloadPdf({ - filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` - }) - toast.success("문서가 다운로드됩니다.") + try { + // PDFTron의 다운로드 기능 실행 + viewerInstance.UI.downloadPdf({ + filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` + }) + toast.success("PDF 다운로드가 시작됩니다.") + } catch (error) { + console.error("다운로드 오류:", error) + toast.error("다운로드 중 오류가 발생했습니다.") + } + } else { + toast.error("뷰어가 준비되지 않았습니다.") } } const handleRegenerateDocument = () => { + console.log("🔄 문서 재생성 시작") setDocumentGenerated(false) + setHasError(false) handleGeneratePreview() } + const handleViewerSuccess = React.useCallback(() => { + setDocumentGenerated(true) + setIsGenerating(false) + setHasError(false) + }, []) + + const handleViewerError = React.useCallback(() => { + setHasError(true) + setIsGenerating(false) + setDocumentGenerated(false) + }, []) + + // 다이얼로그가 열릴 때 자동으로 미리보기 생성 + React.useEffect(() => { + if (props.open && !documentGenerated && !isGenerating && !hasError) { + const timer = setTimeout(() => { + handleGeneratePreview() + }, 300) // 다이얼로그 애니메이션 후 시작 + + return () => clearTimeout(timer) + } + }, [props.open, documentGenerated, isGenerating, hasError]) + + // 다이얼로그가 닫힐 때 상태 초기화 React.useEffect(() => { - // 다이얼로그가 열릴 때 자동으로 미리보기 생성 - if (props.open && !documentGenerated && !isGenerating) { - handleGeneratePreview() + if (!props.open) { + setDocumentGenerated(false) + setIsGenerating(false) + setHasError(false) + setViewerInstance(null) } }, [props.open]) @@ -107,9 +155,15 @@ export function PreviewDocumentDialog({ {document?.title || 'GTC 계약서'} {stats.total}개 조항 + {hasError && ( + + + 오류 발생 + + )}
- {documentGenerated && ( + {documentGenerated && !hasError && ( <> )} + {hasError && ( + + )}
@@ -164,6 +230,21 @@ export function PreviewDocumentDialog({

{stats.total}개의 조항을 배치하고 있습니다.

+

+ 초기화에 시간이 걸릴 수 있습니다... +

+ + ) : hasError ? ( +
+ +

문서 생성 실패

+

+ 문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요. +

+
) : documentGenerated ? ( ) : (
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index 308c52bf..4d11ad0a 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -1,13 +1,13 @@ 'use server' import { revalidateTag, unstable_cache } from "next/cache" -import { and, desc, asc, eq, or, ilike, count, max , inArray} from "drizzle-orm" +import { and, desc, asc, eq, or, ilike, count, max , inArray, isNotNull, notInArray} from "drizzle-orm" import db from "@/db/db" -import { gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { GtcClauseTreeView, gtcClauses, gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" import { projects } from "@/db/schema/projects" import { users } from "@/db/schema/users" import { filterColumns } from "@/lib/filter-columns" -import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema } from "./validations" +import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema, CloneGtcDocumentSchema } from "./validations" /** * 프로젝트 존재 여부 확인 @@ -330,4 +330,518 @@ export async function getGtcDocumentById(id: number) { tags: [`gtc-document-${id}`, "gtc-documents"], } )() +} + +// 복제 함수 +export async function cloneGtcDocument( + data: CloneGtcDocumentSchema & { createdById: number } +): Promise<{ data?: GtcDocument; error?: string }> { + try { + return await db.transaction(async (tx) => { + // 1. 원본 문서 조회 + const [sourceDocument] = await tx + .select() + .from(gtcDocuments) + .where(eq(gtcDocuments.id, data.sourceDocumentId)) + + if (!sourceDocument) { + throw new Error("원본 문서를 찾을 수 없습니다.") + } + + // 2. 새로운 리비전 번호 계산 + const nextRevision = await getNextRevision(data.type, data.projectId || undefined) + + // 3. 새 문서 생성 + const [newDocument] = await tx + .insert(gtcDocuments) + .values({ + type: data.type, + projectId: data.projectId, + title: data.title || sourceDocument.title, + revision: nextRevision, + fileName: sourceDocument.fileName, // 파일 정보도 복사 + filePath: sourceDocument.filePath, + fileSize: sourceDocument.fileSize, + createdById: data.createdById, + updatedById: data.createdById, + editReason: data.editReason || `${sourceDocument.title || 'GTC 문서'} v${sourceDocument.revision}에서 복제`, + isActive: true, + }) + .returning() + + // 4. 원본 문서의 모든 clauses 조회 + const sourceClauses = await tx + .select() + .from(gtcClauses) + .where(eq(gtcClauses.documentId, data.sourceDocumentId)) + .orderBy(gtcClauses.sortOrder) + + // 5. clauses 복제 (ID 매핑을 위한 Map 생성) + if (sourceClauses.length > 0) { + const clauseIdMapping = new Map() + + // 첫 번째 pass: 모든 clauses를 복사하고 ID 매핑 생성 + for (const sourceClause of sourceClauses) { + const [newClause] = await tx + .insert(gtcClauses) + .values({ + documentId: newDocument.id, + parentId: null, // 첫 번째 pass에서는 null로 설정 + itemNumber: sourceClause.itemNumber, + category: sourceClause.category, + subtitle: sourceClause.subtitle, + content: sourceClause.content, + sortOrder: sourceClause.sortOrder, + depth: sourceClause.depth, + fullPath: sourceClause.fullPath, + images: sourceClause.images, + isActive: sourceClause.isActive, + createdById: data.createdById, + updatedById: data.createdById, + editReason: data.editReason || "문서 복제", + }) + .returning() + + clauseIdMapping.set(sourceClause.id, newClause.id) + } + + // 두 번째 pass: parentId 관계 설정 + for (const sourceClause of sourceClauses) { + if (sourceClause.parentId) { + const newParentId = clauseIdMapping.get(sourceClause.parentId) + const newClauseId = clauseIdMapping.get(sourceClause.id) + + if (newParentId && newClauseId) { + await tx + .update(gtcClauses) + .set({ parentId: newParentId }) + .where(eq(gtcClauses.id, newClauseId)) + } + } + } + } + + revalidateTag("gtc-documents") + revalidateTag(`gtc-clauses-${newDocument.id}`) + + return { data: newDocument } + }) + } catch (error) { + console.error("Error cloning GTC document:", error) + return { + error: error instanceof Error + ? error.message + : "문서 복제 중 오류가 발생했습니다." + } + } +} + + +// 새 함수: GTC 문서가 없는 프로젝트만 조회 +export async function getAvailableProjectsForGtc(): Promise { + // 이미 GTC 문서가 있는 프로젝트 ID들 조회 + const projectsWithGtc = await db + .selectDistinct({ + projectId: gtcDocuments.projectId + }) + .from(gtcDocuments) + .where(isNotNull(gtcDocuments.projectId)) + + const usedProjectIds = projectsWithGtc + .map(row => row.projectId) + .filter((id): id is number => id !== null) + + // GTC 문서가 없는 프로젝트들만 반환 + if (usedProjectIds.length === 0) { + // 사용된 프로젝트가 없으면 모든 프로젝트 반환 + return await getProjectsForSelect() + } + + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(notInArray(projects.id, usedProjectIds)) + .orderBy(projects.name) +} + +// 복제시 사용할 함수: 특정 프로젝트는 제외하고 조회 +export async function getAvailableProjectsForGtcExcluding(excludeProjectId?: number): Promise { + // 이미 GTC 문서가 있는 프로젝트 ID들 조회 + const projectsWithGtc = await db + .selectDistinct({ + projectId: gtcDocuments.projectId + }) + .from(gtcDocuments) + .where(isNotNull(gtcDocuments.projectId)) + + let usedProjectIds = projectsWithGtc + .map(row => row.projectId) + .filter((id): id is number => id !== null) + + // 제외할 프로젝트 ID가 있다면 사용된 ID 목록에서 제거 (복제시 원본 프로젝트는 선택 가능) + if (excludeProjectId) { + usedProjectIds = usedProjectIds.filter(id => id !== excludeProjectId) + } + + // GTC 문서가 없는 프로젝트들만 반환 + if (usedProjectIds.length === 0) { + return await getProjectsForSelect() + } + + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(notInArray(projects.id, usedProjectIds)) + .orderBy(projects.name) +} + +export async function hasStandardGtcDocument(): Promise { + const result = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where(eq(gtcDocuments.type, "standard")) + .limit(1) + + return result.length > 0 +} + +export async function getAllGtcClausesForExport(documentId: number): Promise { + try { + // 실제 데이터베이스 쿼리 로직을 여기에 구현 + // 예시: 문서 ID에 해당하는 모든 조항을 트리 뷰 형태로 가져오기 + const clauses = await db + .select() + .from(gtcClauses) + .where(eq(gtcClauses.documentId, documentId)) + // 여기에 필요한 JOIN, ORDER BY 등을 추가 + .orderBy(gtcClauses.sortOrder) + + // GtcClauseTreeView 형태로 변환하여 반환 + return clauses.map((clause) => ({ + ...clause, + // 필요한 추가 필드들을 여기에 매핑 + })) as GtcClauseTreeView[] + } catch (error) { + console.error("Failed to fetch GTC clauses for export:", error) + throw new Error("Failed to fetch GTC clauses for export") + } +} + + +interface ImportGtcClauseData { + itemNumber: string + subtitle: string + content?: string + category?: string + sortOrder?: number + parentId?: number | null + depth?: number + fullPath?: string + images?: any[] + isActive?: boolean + editReason?: string +} + +interface ImportResult { + success: boolean + importedCount: number + errors: string[] + duplicates: string[] +} + +/** + * Excel에서 가져온 GTC 조항들을 데이터베이스에 저장 + */ +export async function importGtcClausesFromExcel( + documentId: number, + data: Partial[], + userId: number = 1 // TODO: 실제 사용자 ID로 교체 +): Promise { + const result: ImportResult = { + success: false, + importedCount: 0, + errors: [], + duplicates: [] + } + + try { + // 데이터 검증 및 변환 + const validData: ImportGtcClauseData[] = [] + + for (let i = 0; i < data.length; i++) { + const item = data[i] + const rowNumber = i + 1 + + // 필수 필드 검증 + if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') { + result.errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`) + continue + } + + if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') { + result.errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`) + continue + } + + // 중복 채번 체크 (같은 문서 내에서, 같은 부모 하에서) + const existingClause = await db + .select({ id: gtcClauses.id, itemNumber: gtcClauses.itemNumber }) + .from(gtcClauses) + .where( + and( + eq(gtcClauses.documentId, documentId), + eq(gtcClauses.parentId, item.parentId || null), + eq(gtcClauses.itemNumber, item.itemNumber.trim()) + ) + ) + .limit(1) + + if (existingClause.length > 0) { + result.duplicates.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`) + continue + } + + // 상위 조항 ID 검증 (제공된 경우) + if (item.parentId && typeof item.parentId === 'number') { + const parentExists = await db + .select({ id: gtcClauses.id }) + .from(gtcClauses) + .where( + and( + eq(gtcClauses.documentId, documentId), + eq(gtcClauses.id, item.parentId) + ) + ) + .limit(1) + + if (parentExists.length === 0) { + result.errors.push(`${rowNumber}행: 상위 조항 ID ${item.parentId}를 찾을 수 없습니다.`) + continue + } + } + + // sortOrder를 decimal로 변환 + let sortOrder = 0 + if (item.sortOrder !== undefined) { + if (typeof item.sortOrder === 'number') { + sortOrder = item.sortOrder + } else if (typeof item.sortOrder === 'string') { + const parsed = parseFloat(item.sortOrder) + if (!isNaN(parsed)) { + sortOrder = parsed + } + } + } else { + // 기본값: (현재 인덱스 + 1) * 10 + sortOrder = (validData.length + 1) * 10 + } + + // depth 계산 (parentId가 있으면 부모의 depth + 1, 아니면 0) + let depth = 0 + if (item.parentId) { + const parentClause = await db + .select({ depth: gtcClauses.depth }) + .from(gtcClauses) + .where(eq(gtcClauses.id, item.parentId)) + .limit(1) + + if (parentClause.length > 0) { + depth = (parentClause[0].depth || 0) + 1 + } + } + + // 유효한 데이터 추가 + validData.push({ + itemNumber: item.itemNumber.trim(), + subtitle: item.subtitle.trim(), + content: item.content?.toString().trim() || null, + category: item.category?.toString().trim() || null, + sortOrder: sortOrder, + parentId: item.parentId || null, + isActive: typeof item.isActive === 'boolean' ? item.isActive : true, + editReason: item.editReason?.toString().trim() || null, + }) + } + + // 오류가 있거나 중복이 있으면 가져오기 중단 + if (result.errors.length > 0 || result.duplicates.length > 0) { + return result + } + + // 트랜잭션으로 데이터 저장 + await db.transaction(async (tx) => { + for (const clauseData of validData) { + try { + // depth 재계산 (저장 시점에서) + let finalDepth = 0 + if (clauseData.parentId) { + const parentClause = await tx + .select({ depth: gtcClauses.depth }) + .from(gtcClauses) + .where(eq(gtcClauses.id, clauseData.parentId)) + .limit(1) + + if (parentClause.length > 0) { + finalDepth = (parentClause[0].depth || 0) + 1 + } + } + + await tx.insert(gtcClauses).values({ + documentId, + parentId: clauseData.parentId, + itemNumber: clauseData.itemNumber, + category: clauseData.category, + subtitle: clauseData.subtitle, + content: clauseData.content, + sortOrder:clauseData.sortOrder? clauseData.sortOrder.toString() :"0", // decimal로 저장 + depth: finalDepth, + fullPath: null, // 추후 별도 로직에서 생성 + images: null, // Excel 가져오기에서는 이미지 제외 + isActive: clauseData.isActive, + createdById: userId, + updatedById: userId, + editReason: clauseData.editReason, + createdAt: new Date(), + updatedAt: new Date(), + }) + + result.importedCount++ + } catch (insertError) { + result.errors.push(`"${clauseData.subtitle}" 저장 중 오류: ${insertError instanceof Error ? insertError.message : '알 수 없는 오류'}`) + } + } + }) + + result.success = result.importedCount > 0 && result.errors.length === 0 + + return result + + } catch (error) { + result.errors.push(`가져오기 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + return result + } +} + +/** + * Excel 가져오기 전 데이터 유효성 검사 + */ +export async function validateGtcClausesImport( + documentId: number, + data: Partial[] +): Promise<{ + valid: boolean + errors: string[] + warnings: string[] + summary: { + totalRows: number + validRows: number + duplicateCount: number + errorCount: number + } +}> { + const errors: string[] = [] + const warnings: string[] = [] + let validRows = 0 + let duplicateCount = 0 + + try { + // 기존 채번들 가져오기 (중복 체크용) + const existingItems = await db + .select({ + itemNumber: gtcClauses.itemNumber, + parentId: gtcClauses.parentId + }) + .from(gtcClauses) + .where(eq(gtcClauses.documentId, documentId)) + + // 채번-부모ID 조합으로 중복 체크 세트 생성 + const existingItemSet = new Set( + existingItems.map(item => `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}`) + ) + + // 같은 파일 내에서의 중복 체크를 위한 세트 + const currentFileItems = new Set() + + for (let i = 0; i < data.length; i++) { + const item = data[i] + const rowNumber = i + 1 + let hasError = false + + // 필수 필드 검증 + if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') { + errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`) + hasError = true + } + + if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') { + errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`) + hasError = true + } + + if (item.itemNumber && item.itemNumber.trim() !== '') { + const itemKey = `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}` + + // DB에 이미 존재하는지 체크 + if (existingItemSet.has(itemKey)) { + warnings.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`) + duplicateCount++ + } + + // 현재 파일 내에서 중복인지 체크 + if (currentFileItems.has(itemKey)) { + errors.push(`${rowNumber}행: 채번 "${item.itemNumber}"가 파일 내에서 중복됩니다.`) + hasError = true + } else { + currentFileItems.add(itemKey) + } + } + + // 숫자 필드 검증 + if (item.sortOrder !== undefined) { + const sortOrderNum = typeof item.sortOrder === 'string' ? parseFloat(item.sortOrder) : item.sortOrder + if (typeof sortOrderNum !== 'number' || isNaN(sortOrderNum) || sortOrderNum < 0) { + warnings.push(`${rowNumber}행: 순서는 0 이상의 숫자여야 합니다.`) + } + } + + if (!hasError) { + validRows++ + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + summary: { + totalRows: data.length, + validRows, + duplicateCount, + errorCount: errors.length + } + } + + } catch (error) { + errors.push(`유효성 검사 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + + return { + valid: false, + errors, + warnings, + summary: { + totalRows: data.length, + validRows: 0, + duplicateCount: 0, + errorCount: errors.length + } + } + } } \ No newline at end of file diff --git a/lib/gtc-contract/status/clone-gtc-document-dialog.tsx b/lib/gtc-contract/status/clone-gtc-document-dialog.tsx new file mode 100644 index 00000000..1e56f2f7 --- /dev/null +++ b/lib/gtc-contract/status/clone-gtc-document-dialog.tsx @@ -0,0 +1,383 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader, Copy } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { cloneGtcDocumentSchema, type CloneGtcDocumentSchema } from "@/lib/gtc-contract/validations" +import { cloneGtcDocument, getAvailableProjectsForGtcExcluding, hasStandardGtcDocument } from "@/lib/gtc-contract/service" +import { type ProjectForFilter } from "@/lib/gtc-contract/service" +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" +import { Input } from "@/components/ui/input" +import { useRouter } from "next/navigation" + +interface CloneGtcDocumentDialogProps { + sourceDocument: GtcDocumentWithRelations + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function CloneGtcDocumentDialog({ + sourceDocument, + open: controlledOpen, + onOpenChange: controlledOnOpenChange +}: CloneGtcDocumentDialogProps) { + const [internalOpen, setInternalOpen] = React.useState(false) + const [projects, setProjects] = React.useState([]) + const [isClonePending, startCloneTransition] = React.useTransition() + const { data: session } = useSession() + const router = useRouter() + const [defaultType, setDefaultType] = React.useState<"standard" | "project">("standard") + + const isControlled = controlledOpen !== undefined + const open = isControlled ? controlledOpen! : internalOpen + const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + + + + const form = useForm({ + resolver: zodResolver(cloneGtcDocumentSchema), + defaultValues: { + sourceDocumentId: sourceDocument.id, + type: sourceDocument.type, + projectId: sourceDocument.projectId, + title: sourceDocument.title || "", + editReason: "", + }, + }) + + const resetForm = React.useCallback((type: "standard" | "project") => { + form.reset({ + sourceDocumentId: sourceDocument.id, + type, + projectId: sourceDocument.projectId, + title: sourceDocument.title || "", + editReason: "", + }) + }, [form, sourceDocument]) + + React.useEffect(() => { + if (open) { + // 표준 GTC 존재 여부와 사용 가능한 프로젝트 동시 조회 + Promise.all([ + hasStandardGtcDocument(), + getAvailableProjectsForGtcExcluding(sourceDocument.projectId || undefined) + ]).then(([hasStandard, availableProjects]) => { + const initialType = hasStandard ? "project" : "standard" + setDefaultType(initialType) + setProjects(availableProjects) + + // 폼 기본값 설정: 원본 문서 타입을 우선으로 하되, 표준이 이미 있고 원본도 표준이면 프로젝트로 변경 + const targetType = hasStandard && sourceDocument.type === "standard" ? "project" : sourceDocument.type + resetForm(targetType) + }) + } + }, [open, sourceDocument.projectId, sourceDocument.type, resetForm]) + + + const watchedType = form.watch("type") + + React.useEffect(() => { + // 소스 문서가 변경되면 폼 기본값 업데이트 (다이얼로그가 열려있을 때만) + if (open) { + hasStandardGtcDocument().then((hasStandard) => { + const targetType = hasStandard && sourceDocument.type === "standard" ? "project" : sourceDocument.type + resetForm(targetType) + }) + } + }, [sourceDocument, resetForm, open]) + + async function onSubmit(data: CloneGtcDocumentSchema) { + startCloneTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + const result = await cloneGtcDocument({ + ...data, + createdById: currentUserId + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + resetForm(sourceDocument.type) + setOpen(false) + router.refresh() + + toast.success("GTC 문서가 복제되었습니다.") + } catch (error) { + toast.error("문서 복제 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + // 다이얼로그 닫을 때는 원본 문서 정보로 리셋 + resetForm(sourceDocument.type) + } + setOpen(nextOpen) + } + + const DialogWrapper = isControlled ? React.Fragment : Dialog + + return ( + + {!isControlled && ( + + + + )} + + + + + GTC 문서 복제 + + 기존 문서를 복제하여 새로운 문서를 생성합니다.
+ + 원본: {sourceDocument.title || `${sourceDocument.type === 'standard' ? '표준' : '프로젝트'} GTC v${sourceDocument.revision}`} + +
+
+ + + +
+ {/* 구분 (Type) */} + ( + + 구분 + + + + {defaultType === "project" && sourceDocument.type === "standard" && ( + + 표준 GTC 문서가 이미 존재합니다. 복제시에는 프로젝트 타입을 권장합니다. + + )} + + + )} + /> + + {/* 프로젝트 선택 (프로젝트 타입인 경우만) */} + {watchedType === "project" && ( + { + const selectedProject = projects.find( + (p) => p.id === field.value + ) + const [popoverOpen, setPopoverOpen] = React.useState(false) + + return ( + + 프로젝트 + + + + + + + + + + + + {projects.length === 0 + ? "사용 가능한 프로젝트가 없습니다." + : "프로젝트를 찾을 수 없습니다." + } + + + {projects.map((project) => { + const label = `${project.name} (${project.code})` + return ( + { + field.onChange(project.id) + setPopoverOpen(false) + }} + > + {label} + + + ) + })} + + + + + + + + + ) + }} + /> + )} + + ( + + GTC 제목 (선택사항) + + + + + 워드의 제목으로 사용됩니다. + + + + )} + /> + + {/* 편집 사유 */} + ( + + 복제 사유 (선택사항) + +