From 92ddb4f13d48cbf344dc2bf63df4457b3c713608 Mon Sep 17 00:00:00 2001 From: rlaks5757 Date: Wed, 26 Mar 2025 16:51:54 +0900 Subject: feat: report batch download 기능 완료 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/form-data/form-data-table.tsx | 564 ++++++++++++++++++------------- 1 file changed, 334 insertions(+), 230 deletions(-) (limited to 'components/form-data/form-data-table.tsx') diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 14fff12e..50c4f267 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -1,36 +1,40 @@ -"use client" +"use client"; -import * as React from "react" -import { useParams } from "next/navigation" -import { useTranslation } from "@/i18n/client" +import * as React from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; -import { ClientDataTable } from "../client-data-table/data-table" +import { ClientDataTable } from "../client-data-table/data-table"; import { getColumns, DataTableRowAction, DataTableColumnJSON, ColumnType, -} from "./form-data-table-columns" +} from "./form-data-table-columns"; -import type { DataTableAdvancedFilterField } from "@/types/table" -import { Button } from "../ui/button" -import { Download, Loader, Save, Upload } from "lucide-react" -import { toast } from "sonner" -import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services" -import { UpdateTagSheet } from "./update-form-sheet" +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { Button } from "../ui/button"; +import { Download, Loader, Save, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"; +import { UpdateTagSheet } from "./update-form-sheet"; -import ExcelJS from "exceljs" -import { saveAs } from "file-saver" +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; +import { FormDataReportDialog } from "./form-data-report-dialog"; +import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; interface GenericData { - [key: string]: any + [key: string]: any; } export interface DynamicTableProps { - dataJSON: GenericData[] - columnsJSON: DataTableColumnJSON[] - contractItemId: number - formCode: string + dataJSON: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + contractItemId: number; + formCode: string; + formId: number; } export default function DynamicTable({ @@ -38,437 +42,476 @@ export default function DynamicTable({ columnsJSON, contractItemId, formCode, + formId, }: DynamicTableProps) { - const params = useParams() - const lng = (params?.lng as string) || "ko" - const { t } = useTranslation(lng, "translation") - - const [rowAction, setRowAction] = React.useState | null>(null) - const [tableData, setTableData] = React.useState(() => dataJSON) - const [isPending, setIsPending] = React.useState(false) - const [isSaving, setIsSaving] = React.useState(false) + console.log({ columnsJSON }); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "translation"); + + const [rowAction, setRowAction] = + React.useState | null>(null); + const [tableData, setTableData] = React.useState( + () => dataJSON + ); + const [isPending, setIsPending] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [tempUpDialog, setTempUpDialog] = React.useState(false); + const [reportData, setReportData] = React.useState([]); + const [batchDownDialog, setBatchDownDialog] = React.useState(false); // Reference to the table instance - const tableRef = React.useRef(null) + const tableRef = React.useRef(null); const columns = React.useMemo( - () => getColumns({ columnsJSON, setRowAction }), - [columnsJSON, setRowAction] - ) + () => getColumns({ columnsJSON, setRowAction, setReportData }), + [columnsJSON, setRowAction, setReportData] + ); function mapColumnTypeToAdvancedFilterType( columnType: ColumnType ): DataTableAdvancedFilterField["type"] { switch (columnType) { case "STRING": - return "text" - case "NUMBER": - return "number" + return "text"; + case "NUMBER": + return "number"; case "LIST": // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다. - return "select" + return "select"; // 그 외 다른 타입들도 적절히 추가 매핑 default: // 예: 못 매핑한 경우 기본적으로 "text" 적용 - return "text" + return "text"; } } - const advancedFilterFields = React.useMemo[]>( - () => { - return columnsJSON.map((col) => ({ - id: col.key, - label: col.label, - type: mapColumnTypeToAdvancedFilterType(col.type), - options: - col.type === "LIST" - ? col.options?.map((v) => ({ label: v, value: v })) - : undefined, - })) - }, - [columnsJSON] - ) + const advancedFilterFields = React.useMemo< + DataTableAdvancedFilterField[] + >(() => { + return columnsJSON.map((col) => ({ + id: col.key, + label: col.label, + type: mapColumnTypeToAdvancedFilterType(col.type), + options: + col.type === "LIST" + ? col.options?.map((v) => ({ label: v, value: v })) + : undefined, + })); + }, [columnsJSON]); // 1) 태그 불러오기 (기존) async function handleSyncTags() { try { - setIsPending(true) - const result = await syncMissingTags(contractItemId, formCode) - + setIsPending(true); + const result = await syncMissingTags(contractItemId, formCode); + // Prepare the toast messages based on what changed - const changes = [] - if (result.createdCount > 0) changes.push(`${result.createdCount}건 태그 생성`) - if (result.updatedCount > 0) changes.push(`${result.updatedCount}건 태그 업데이트`) - if (result.deletedCount > 0) changes.push(`${result.deletedCount}건 태그 삭제`) - + const changes = []; + if (result.createdCount > 0) + changes.push(`${result.createdCount}건 태그 생성`); + if (result.updatedCount > 0) + changes.push(`${result.updatedCount}건 태그 업데이트`); + if (result.deletedCount > 0) + changes.push(`${result.deletedCount}건 태그 삭제`); + if (changes.length > 0) { // If any changes were made, show success message and reload - toast.success(`동기화 완료: ${changes.join(', ')}`) - location.reload() + toast.success(`동기화 완료: ${changes.join(", ")}`); + location.reload(); } else { // If no changes were made, show an info message - toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다.") + toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); } } catch (err) { - console.error(err) - toast.error("태그 동기화 중 에러가 발생했습니다.") + console.error(err); + toast.error("태그 동기화 중 에러가 발생했습니다."); } finally { - setIsPending(false) + setIsPending(false); } } // 2) Excel Import (새로운 기능) async function handleImportExcel(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return + const file = e.target.files?.[0]; + if (!file) return; try { - setIsPending(true) + setIsPending(true); // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그) - const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)) + const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)); - const workbook = new ExcelJS.Workbook() - const arrayBuffer = await file.arrayBuffer() - await workbook.xlsx.load(arrayBuffer) + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await file.arrayBuffer(); + await workbook.xlsx.load(arrayBuffer); - const worksheet = workbook.worksheets[0] + const worksheet = workbook.worksheets[0]; // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크) - const headerRow = worksheet.getRow(1) - const headerRowValues = headerRow.values as ExcelJS.CellValue[] + const headerRow = worksheet.getRow(1); + const headerRowValues = headerRow.values as ExcelJS.CellValue[]; // 디버깅용 로그 - console.log("원본 헤더 값:", headerRowValues) + console.log("원본 헤더 값:", headerRowValues); // Excel의 헤더와 columnsJSON의 label 매핑 생성 // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined - const headerToIndexMap = new Map() + const headerToIndexMap = new Map(); for (let i = 1; i < headerRowValues.length; i++) { - const headerValue = String(headerRowValues[i] || "").trim() + const headerValue = String(headerRowValues[i] || "").trim(); if (headerValue) { - headerToIndexMap.set(headerValue, i) + headerToIndexMap.set(headerValue, i); } } // (B) 헤더 검사 - let headerErrorMessage = "" + let headerErrorMessage = ""; // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨 columnsJSON.forEach((col) => { - const label = col.label + const label = col.label; if (!headerToIndexMap.has(label)) { - headerErrorMessage += `Column "${label}" is missing. ` + headerErrorMessage += `Column "${label}" is missing. `; } - }) + }); // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사 headerToIndexMap.forEach((index, headerLabel) => { - const found = columnsJSON.some((col) => col.label === headerLabel) + const found = columnsJSON.some((col) => col.label === headerLabel); if (!found) { - headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. ` + headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; } - }) + }); // (C) 이제 Error 열 추가 - const lastColIndex = worksheet.columnCount + 1 - worksheet.getRow(1).getCell(lastColIndex).value = "Error" + const lastColIndex = worksheet.columnCount + 1; + worksheet.getRow(1).getCell(lastColIndex).value = "Error"; // 헤더 에러가 있으면 기록 후 다운로드하고 중단 if (headerErrorMessage) { - headerRow.getCell(lastColIndex).value = headerErrorMessage.trim() + headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); - const outBuffer = await workbook.xlsx.writeBuffer() - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - toast.error(`Header mismatch found. Please check downloaded file.`) - return + toast.error(`Header mismatch found. Please check downloaded file.`); + return; } // -- 여기까지 왔다면, 헤더는 문제 없음 -- // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용) // columnsJSON의 key와 Excel 열 인덱스 간의 매핑 - const keyToIndexMap = new Map() + const keyToIndexMap = new Map(); columnsJSON.forEach((col) => { - const index = headerToIndexMap.get(col.label) + const index = headerToIndexMap.get(col.label); if (index !== undefined) { - keyToIndexMap.set(col.key, index) + keyToIndexMap.set(col.key, index); } - }) + }); // 데이터 파싱 - const importedData: GenericData[] = [] - const lastRowNumber = worksheet.lastRow?.number || 1 - let errorCount = 0 + const importedData: GenericData[] = []; + const lastRowNumber = worksheet.lastRow?.number || 1; + let errorCount = 0; // 실제 데이터 행 파싱 for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { - const row = worksheet.getRow(rowNum) - const rowValues = row.values as ExcelJS.CellValue[] - if (!rowValues || rowValues.length <= 1) continue // 빈 행 스킵 + const row = worksheet.getRow(rowNum); + const rowValues = row.values as ExcelJS.CellValue[]; + if (!rowValues || rowValues.length <= 1) continue; // 빈 행 스킵 - let errorMessage = "" - const rowObj: Record = {} + let errorMessage = ""; + const rowObj: Record = {}; // 각 열에 대해 처리 columnsJSON.forEach((col) => { - const colIndex = keyToIndexMap.get(col.key) - if (colIndex === undefined) return + const colIndex = keyToIndexMap.get(col.key); + if (colIndex === undefined) return; - const cellValue = rowValues[colIndex] ?? "" - let stringVal = String(cellValue).trim() + const cellValue = rowValues[colIndex] ?? ""; + let stringVal = String(cellValue).trim(); // 타입별 검사 switch (col.type) { case "STRING": if (!stringVal && col.key === "tagNumber") { - errorMessage += `[${col.label}] is empty. ` + errorMessage += `[${col.label}] is empty. `; } - rowObj[col.key] = stringVal - break + rowObj[col.key] = stringVal; + break; case "NUMBER": if (stringVal) { - const num = parseFloat(stringVal) + const num = parseFloat(stringVal); if (isNaN(num)) { - errorMessage += `[${col.label}] '${stringVal}' is not a valid number. ` + errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; } else { - rowObj[col.key] = num + rowObj[col.key] = num; } } else { - rowObj[col.key] = null + rowObj[col.key] = null; } - break + break; case "LIST": - if (stringVal && col.options && !col.options.includes(stringVal)) { - errorMessage += `[${col.label}] '${stringVal}' not in ${col.options.join(", ")}. ` + if ( + stringVal && + col.options && + !col.options.includes(stringVal) + ) { + errorMessage += `[${ + col.label + }] '${stringVal}' not in ${col.options.join(", ")}. `; } - rowObj[col.key] = stringVal - break + rowObj[col.key] = stringVal; + break; default: - rowObj[col.key] = stringVal - break + rowObj[col.key] = stringVal; + break; } - }) + }); // tagNumber 검사 - const tagNum = rowObj["tagNumber"] + const tagNum = rowObj["tagNumber"]; if (!tagNum) { - errorMessage += `No tagNumber found. ` + errorMessage += `No tagNumber found. `; } else if (!existingTagNumbers.has(tagNum)) { - errorMessage += `TagNumber '${tagNum}' is not in current data. ` + errorMessage += `TagNumber '${tagNum}' is not in current data. `; } if (errorMessage) { - row.getCell(lastColIndex).value = errorMessage.trim() - errorCount++ + row.getCell(lastColIndex).value = errorMessage.trim(); + errorCount++; } else { - importedData.push(rowObj) + importedData.push(rowObj); } } // 에러가 있으면 재다운로드 후 import 중단 if (errorCount > 0) { - const outBuffer = await workbook.xlsx.writeBuffer() - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) - toast.error(`There are ${errorCount} error row(s). Please check downloaded file.`) - return + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + toast.error( + `There are ${errorCount} error row(s). Please check downloaded file.` + ); + return; } // 에러 없으니 tableData 병합 setTableData((prev) => { - const newDataMap = new Map() + const newDataMap = new Map(); // 기존 데이터를 맵에 추가 prev.forEach((item) => { if (item.tagNumber) { - newDataMap.set(item.tagNumber, { ...item }) + newDataMap.set(item.tagNumber, { ...item }); } - }) + }); // 임포트 데이터로 기존 데이터 업데이트 importedData.forEach((item) => { - const tag = item.tagNumber - if (!tag) return - const oldItem = newDataMap.get(tag) || {} - newDataMap.set(tag, { ...oldItem, ...item }) - }) + const tag = item.tagNumber; + if (!tag) return; + const oldItem = newDataMap.get(tag) || {}; + newDataMap.set(tag, { ...oldItem, ...item }); + }); - return Array.from(newDataMap.values()) - }) + return Array.from(newDataMap.values()); + }); - toast.success(`Imported ${importedData.length} rows successfully.`) + toast.success(`Imported ${importedData.length} rows successfully.`); } catch (err) { - console.error("Excel import error:", err) - toast.error("Excel import failed.") + console.error("Excel import error:", err); + toast.error("Excel import failed."); } finally { - setIsPending(false) - e.target.value = "" + setIsPending(false); + e.target.value = ""; } } // 3) Save -> 서버에 전체 tableData를 저장 async function handleSave() { try { - setIsSaving(true) - + setIsSaving(true); + // 유효성 검사 - const invalidData = tableData.filter(item => !item.tagNumber?.trim()) + const invalidData = tableData.filter((item) => !item.tagNumber?.trim()); if (invalidData.length > 0) { - toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`) - return + toast.error( + `태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.` + ); + return; } - + // 서버 액션 호출 - const result = await updateFormDataInDB(formCode, contractItemId, tableData) - + const result = await updateFormDataInDB( + formCode, + contractItemId, + tableData + ); + if (result.success) { - toast.success(result.message) + toast.success(result.message); } else { - toast.error(result.message) + toast.error(result.message); } } catch (err) { - console.error("Save error:", err) - toast.error("데이터 저장 중 오류가 발생했습니다.") + console.error("Save error:", err); + toast.error("데이터 저장 중 오류가 발생했습니다."); } finally { - setIsSaving(false) + setIsSaving(false); } } // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet async function handleExportExcel() { try { - setIsPending(true) + setIsPending(true); // Create a new workbook - const workbook = new ExcelJS.Workbook() + const workbook = new ExcelJS.Workbook(); // 데이터 시트 생성 - const worksheet = workbook.addWorksheet("Data") + const worksheet = workbook.addWorksheet("Data"); // 유효성 검사용 숨김 시트 생성 - const validationSheet = workbook.addWorksheet("ValidationData") - validationSheet.state = 'hidden' // 시트 숨김 처리 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 // 1. 유효성 검사 시트에 select 옵션 추가 - const selectColumns = columnsJSON.filter(col => - col.type === "LIST" && col.options && col.options.length > 0 - ) + const selectColumns = columnsJSON.filter( + (col) => col.type === "LIST" && col.options && col.options.length > 0 + ); // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) - const validationRanges = new Map() + const validationRanges = new Map(); selectColumns.forEach((col, idx) => { - const colIndex = idx + 1 - const colLetter = validationSheet.getColumn(colIndex).letter + const colIndex = idx + 1; + const colLetter = validationSheet.getColumn(colIndex).letter; // 헤더 추가 (컬럼 레이블) - validationSheet.getCell(`${colLetter}1`).value = col.label + validationSheet.getCell(`${colLetter}1`).value = col.label; // 옵션 추가 if (col.options) { col.options.forEach((option, optIdx) => { - validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option - }) + validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option; + }); // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) validationRanges.set( col.key, - `ValidationData!${colLetter}$2:${colLetter}${col.options.length + 1}` - ) + `ValidationData!${colLetter}$2:${colLetter}${ + col.options.length + 1 + }` + ); } - }) + }); // 2. 데이터 시트에 헤더 추가 - const headers = columnsJSON.map(col => col.label) - worksheet.addRow(headers) + const headers = columnsJSON.map((col) => col.label); + worksheet.addRow(headers); // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1) - headerRow.font = { bold: true } - headerRow.alignment = { horizontal: 'center' } + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; headerRow.eachCell((cell) => { cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFCCCCCC' } - } - }) + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); // 3. 데이터 행 추가 - tableData.forEach(row => { - const rowValues = columnsJSON.map(col => { - const value = row[col.key] - return value !== undefined && value !== null ? value : '' - }) - worksheet.addRow(rowValues) - }) + tableData.forEach((row) => { + const rowValues = columnsJSON.map((col) => { + const value = row[col.key]; + return value !== undefined && value !== null ? value : ""; + }); + worksheet.addRow(rowValues); + }); // 4. 데이터 유효성 검사 적용 - const maxRows = 5000 // 데이터 유효성 검사를 적용할 최대 행 수 + const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 columnsJSON.forEach((col, idx) => { if (col.type === "LIST" && validationRanges.has(col.key)) { - const colLetter = worksheet.getColumn(idx + 1).letter - const validationRange = validationRanges.get(col.key)! + const colLetter = worksheet.getColumn(idx + 1).letter; + const validationRange = validationRanges.get(col.key)!; // 유효성 검사 정의 const validation = { - type: 'list' as const, + type: "list" as const, allowBlank: true, formulae: [validationRange], showErrorMessage: true, - errorStyle: 'warning' as const, - errorTitle: '유효하지 않은 값', - error: '목록에서 값을 선택해주세요.' - } + errorStyle: "warning" as const, + errorTitle: "유효하지 않은 값", + error: "목록에서 값을 선택해주세요.", + }; // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) - for (let rowIdx = 2; rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + for ( + let rowIdx = 2; + rowIdx <= Math.min(tableData.length + 1, maxRows); + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; } // 빈 행에도 적용 (최대 maxRows까지) if (tableData.length + 1 < maxRows) { - for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + for ( + let rowIdx = tableData.length + 2; + rowIdx <= maxRows; + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; } } } - }) + }); // 5. 컬럼 너비 자동 조정 columnsJSON.forEach((col, idx) => { - const column = worksheet.getColumn(idx + 1) + const column = worksheet.getColumn(idx + 1); // 최적 너비 계산 - let maxLength = col.label.length - tableData.forEach(row => { - const value = row[col.key] + let maxLength = col.label.length; + tableData.forEach((row) => { + const value = row[col.key]; if (value !== undefined && value !== null) { - const valueLength = String(value).length + const valueLength = String(value).length; if (valueLength > maxLength) { - maxLength = valueLength + maxLength = valueLength; } } - }) + }); // 너비 설정 (최소 10, 최대 50) - column.width = Math.min(Math.max(maxLength + 2, 10), 50) - }) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); // 6. 파일 다운로드 - const buffer = await workbook.xlsx.writeBuffer() - saveAs(new Blob([buffer]), `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`) + const buffer = await workbook.xlsx.writeBuffer(); + saveAs( + new Blob([buffer]), + `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` + ); - toast.success("Excel 내보내기 완료!") + toast.success("Excel 내보내기 완료!"); } catch (err) { - console.error("Excel export error:", err) - toast.error("Excel 내보내기 실패.") + console.error("Excel export error:", err); + toast.error("Excel 내보내기 실패."); } finally { - setIsPending(false) + setIsPending(false); } } @@ -478,13 +521,34 @@ export default function DynamicTable({ data={tableData} columns={columns} advancedFilterFields={advancedFilterFields} - // tableRef={tableRef} + // tableRef={tableRef} > {/* 버튼 그룹 */}
{/* 태그 불러오기 버튼 */} - + + @@ -503,7 +567,12 @@ export default function DynamicTable({ {/* EXPORT 버튼 (새로 추가) */} - @@ -533,13 +602,48 @@ export default function DynamicTable({ { - if (!open) setRowAction(null) + if (!open) setRowAction(null); }} columns={columnsJSON} rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} /> + {tempUpDialog && ( + + )} + + {reportData.length > 0 && ( + + )} + + {batchDownDialog && ( + + )} - ) -} \ No newline at end of file + ); +} + + -- cgit v1.2.3 From c4c3f12b4a6d1a3c09b797e1a160747cac8761e9 Mon Sep 17 00:00:00 2001 From: rlaks5757 Date: Fri, 28 Mar 2025 11:27:25 +0900 Subject: template file download 개발 완료 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../form-data/form-data-report-batch-dialog.tsx | 13 +- components/form-data/form-data-report-dialog.tsx | 139 ++------- .../form-data-report-temp-upload-dialog.tsx | 340 ++++++++++++++++++--- components/form-data/form-data-table.tsx | 47 +-- db/schema/vendorData.ts | 19 -- lib/forms/services.ts | 53 +++- .../sample_template_file.docx | Bin 0 -> 24773 bytes 7 files changed, 399 insertions(+), 212 deletions(-) create mode 100644 public/vendorFormReportSample/sample_template_file.docx (limited to 'components/form-data/form-data-table.tsx') diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx index 614f890e..6c690363 100644 --- a/components/form-data/form-data-report-batch-dialog.tsx +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -8,8 +8,10 @@ import React, { useEffect, } from "react"; import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage} from "sonner"; import prettyBytes from "pretty-bytes"; import { X, Loader2 } from "lucide-react"; +import { saveAs } from 'file-saver'; import { Badge } from "@/components/ui/badge"; import { Dialog, @@ -159,15 +161,14 @@ export const FormDataReportBatchDialog: FC = ({ if (reqeustCreateReport.ok) { const blob = await reqeustCreateReport.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${formCode}.pdf`; - a.click(); - window.URL.revokeObjectURL(url); + + saveAs(blob, `${formCode}.pdf`); + + toastMessage.success("Report 다운로드 완료!") } else { const err = await reqeustCreateReport.json(); console.error("에러:", err); + throw new Error(err.message) } } catch (err) { console.error(err); diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index deb0873b..e28b4345 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -8,11 +8,10 @@ import React, { useEffect, useRef, } from "react"; -import { WebViewerInstance, Core } from "@pdftron/webviewer"; -import { useToast } from "@/hooks/use-toast"; -import prettyBytes from "pretty-bytes"; -import { X, Loader2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; +import { WebViewerInstance } from "@pdftron/webviewer"; +import { Loader2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; import { Dialog, DialogContent, @@ -25,11 +24,11 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, - SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; + import { Button } from "@/components/ui/button"; import { getReportTempList } from "@/lib/forms/services"; import { DataTableColumnJSON } from "./form-data-table-columns"; @@ -58,7 +57,9 @@ export const FormDataReportDialog: FC = ({ setReportData, packageId, formId, + formCode, }) => { + const [tempList, setTempList] = useState([]); const [selectTemp, setSelectTemp] = useState(""); const [instance, setInstance] = useState(null); @@ -92,46 +93,9 @@ export const FormDataReportDialog: FC = ({ // }, }); - const blob = new Blob([fileData], { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - }); + saveAs(new Blob([fileData]), fileName); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // const allTabs = UI.TabManager.getAllTabs() as { - // id: number; - // src: Core.Document; - // }[]; - - // for (const tab of allTabs) { - // // await UI.TabManager.setActiveTab(tab.id); - // await activateTabAndWaitForLoad(instance, tab.id); - // const tabDoc = tab.src; - // const fileName = tabDoc.getFilename(); - - // const fileData = await tabDoc.getFileData({ - // includeAnnotations: true, - // }); - - // console.log({ fileData }); - - // const blob = new Blob([fileData], { - // type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - // }); - - // // 다운로드 - // // const link = document.createElement("a"); - // // link.href = URL.createObjectURL(blob); - // // link.download = fileName; - // // document.body.appendChild(link); - // // link.click(); - // // document.body.removeChild(link); - // } + toast.success("Report 다운로드 완료!"); } }; @@ -175,6 +139,7 @@ export const FormDataReportDialog: FC = ({ instance={instance} setInstance={setInstance} setFileLoading={setFileLoading} + formCode={formCode} />
@@ -195,6 +160,7 @@ interface ReportWebViewerProps { instance: null | WebViewerInstance; setInstance: Dispatch>; setFileLoading: Dispatch>; + formCode: string; } const ReportWebViewer: FC = ({ @@ -204,6 +170,7 @@ const ReportWebViewer: FC = ({ instance, setInstance, setFileLoading, + formCode, }) => { const [viwerLoading, setViewerLoading] = useState(true); const viewer = useRef(null); @@ -234,12 +201,6 @@ const ReportWebViewer: FC = ({ viewer.current as HTMLDivElement ).then(async (instance: WebViewerInstance) => { setInstance(instance); - // //Tab 메뉴 사용 필요시 활성화 - // instance.UI.enableFeatures([instance.UI.Feature.MultiTab]); - // instance.UI.disableElements([ - // "addTabButton", - // "multiTabsEmptyPage", - // ]); setViewerLoading(false); }); }); @@ -262,9 +223,10 @@ const ReportWebViewer: FC = ({ instance, reportDatas, reportTempPath, - setFileLoading + setFileLoading, + formCode ); - }, [reportTempPath, reportDatas, instance, columnsJSON]); + }, [reportTempPath, reportDatas, instance, columnsJSON, formCode]); return (
@@ -319,7 +281,8 @@ type ImportReportData = ( instance: null | WebViewerInstance, reportDatas: ReportData[], reportTempPath: string, - setFileLoading: Dispatch> + setFileLoading: Dispatch>, + formCode: string ) => void; const importReportData: ImportReportData = async ( @@ -327,7 +290,8 @@ const importReportData: ImportReportData = async ( instance, reportDatas, reportTempPath, - setFileLoading + setFileLoading, + formCode ) => { setFileLoading(true); try { @@ -352,12 +316,13 @@ const importReportData: ImportReportData = async ( }); const doc = await createDocument(reportFileBlob, { + filename: `${formCode}_report.docx`, extension: "docx", }); await doc.applyTemplateValues(reportValueMapping); - documentViewer.loadDocument(doc, { + documentViewer.loadDocument(doc, { extension: "docx", enableOfficeEditing: true, officeOptions: { @@ -373,68 +338,6 @@ const importReportData: ImportReportData = async ( } }; -const importReportDataTab: ImportReportData = async ( - columnJSON, - instance, - reportDatas, - reportTempPath, - setFileLoading -) => { - setFileLoading(true); - try { - if (instance && reportDatas.length > 0 && reportTempPath.length > 0) { - const { UI, Core } = instance; - const { createDocument } = Core; - - const getFileData = await fetch(reportTempPath); - const reportFileBlob = await getFileData.blob(); - - const prevTab = UI.TabManager.getAllTabs(); - - (prevTab as object[] as { id: number }[]).forEach((c) => { - const { id } = c; - UI.TabManager.deleteTab(id); - }); - - const fileOptions = reportDatas.map((c) => { - const { tagNumber } = c; - - const options = { - filename: `${tagNumber}_report.docx`, - }; - - return { options, reportData: c }; - }); - - const tabIds = []; - - for (const fileOption of fileOptions) { - let doc = await createDocument(reportFileBlob, { - ...fileOption.options, - extension: "docx", - }); - - await doc.applyTemplateValues( - stringifyAllValues(fileOption.reportData) - ); - - const tab = await UI.TabManager.addTab(doc, { - ...fileOption.options, - }); - - tabIds.push(tab); // 탭 ID 저장 - } - - if (tabIds.length > 0) { - await UI.TabManager.setActiveTab(tabIds[0]); - } - } - } catch (err) { - } finally { - setFileLoading(false); - } -}; - type UpdateReportTempList = ( packageId: number, formId: number, diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx index 413c1e51..69df704e 100644 --- a/components/form-data/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -8,8 +8,11 @@ import React, { useEffect, } from "react"; import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; import prettyBytes from "pretty-bytes"; -import { X, Loader2, Download } from "lucide-react"; +import { X, Loader2, Download, Delete, Trash2 } from "lucide-react"; +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; import { Badge } from "@/components/ui/badge"; import { Dialog, @@ -40,10 +43,28 @@ import { FileListItem, FileListName, } from "@/components/ui/file-list"; -import { getReportTempList, uploadReportTemp } from "@/lib/forms/services"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + getReportTempList, + uploadReportTemp, + getReportTempFileData, + deleteReportTempFile, +} from "@/lib/forms/services"; import { VendorDataReportTemps } from "@/db/schema/vendorData"; +import { DataTableColumnJSON } from "./form-data-table-columns"; interface FormDataReportTempUploadDialogProps { + columnsJSON: DataTableColumnJSON[]; open: boolean; setOpen: Dispatch>; packageId: number; @@ -57,7 +78,15 @@ const MAX_FILE_SIZE = 3000000; export const FormDataReportTempUploadDialog: FC< FormDataReportTempUploadDialogProps -> = ({ open, setOpen, packageId, formId, uploaderType }) => { +> = ({ + columnsJSON, + open, + setOpen, + packageId, + formId, + formCode, + uploaderType, +}) => { const { toast } = useToast(); const [selectedFiles, setSelectedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); @@ -131,29 +160,106 @@ export const FormDataReportTempUploadDialog: FC< } }; + const downloadTempFile = async () => { + try { + const { fileName, fileType, base64 } = await getReportTempFileData(); + + saveAs(`data:${fileType};base64,${base64}`, fileName); + + toastMessage.success("Report Sample File 다운로드 완료!"); + } catch (err) { + console.log(err); + toast({ + title: "Error", + description: "Sample File을 찾을 수가 없습니다.", + variant: "destructive", + }); + } + }; + + const downloadReportVarList = async () => { + try { + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data"); + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 + + // 1. 데이터 시트에 헤더 추가 + const headers = ["Table Column Label", "Report Variable"]; + worksheet.addRow(headers); + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); + + // 2. 데이터 행 추가 + columnsJSON.forEach((row) => { + const { displayLabel, label } = row; + + const labelConvert = label.replaceAll(" ", "_"); + + worksheet.addRow([displayLabel, labelConvert]); + }); + + // 3. 컬럼 너비 자동 조정 + headers.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1); + + // 최적 너비 계산 + let maxLength = col.length; + columnsJSON.forEach((row) => { + const valueKey = idx === 0 ? "displayLabel" : "label"; + + const value = row[valueKey]; + if (value !== undefined && value !== null) { + const valueLength = String(value).length; + if (valueLength > maxLength) { + maxLength = valueLength; + } + } + }); + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([buffer]), `${formCode}_report_varible_list.xlsx`); + toastMessage.success("Report Varible List File 다운로드 완료!"); + } catch (err) { + console.log(err); + toast({ + title: "Error", + description: "Variable List 파일을 찾을 수가 없습니다.", + variant: "destructive", + }); + } + }; + return ( Report Template Upload - 사용하시고자 하는 Report Template(docx File)를 업로드 하여주시기 + 사용하시고자 하는 Report Template(.docx)를 업로드 하여주시기 바랍니다. - {/* {prevReportTemp.length > 0 && ( - <> - - - {prevReportTemp.map((c, i) => { - return
{i}
; - })} -
- - )} */} -
+
@@ -163,10 +269,19 @@ export const FormDataReportTempUploadDialog: FC< sample_template_file.docx - removeFile(index)} - // disabled={isUploading} - > + + + Download + + + + + + + + report_variable_list.xlsx + + Download @@ -174,36 +289,48 @@ export const FormDataReportTempUploadDialog: FC<
+
+ + + updateReportTempList(packageId, formId, setPrevReportTemp) + } + /> +
- - {({ maxSize }) => ( - <> - - -
- -
- 파일을 여기에 드롭하세요 - - 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} - {maxSize ? prettyBytes(maxSize) : "무제한"} - +
+ + + {({ maxSize }) => ( + <> + + +
+ +
+ 파일을 여기에 드롭하세요 + + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + +
-
- - - - )} - + + + + )} + +
{selectedFiles.length > 0 && (
@@ -247,7 +374,7 @@ const UploadFileItem: FC = ({ isUploading, }) => { return ( - + {selectedFiles.map((file, index) => ( @@ -327,3 +454,116 @@ const updateReportTempList: UpdateReportTempList = async ( const tempList = await getReportTempList(packageId, formId); setPrevReportTemp(tempList); }; + +interface UploadedTempFiles { + prevReportTemp: VendorDataReportTemps[]; + updateReportTempList: () => void; +} + +const UploadedTempFiles: FC = ({ + prevReportTemp, + updateReportTempList, +}) => { + const { toast } = useToast(); + + const downloadTempFile = async (fileName: string, filePath: string) => { + try { + const getTempFile = await fetch(filePath); + + if (getTempFile.ok) { + const blob = await getTempFile.blob(); + + saveAs(blob, fileName); + + toastMessage.success("Report 다운로드 완료!"); + } else { + const err = await getTempFile.json(); + console.error("에러:", err); + throw new Error(err.message); + } + + toastMessage.success("Template File 다운로드 완료!"); + } catch (err) { + console.error(err) + toast({ + title: "Error", + description: "Template File 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + const deleteTempFile = async (id: number) => { + try { + const { result, error } = await deleteReportTempFile(id); + + if (result) { + updateReportTempList(); + toastMessage.success("Template File 삭제 완료!"); + } else { + throw new Error(error); + } + } catch (err) { + toast({ + title: "Error", + description: "Template File 삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + return ( + + + {prevReportTemp.map((c) => { + const { fileName, filePath, id } = c; + + return ( + + + + + + {fileName} + + { + downloadTempFile(fileName, filePath); + }} + > + + Download + + + + + Delete + + + + + + Report Templete File({fileName})을 삭제하시겠습니까? + + + + + 취소 + { + deleteTempFile(id); + }} + > + 삭제 + + + + + + + ); + })} + + + ); +}; diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 50c4f267..823416c1 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -11,19 +11,22 @@ import { DataTableColumnJSON, ColumnType, } from "./form-data-table-columns"; - import type { DataTableAdvancedFilterField } from "@/types/table"; import { Button } from "../ui/button"; import { Download, Loader, Save, Upload } from "lucide-react"; import { toast } from "sonner"; import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"; import { UpdateTagSheet } from "./update-form-sheet"; - import ExcelJS from "exceljs"; import { saveAs } from "file-saver"; import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; import { FormDataReportDialog } from "./form-data-report-dialog"; import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; interface GenericData { [key: string]: any; @@ -526,20 +529,29 @@ export default function DynamicTable({ {/* 버튼 그룹 */}
{/* 태그 불러오기 버튼 */} - - + + + + + + + + +
- +