diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-21 06:57:36 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-21 06:57:36 +0000 |
| commit | 02b1cf005cf3e1df64183d20ba42930eb2767a9f (patch) | |
| tree | e932c54d5260b0e6fda2b46be2a6ba1c3ee30434 /components | |
| parent | d78378ecd7ceede1429359f8058c7a99ac34b1b7 (diff) | |
(대표님, 최겸) 설계메뉴추가, 작업사항 업데이트
설계메뉴 - 문서관리
설계메뉴 - 벤더 데이터
gtc 메뉴 업데이트
정보시스템 - 메뉴리스트 및 정보 업데이트
파일 라우트 업데이트
엑셀임포트 개선
기본계약 개선
벤더 가입과정 변경 및 개선
벤더 기본정보 - pq
돌체 오류 수정 및 개선
벤더 로그인 과정 이메일 오류 수정
Diffstat (limited to 'components')
20 files changed, 5490 insertions, 424 deletions
diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index d9f5052e..ca0c60d5 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -192,6 +192,8 @@ export function InfoForm() { vendorName: "", taxId: "", address: "", + addressDetail: "", + postalCode: "", email: "", phone: "", country: "", @@ -286,6 +288,8 @@ export function InfoForm() { vendorName: vendorData.vendorName || "", taxId: vendorData.taxId || "", address: vendorData.address || "", + addressDetail: vendorData.addressDetail || "", + postalCode: vendorData.postalCode || "", email: vendorData.email || "", phone: vendorData.phone || "", country: vendorData.country || "", @@ -639,6 +643,8 @@ export function InfoForm() { vendorName: values.vendorName, website: values.website, address: values.address, + addressDetail: values.addressDetail, + postalCode: values.postalCode, email: values.email, phone: values.phone, country: values.country, @@ -1105,7 +1111,9 @@ export function InfoForm() { name="address" render={({ field }) => ( <FormItem> - <FormLabel>주소</FormLabel> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 주소 + </FormLabel> <FormControl> <Input {...field} disabled={isSubmitting} /> </FormControl> @@ -1114,6 +1122,36 @@ export function InfoForm() { )} /> + {/* Address Detail */} + <FormField + control={form.control} + name="addressDetail" + render={({ field }) => ( + <FormItem> + <FormLabel>상세주소</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} placeholder="상세주소를 입력해주세요" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Postal Code */} + <FormField + control={form.control} + name="postalCode" + render={({ field }) => ( + <FormItem> + <FormLabel>우편번호</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} placeholder="우편번호를 입력해주세요" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField control={form.control} name="phone" diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx index b689adab..592933f2 100644 --- a/components/data-table/data-table-view-options.tsx +++ b/components/data-table/data-table-view-options.tsx @@ -80,12 +80,14 @@ export function DataTableViewOptions<TData>({ const hideableCols = React.useMemo(() => { return table .getAllLeafColumns() - .filter((col) => col.getCanHide()) - }, [table]) + .filter((col) => col.getCanHide()) + }, [table.getAllLeafColumns().map(c => c.id).join(',')]) + // 2) local state for "columnOrder" (just the ID of hideable columns) // We'll reorder these with drag & drop + const isInitialized = React.useRef(false) const [columnOrder, setColumnOrder] = React.useState<string[]>(() => hideableCols.map((c) => c.id) ) @@ -107,21 +109,24 @@ export function DataTableViewOptions<TData>({ // 4) After local state changes, reflect in tanstack table // - We do this in useEffect to avoid "update a different component" error React.useEffect(() => { - // Also consider "non-hideable" columns, if any, to keep them in original positions + if (!isInitialized.current) { + isInitialized.current = true + return + } + const nonHideable = table .getAllColumns() .filter((col) => !hideableCols.some((hc) => hc.id === col.id)) .map((c) => c.id) - // e.g. place nonHideable at the front, then our local hideable order const finalOrder = [...nonHideable, ...columnOrder] + const currentOrder = table.getState().columnOrder - // Now we set the table's official column order - if (!deepEqual(table.getState().columnOrder, finalOrder)) { - table.setColumnOrder(finalOrder) - resetAutoSize?.() - } - }, [columnOrder, hideableCols.join("|"), table, resetAutoSize]) + if (!deepEqual(currentOrder, finalOrder)) { + table.setColumnOrder(finalOrder) + resetAutoSize?.() + } + }, [columnOrder,hideableCols.map(c => c.id).join(','),resetAutoSize]) return ( diff --git a/components/form-data/export-excel-form.tsx b/components/form-data/export-excel-form.tsx index 64e9ea3d..1efa5819 100644 --- a/components/form-data/export-excel-form.tsx +++ b/components/form-data/export-excel-form.tsx @@ -11,7 +11,7 @@ export interface DataTableColumnJSON { label: string; type: ColumnType; options?: string[]; - shi?: boolean; // SHI-only field indicator + shi?: string | null; // Updated to support both string and boolean for backward compatibility required?: boolean; // Required field indicator // Add any other properties that might be in columnsJSON } @@ -39,6 +39,7 @@ export interface ExportExcelOptions { tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode: string; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 onPendingChange?: (isPending: boolean) => void; validateData?: boolean; // Option to enable/disable data validation } @@ -52,6 +53,63 @@ export interface ExportExcelResult { } /** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map<string, string[]> +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + +/** + * Get the read-only reason for a field + */ +function getReadOnlyReason( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map<string, string[]> +): string { + if (column.shi === "OUT" || column.shi === null) { + return "SHI-only field"; + } + + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") { + return "System field"; + } + + if (!editableFieldsMap || editableFieldsMap.size === 0) { + return "No restrictions"; + } + + if (!editableFieldsMap.has(tagNo)) { + return "No editable fields for this TAG"; + } + + const editableFields = editableFieldsMap.get(tagNo) || []; + if (!editableFields.includes(column.key)) { + return "Not editable for this TAG"; + } + + return "Editable"; +} + +/** * Validate data and collect errors */ function validateTableData( @@ -276,6 +334,7 @@ export async function exportExcelData({ tableData, columnsJSON, formCode, + editableFieldsMap = new Map(), // 새로 추가 onPendingChange, validateData = true }: ExportExcelOptions): Promise<ExportExcelResult> { @@ -346,7 +405,7 @@ export async function exportExcelData({ const columnIndex = colNumber - 1; const column = columnsJSON[columnIndex]; - if (column?.shi === true) { + if (column?.shi === "OUT" || column?.shi === null ) { // SHI-only 필드는 더 진한 음영으로 표시 cell.fill = { type: "pattern", @@ -384,24 +443,53 @@ export async function exportExcelData({ const rowErrors = errors.filter(err => err.rowIndex === rowIndex + 2); const hasErrors = rowErrors.length > 0; - // SHI-only 컬럼의 데이터 셀에도 음영 적용 + // 각 데이터 셀에 적절한 스타일 적용 dataRow.eachCell((cell, colNumber) => { const columnIndex = colNumber - 1; const column = columnsJSON[columnIndex]; + const tagNo = rowData.TAG_NO || ""; // Check if this cell has errors const cellHasError = rowErrors.some(err => err.columnKey === column.key); - if (column?.shi === true) { - // SHI-only 필드의 데이터 셀에 연한 음영 적용 + // Check if this field is editable for this specific TAG_NO + const fieldEditable = isFieldEditable(column, tagNo, editableFieldsMap); + const readOnlyReason = getReadOnlyReason(column, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // Read-only field styling + let bgColor = "FFFFCCCC"; // Default light red for read-only + let fontColor = "FF666666"; // Gray text + + if (column?.shi === "OUT" || column?.shi === null ) { + // SHI-only fields get a more distinct styling + bgColor = cellHasError ? "FFFF6666" : "FFFFCCCC"; // Darker red if error + fontColor = "FF800000"; // Dark red text + } else { + // Other read-only fields (editableFieldsMap restrictions) + bgColor = cellHasError ? "FFFFAA99" : "FFFFDDCC"; // Orange-ish tint + fontColor = "FF996633"; // Brown text + } + cell.fill = { type: "pattern", pattern: "solid", - fgColor: { argb: cellHasError ? "FFFF6666" : "FFFFCCCC" }, // 에러가 있으면 더 진한 빨간색 + fgColor: { argb: bgColor }, }; - cell.font = { italic: true, color: { argb: "FF666666" } }; + cell.font = { italic: true, color: { argb: fontColor } }; + + // Add comment to explain why it's read-only + if (readOnlyReason !== "Editable") { + cell.note = { + texts: [{ text: `Read-only: ${readOnlyReason}` }], + margins: { + insetmode: "custom", + inset: [0.13, 0.13, 0.25, 0.25] + } + }; + } } else if (cellHasError) { - // 에러가 있는 셀은 연한 빨간색 배경 + // Editable field with validation error cell.fill = { type: "pattern", pattern: "solid", @@ -409,6 +497,7 @@ export async function exportExcelData({ }; cell.font = { color: { argb: "FFCC0000" } }; } + // If field is editable and has no errors, no special styling needed }); }); @@ -418,8 +507,8 @@ export async function exportExcelData({ columnsJSON.forEach((col, idx) => { const colLetter = worksheet.getColumn(idx + 1).letter; - // SHI-only 필드가 아닌 LIST 타입에만 유효성 검사 적용 - if (col.type === "LIST" && validationRanges.has(col.key) && col.shi !== true) { + // LIST 타입이고 유효성 검사 범위가 있는 경우에만 적용 + if (col.type === "LIST" && validationRanges.has(col.key)) { const validationRange = validationRanges.get(col.key)!; // 유효성 검사 정의 @@ -439,25 +528,34 @@ export async function exportExcelData({ rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++ ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; + const cell = worksheet.getCell(`${colLetter}${rowIdx}`); + + // Only apply validation to editable cells + const rowData = tableData[rowIdx - 2]; // rowIdx is 1-based, data array is 0-based + if (rowData) { + const tagNo = rowData.TAG_NO || ""; + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (fieldEditable) { + cell.dataValidation = validation; + } + } } - // 빈 행에도 적용 (최대 maxRows까지) + // 빈 행에도 적용 (최대 maxRows까지) - 기본적으로 편집 가능하다고 가정 if (tableData.length + 1 < maxRows) { for ( let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++ ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation; } } } - // SHI-only 필드의 빈 행들에도 음영 처리 적용 - if (col.shi === true) { + // Read-only 필드의 빈 행들에도 음영 처리 적용 (기본적으로 SHI-only 필드에만) + if (col.shi === "OUT" || col.shi === null ) { for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { const cell = worksheet.getCell(`${colLetter}${rowIdx}`); cell.fill = { @@ -503,8 +601,9 @@ export async function exportExcelData({ legendSheet.addRow(["Red background header", "SHI-only fields that cannot be edited"]); legendSheet.addRow(["Blue background header", "Required fields (marked with *)"]); legendSheet.addRow(["Gray background header", "Regular optional fields"]); - legendSheet.addRow(["Light red background cells", "Cells with validation errors"]); - legendSheet.addRow(["Light red data cells", "Data in SHI-only fields (read-only)"]); + legendSheet.addRow(["Light red background cells", "Cells with validation errors OR SHI-only fields"]); + legendSheet.addRow(["Light orange background cells", "Fields not editable for specific TAG (based on editableFieldsMap)"]); + legendSheet.addRow(["Cell comments", "Hover over read-only cells to see the reason why they cannot be edited"]); if (errors.length > 0) { legendSheet.addRow([]); @@ -512,6 +611,25 @@ export async function exportExcelData({ const errorNoteRow = legendSheet.getRow(legendSheet.rowCount); errorNoteRow.font = { bold: true, color: { argb: "FFCC0000" } }; } + + // Add editableFieldsMap summary if available + if (editableFieldsMap.size > 0) { + legendSheet.addRow([]); + legendSheet.addRow([`Editable Fields Map Summary (${editableFieldsMap.size} TAGs):`]); + const summaryHeaderRow = legendSheet.getRow(legendSheet.rowCount); + summaryHeaderRow.font = { bold: true, color: { argb: "FF000080" } }; + + // Show first few examples + let count = 0; + for (const [tagNo, editableFields] of editableFieldsMap) { + if (count >= 5) { // Show only first 5 examples + legendSheet.addRow([`... and ${editableFieldsMap.size - 5} more TAGs`]); + break; + } + legendSheet.addRow([`${tagNo}:`, editableFields.join(", ")]); + count++; + } + } // 범례 스타일 적용 const legendHeaderRow = legendSheet.getRow(1); diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index 2f623bdb..598b66c6 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -41,7 +41,7 @@ export interface DataTableColumnJSON { options?: string[]; uom?: string; uomId?: string; - shi?: boolean; + shi?: string; /** 템플릿에서 가져온 추가 정보 */ hidden?: boolean; // true이면 컬럼 숨김 diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 4f101b45..c9632c8c 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -509,7 +509,7 @@ export default function DynamicTable({ columnsJSON, formCode, contractItemId, - // editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 + editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 onPendingChange: setIsImporting, // Let importExcelData handle loading state onDataUpdate: (newData) => { setTableData(Array.isArray(newData) ? newData : newData(tableData)); @@ -648,6 +648,7 @@ export default function DynamicTable({ tableData, columnsJSON, formCode, + editableFieldsMap, onPendingChange: setIsExporting }); } finally { diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx index e3ac9e0e..637b0ccf 100644 --- a/components/form-data/import-excel-form.tsx +++ b/components/form-data/import-excel-form.tsx @@ -17,13 +17,14 @@ export interface ImportError { expectedFormat?: string; } -// Simplified options interface without editableFieldsMap +// Updated options interface with editableFieldsMap export interface ImportExcelOptions { file: File; tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode?: string; contractItemId?: number; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 onPendingChange?: (isPending: boolean) => void; onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; } @@ -42,6 +43,7 @@ export interface ExportExcelOptions { tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode: string; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 onPendingChange?: (isPending: boolean) => void; } @@ -50,6 +52,31 @@ interface GenericData { } /** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map<string, string[]> +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + +/** * Create error sheet with import validation results */ function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[], headerErrors?: string[]) { @@ -156,6 +183,9 @@ function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[ case "HEADER_MISMATCH": bgColor = "FFFFE0E0"; // Very light red break; + case "READ_ONLY_FIELD": + bgColor = "FFF0F0F0"; // Light gray + break; } cell.fill = { @@ -188,6 +218,7 @@ export async function importExcelData({ columnsJSON, formCode, contractItemId, + editableFieldsMap = new Map(), // 새로 추가 onPendingChange, onDataUpdate }: ImportExcelOptions): Promise<ImportExcelResult> { @@ -321,8 +352,11 @@ export async function importExcelData({ const colIndex = keyToIndexMap.get(col.key); if (colIndex === undefined) return; - // Check if this is a SHI-only field (skip processing but preserve existing value) - if (col.shi === true) { + // Check if this field is editable for this TAG_NO + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // If field is not editable, preserve existing value if (existingRowData && existingRowData[col.key] !== undefined) { rowObj[col.key] = existingRowData[col.key]; } else { @@ -339,8 +373,36 @@ export async function importExcelData({ } } + // Determine skip reason + let skipReason = ""; + if (col.shi === "OUT" || col.shi === null) { + skipReason = "SHI-only field"; + } else if (col.key === "TAG_NO" || col.key === "TAG_DESC" || col.key === "status") { + skipReason = "System field"; + } else { + skipReason = "Not editable for this TAG"; + } + // Log skipped field - skippedFields.push(`${col.label} (SHI-only field)`); + skippedFields.push(`${col.label} (${skipReason})`); + + // Check if Excel contains a value for a read-only field and warn + const cellValue = rowValues[colIndex] ?? ""; + const stringVal = String(cellValue).trim(); + if (stringVal && existingRowData && String(existingRowData[col.key] || "").trim() !== stringVal) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "READ_ONLY_FIELD", + errorMessage: `Attempting to modify read-only field. ${skipReason}.`, + currentValue: stringVal, + expectedFormat: `Field is read-only. Current value: ${existingRowData[col.key] || "empty"}`, + }); + hasErrors = true; + } + return; // Skip processing Excel value for this column } @@ -419,7 +481,7 @@ export async function importExcelData({ const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); console.log("Skipped fields summary:", skippedFieldsLog); toast.info( - `${totalSkippedFields} SHI-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` + `${totalSkippedFields} read-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` ); } @@ -537,7 +599,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Successfully updated ${successCount} rows (SHI-only fields were preserved)` + ? `Successfully updated ${successCount} rows (read-only fields were preserved)` : `Successfully updated ${successCount} rows`; toast.success(successMessage); @@ -567,7 +629,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Imported ${importedData.length} rows successfully (SHI-only fields preserved)` + ? `Imported ${importedData.length} rows successfully (read-only fields preserved)` : `Imported ${importedData.length} rows successfully`; toast.success(`${successMessage} (local only)`); diff --git a/components/form-data/spreadJS-dialog copy 4.tsx b/components/form-data/spreadJS-dialog copy 4.tsx new file mode 100644 index 00000000..14f4d3ea --- /dev/null +++ b/components/form-data/spreadJS-dialog copy 4.tsx @@ -0,0 +1,1491 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms/services"; +import { Loader, Save, AlertTriangle } from "lucide-react"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; +import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; + +const SpreadSheets = dynamic( + () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), + { + ssr: false, + loading: () => ( + <div className="flex items-center justify-center h-full"> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading SpreadSheets... + </div> + ) + } +); + +if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array<string>; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; + onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( + <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]"> + <div className="flex items-center space-x-3 mb-4"> + <Loader className="h-5 w-5 animate-spin text-blue-600" /> + <span className="font-medium text-gray-900">Loading Template</span> + </div> + + <div className="space-y-2"> + <div className="text-sm text-gray-600">{phase}</div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out" + style={{ width: `${percentage}%` }} + /> + </div> + <div className="text-xs text-gray-500 text-right"> + {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) + </div> + </div> + </div> + </div> + ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState<any>(null); + const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); + const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + if (!templateData) return; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + const validTemplates = templates.filter(isValidTemplate); + setAvailableTemplates(validTemplates); + + if (validTemplates.length > 0 && !selectedTemplateId) { + const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); + + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (template) { + const templateTypeToSet = determineTemplateType(template); + setSelectedTemplateId(templateId); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + if (currentSpread && template) { + initSpread(currentSpread, template); + } + } + }; + + const selectedTemplate = React.useMemo(() => { + return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); + }, [availableTemplates, selectedTemplateId]); + + const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map<number, Array<{row: number, value: any}>>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#bbf7d0"; + } else { + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; + } + return style; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + const editableStyle = createCellStyle(true); + const readonlyStyle = createCellStyle(false); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = isEditable ? editableStyle : readonlyStyle; + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + return { row, col }; + }; + + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + break; + default: + break; + } + + return null; + }; + + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); + + + + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { + try { + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } + + const optionsString = safeOptions.join(','); + + for (let i = 0; i < rowCount; i++) { + try { + const targetRow = cellPos.row + i; + + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // 🔧 DataValidation 설정 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) +const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } + } + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } + } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); + } + } + } + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); + } + } + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; +}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 + } + + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); + + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { + const dataRowIndex = exactMapping.dataRowIndex; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null ) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + info.cancel = true; + return; + } + } + } + + console.log(`✅ Edit allowed for ${exactMapping.attId}`); + }); + + // ✅ 편집 완료 검증 + activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (errorMessage) { + // 🚨 에러 스타일 적용 + const errorStyle = new GC.Spread.Sheets.Style(); + errorStyle.backColor = "#fef2f2"; + errorStyle.foreColor = "#dc2626"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + + activeSheet.setStyle(info.row, info.col, errorStyle); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized spread initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + + dataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized spread initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); + + const handleSaveChanges = React.useCallback(async () => { + if (!currentSpread || !hasChanges) { + toast.info("No changes to save"); + return; + } + + const errors = validateAllData(); + if (errors.length > 0) { + toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); + return; + } + + try { + setIsPending(true); + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + console.log('🔍 Starting batch save process...'); + + const updatedRows: GenericData[] = []; + let saveCount = 0; + let checkedCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + checkedCount++; + + // 🔧 isFieldEditable과 동일한 로직 사용 + const rowData = tableData[i]; + const fieldEditable = isFieldEditable(mapping.attId, rowData); + + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); + + if (fieldEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const originalValue = originalRow[mapping.attId]; + + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) + const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); + const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); + + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); + + if (normalizedCellValue !== normalizedOriginalValue) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + console.log(` ✅ Change detected for ${mapping.attId}`); + } + } + } + } + }); + + if (hasRowChanges) { + console.log(`💾 Saving row ${i} with changes`); + dataToSave.TAG_NO = originalRow.TAG_NO; + + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + console.log(`✅ Row ${i} saved successfully`); + } else { + console.error(`❌ Failed to save row ${i}: ${message}`); + toast.error(`Failed to save row ${i + 1}: ${message}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } catch (error) { + console.error(`❌ Error saving row ${i}:`, error); + toast.error(`Error saving row ${i + 1}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } else { + updatedRows.push(originalRow); + console.log(`ℹ️ No changes in row ${i}`); + } + } + + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); + toast.warning("No actual changes were found to save. Please check if the values were properly edited."); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [ + currentSpread, + hasChanges, + templateType, + selectedRow, + tableData, + formCode, + contractItemId, + onUpdateSuccess, + cellMappings, + columnsJSON, + validateAllData, + isFieldEditable // 🔧 의존성 추가 + ]); + + if (!isOpen) return null; + + const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; + const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent + className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" + > + <DialogHeader className="flex-shrink-0"> + <DialogTitle>SEDP Template - {formCode}</DialogTitle> + <DialogDescription> + <div className="space-y-3"> + {availableTemplates.length > 1 && ( + <div className="flex items-center gap-4"> + <span className="text-sm font-medium">Template:</span> + <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> + <SelectTrigger className="w-64"> + <SelectValue placeholder="Select a template" /> + </SelectTrigger> + <SelectContent> + {availableTemplates.map(template => ( + <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> + {template.NAME} ({template.TMPL_TYPE}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {selectedTemplate && ( + <div className="flex items-center gap-4 text-sm"> + <span className="font-medium text-blue-600"> + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } + </span> + {templateType === 'SPREAD_ITEM' && selectedRow && ( + <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + <span>• {dataCount} rows</span> + )} + {hasChanges && ( + <span className="text-orange-600 font-medium"> + • Unsaved changes + </span> + )} + {validationErrors.length > 0 && ( + <span className="text-red-600 font-medium flex items-center"> + <AlertTriangle className="w-4 h-4 mr-1" /> + {validationErrors.length} validation errors + </span> + )} + </div> + )} + + <div className="flex items-center gap-4 text-xs"> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> + Editable fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> + Read-only fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> + Validation errors + </span> + {cellMappings.length > 0 && ( + <span className="text-blue-600"> + {editableFieldsCount} of {cellMappings.length} fields editable + </span> + )} + </div> + </div> + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden relative"> + {/* 🆕 로딩 프로그레스 오버레이 */} + <LoadingProgress + phase={loadingProgress?.phase || ''} + progress={loadingProgress?.progress || 0} + total={loadingProgress?.total || 100} + isVisible={isInitializing && !!loadingProgress} + /> + + {selectedTemplate && isClient && isDataValid ? ( + <SpreadSheets + key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} + workbookInitialized={initSpread} + hostStyle={hostStyle} + /> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {!isClient ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading... + </> + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} + </div> + )} + </div> + + <DialogFooter className="flex-shrink-0"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={onClose}> + Close + </Button> + + {hasChanges && ( + <Button + variant="default" + onClick={handleSaveChanges} + disabled={isPending || validationErrors.length > 0} + > + {isPending ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Saving... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + Save Changes + </> + )} + </Button> + )} + + {validationErrors.length > 0 && ( + <Button + variant="outline" + onClick={validateAllData} + className="text-red-600 border-red-300 hover:bg-red-50" + > + <AlertTriangle className="mr-2 h-4 w-4" /> + Check Errors ({validationErrors.length}) + </Button> + )} + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index a223a849..14f4d3ea 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -235,46 +235,79 @@ export function TemplateViewDialog({ }, [availableTemplates, selectedTemplateId]); const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { if (!editableFieldsMap.has(selectedRow.TAG_NO)) { return []; } return editableFieldsMap.get(selectedRow.TAG_NO) || []; } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); - if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { - const firstRowTagNo = tableData[0]?.TAG_NO; - if (firstRowTagNo && editableFieldsMap.has(firstRowTagNo)) { - return editableFieldsMap.get(firstRowTagNo) || []; - } - } + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } - return []; - }, [templateType, selectedRow?.TAG_NO, tableData, editableFieldsMap]); + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } - const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { - const columnConfig = columnsJSON.find(col => col.key === attId); - if (columnConfig?.shi === true) { + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { return false; } - - if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { return false; } - - if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - if (rowData && rowData.shi === true) { - return false; - } - return true; + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; } - return true; - }, [templateType, columnsJSON]); + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } - const editableFieldsCount = React.useMemo(() => { + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 return cellMappings.filter(m => m.isEditable).length; - }, [cellMappings]); + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); // 🚀 배치 처리 함수들 const setBatchValues = React.useCallback(( @@ -330,10 +363,10 @@ export function TemplateViewDialog({ const createCellStyle = React.useCallback((isEditable: boolean) => { const style = new GC.Spread.Sheets.Style(); if (isEditable) { - style.backColor = "#f0fdf4"; + style.backColor = "#bbf7d0"; } else { - style.backColor = "#f9fafb"; - style.foreColor = "#6b7280"; + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; } return style; }, []); @@ -569,153 +602,206 @@ export function TemplateViewDialog({ }, []); // 🚀 최적화된 GRD_LIST 생성 - const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { - console.log('🚀 Creating optimized GRD_LIST table'); - - const visibleColumns = columnsJSON - .filter(col => col.hidden !== true) - .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); - - if (visibleColumns.length === 0) return []; + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) +const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); - const startCol = 1; - const dataStartRow = 1; - const mappings: CellMapping[] = []; + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); - ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); - ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + if (visibleColumns.length === 0) return []; - // 헤더 생성 - const headerStyle = new GC.Spread.Sheets.Style(); - headerStyle.backColor = "#3b82f6"; - headerStyle.foreColor = "#ffffff"; - headerStyle.font = "bold 12px Arial"; - headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const cell = activeSheet.getCell(0, targetCol); - cell.value(column.label); - cell.locked(true); - activeSheet.setStyle(0, targetCol, headerStyle); - }); + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); - // 🚀 데이터 배치 처리 준비 - const allValues: Array<{row: number, col: number, value: any}> = []; - const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } + } - // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) - const dropdownConfigs: Array<{ - startRow: number; - col: number; - rowCount: number; - options: string[]; - editableRows: number[]; // 편집 가능한 행만 추적 - }> = []; + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - - // 드롭다운 설정을 위한 편집 가능한 행 찾기 - if (column.type === "LIST" && column.options) { - const editableRows: number[] = []; - tableData.forEach((rowData, rowIndex) => { - if (isFieldEditable(column.key, rowData)) { - editableRows.push(dataStartRow + rowIndex); - } - }); - - if (editableRows.length > 0) { - dropdownConfigs.push({ - startRow: dataStartRow, - col: targetCol, - rowCount: tableData.length, - options: column.options, - editableRows: editableRows - }); + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); } - } + }); - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; - const cellEditable = isFieldEditable(column.key, rowData); - const value = rowData[column.key]; - - mappings.push({ - attId: column.key, - cellAddress: getCellAddress(targetRow, targetCol), - isEditable: cellEditable, - dataRowIndex: rowIndex - }); - - allValues.push({ - row: targetRow, + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, col: targetCol, - value: value ?? null - }); - - allStyles.push({ - row: targetRow, - col: targetCol, - isEditable: cellEditable + rowCount: tableData.length, + options: column.options, + editableRows: editableRows }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable }); }); + }); - // 🚀 배치로 값과 스타일 설정 - setBatchValues(activeSheet, allValues); - setBatchStyles(activeSheet, allStyles); + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); - // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) - dropdownConfigs.forEach(({ col, options, editableRows }) => { - try { - console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); - - const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0) - .slice(0, 20); + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); - if (safeOptions.length === 0) return; + if (safeOptions.length === 0) return; - // 편집 가능한 행에만 드롭다운 적용 - editableRows.forEach(targetRow => { - try { - const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); - comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); - cellValidator.showInputMessage(false); - cellValidator.showErrorMessage(false); + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); - activeSheet.setCellType(targetRow, col, comboBoxCellType); - activeSheet.setDataValidator(targetRow, col, cellValidator); - - // 🚀 편집 권한 명시적 설정 - const cell = activeSheet.getCell(targetRow, col); - cell.locked(false); + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); - console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); - } catch (cellError) { - console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } + } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); } - }); - } catch (error) { - console.error(`❌ Dropdown config failed for column ${col}:`, error); + } } - }); + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); + } + } - setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); - - console.log(`✅ Optimized GRD_LIST created:`); - console.log(` - Total mappings: ${mappings.length}`); - console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); - console.log(` - Dropdown configs: ${dropdownConfigs.length}`); - - return mappings; - }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; +}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); @@ -840,7 +926,7 @@ export function TemplateViewDialog({ const dataRowIndex = exactMapping.dataRowIndex; if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { const rowData = tableData[dataRowIndex]; - if (rowData?.shi === true) { + if (rowData?.shi === "OUT" || rowData?.shi === null ) { console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); info.cancel = true; @@ -1092,20 +1178,20 @@ export function TemplateViewDialog({ toast.info("No changes to save"); return; } - + const errors = validateAllData(); if (errors.length > 0) { toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); return; } - + try { setIsPending(true); const activeSheet = currentSpread.getActiveSheet(); - + if (templateType === 'SPREAD_ITEM' && selectedRow) { const dataToSave = { ...selectedRow }; - + cellMappings.forEach(mapping => { if (mapping.isEditable) { const cellPos = parseCellAddress(mapping.cellAddress); @@ -1115,86 +1201,134 @@ export function TemplateViewDialog({ } } }); - + dataToSave.TAG_NO = selectedRow.TAG_NO; - + const { success, message } = await updateFormDataInDB( formCode, contractItemId, dataToSave ); - + if (!success) { toast.error(message); return; } - + toast.success("Changes saved successfully!"); onUpdateSuccess?.(dataToSave); - + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + console.log('🔍 Starting batch save process...'); + const updatedRows: GenericData[] = []; let saveCount = 0; - + let checkedCount = 0; + for (let i = 0; i < tableData.length; i++) { const originalRow = tableData[i]; const dataToSave = { ...originalRow }; let hasRowChanges = false; - + + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); + cellMappings.forEach(mapping => { if (mapping.dataRowIndex === i && mapping.isEditable) { - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - const isColumnEditable = columnConfig?.shi !== true; - const isRowEditable = originalRow.shi !== true; - - if (isColumnEditable && isRowEditable) { + checkedCount++; + + // 🔧 isFieldEditable과 동일한 로직 사용 + const rowData = tableData[i]; + const fieldEditable = isFieldEditable(mapping.attId, rowData); + + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); + + if (fieldEditable) { const cellPos = parseCellAddress(mapping.cellAddress); if (cellPos) { const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - if (cellValue !== originalRow[mapping.attId]) { + const originalValue = originalRow[mapping.attId]; + + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) + const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); + const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); + + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); + + if (normalizedCellValue !== normalizedOriginalValue) { dataToSave[mapping.attId] = cellValue; hasRowChanges = true; + console.log(` ✅ Change detected for ${mapping.attId}`); } } } } }); - + if (hasRowChanges) { + console.log(`💾 Saving row ${i} with changes`); dataToSave.TAG_NO = originalRow.TAG_NO; - const { success } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (success) { - updatedRows.push(dataToSave); - saveCount++; + + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + console.log(`✅ Row ${i} saved successfully`); + } else { + console.error(`❌ Failed to save row ${i}: ${message}`); + toast.error(`Failed to save row ${i + 1}: ${message}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } catch (error) { + console.error(`❌ Error saving row ${i}:`, error); + toast.error(`Error saving row ${i + 1}`); + updatedRows.push(originalRow); // 원본 데이터 유지 } } else { updatedRows.push(originalRow); + console.log(`ℹ️ No changes in row ${i}`); } } - + + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); + if (saveCount > 0) { toast.success(`${saveCount} rows saved successfully!`); onUpdateSuccess?.(updatedRows); } else { - toast.info("No changes to save"); + console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); + toast.warning("No actual changes were found to save. Please check if the values were properly edited."); } } - + setHasChanges(false); setValidationErrors([]); - + } catch (error) { console.error("Error saving changes:", error); toast.error("An unexpected error occurred while saving"); } finally { setIsPending(false); } - }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); + }, [ + currentSpread, + hasChanges, + templateType, + selectedRow, + tableData, + formCode, + contractItemId, + onUpdateSuccess, + cellMappings, + columnsJSON, + validateAllData, + isFieldEditable // 🔧 의존성 추가 + ]); if (!isOpen) return null; diff --git a/components/form-data/spreadJS-dialog_designer.tsx b/components/form-data/spreadJS-dialog_designer.tsx new file mode 100644 index 00000000..71d8ec08 --- /dev/null +++ b/components/form-data/spreadJS-dialog_designer.tsx @@ -0,0 +1,1404 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms/services"; +import { Loader, Save, AlertTriangle } from "lucide-react"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; +import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; + +const Designer = dynamic( + () => import("@mescius/spread-sheets-designer-react").then(mod => mod.Designer), + { + ssr: false, + loading: () => ( + <div className="flex items-center justify-center h-full"> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading Designer... + </div> + ) + } +); + +// 라이센스 키 설정 (두 개의 환경변수 사용) +if (typeof window !== 'undefined') { + if (process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; + // ExcelIO가 사용 가능한 경우에만 설정 + if (typeof (window as any).ExcelIO !== 'undefined') { + (window as any).ExcelIO.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; + } + } + + if (process.env.NEXT_PUBLIC_DESIGNER_LICENSE) { + // Designer 라이센스 키 설정 + if (GC.Spread.Sheets.Designer) { + GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE; + } + } +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array<string>; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; + onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( + <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]"> + <div className="flex items-center space-x-3 mb-4"> + <Loader className="h-5 w-5 animate-spin text-blue-600" /> + <span className="font-medium text-gray-900">Loading Template</span> + </div> + + <div className="space-y-2"> + <div className="text-sm text-gray-600">{phase}</div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out" + style={{ width: `${percentage}%` }} + /> + </div> + <div className="text-xs text-gray-500 text-right"> + {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) + </div> + </div> + </div> + </div> + ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState<any>(null); + const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); + const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + if (!templateData) return; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + const validTemplates = templates.filter(isValidTemplate); + setAvailableTemplates(validTemplates); + + if (validTemplates.length > 0 && !selectedTemplateId) { + const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); + + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (template) { + const templateTypeToSet = determineTemplateType(template); + setSelectedTemplateId(templateId); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + if (currentSpread && template) { + initSpread(currentSpread, template); + } + } + }; + + const selectedTemplate = React.useMemo(() => { + return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); + }, [availableTemplates, selectedTemplateId]); + + const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map<number, Array<{row: number, value: any}>>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#bbf7d0"; + } else { + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; + } + return style; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + const editableStyle = createCellStyle(true); + const readonlyStyle = createCellStyle(false); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = isEditable ? editableStyle : readonlyStyle; + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + return { row, col }; + }; + + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + break; + default: + break; + } + + return null; + }; + + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); + + + + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { + try { + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } + + const optionsString = safeOptions.join(','); + + for (let i = 0; i < rowCount; i++) { + try { + const targetRow = cellPos.row + i; + + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // 🔧 DataValidation 설정 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + + return mappings; + }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 + } + + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); + + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { + const dataRowIndex = exactMapping.dataRowIndex; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null ) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + info.cancel = true; + return; + } + } + } + + console.log(`✅ Edit allowed for ${exactMapping.attId}`); + }); + + // ✅ 편집 완료 검증 + activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (errorMessage) { + // 🚨 에러 스타일 적용 + const errorStyle = new GC.Spread.Sheets.Style(); + errorStyle.backColor = "#fef2f2"; + errorStyle.foreColor = "#dc2626"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + + activeSheet.setStyle(info.row, info.col, errorStyle); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread - Designer용으로 수정 + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized Designer initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + + dataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized Designer initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized Designer initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); + + const handleSaveChanges = React.useCallback(async () => { + if (!currentSpread || !hasChanges) { + toast.info("No changes to save"); + return; + } + + const errors = validateAllData(); + if (errors.length > 0) { + toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); + return; + } + + try { + setIsPending(true); + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + const updatedRows: GenericData[] = []; + let saveCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + const isColumnEditable = columnConfig?.shi === "IN" ||columnConfig?.shi === "BOTH"; + const isRowEditable = originalRow.shi === "IN" ||originalRow.shi === "BOTH" ; + + if (isColumnEditable && isRowEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + if (cellValue !== originalRow[mapping.attId]) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + } + } + } + } + }); + + if (hasRowChanges) { + dataToSave.TAG_NO = originalRow.TAG_NO; + const { success } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + } + } else { + updatedRows.push(originalRow); + } + } + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + toast.info("No changes to save"); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); + + if (!isOpen) return null; + + const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; + const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent + className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" + > + <DialogHeader className="flex-shrink-0"> + <DialogTitle>SEDP Template Designer - {formCode}</DialogTitle> + <DialogDescription> + <div className="space-y-3"> + {availableTemplates.length > 1 && ( + <div className="flex items-center gap-4"> + <span className="text-sm font-medium">Template:</span> + <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> + <SelectTrigger className="w-64"> + <SelectValue placeholder="Select a template" /> + </SelectTrigger> + <SelectContent> + {availableTemplates.map(template => ( + <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> + {template.NAME} ({template.TMPL_TYPE}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {selectedTemplate && ( + <div className="flex items-center gap-4 text-sm"> + <span className="font-medium text-blue-600"> + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } + </span> + {templateType === 'SPREAD_ITEM' && selectedRow && ( + <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + <span>• {dataCount} rows</span> + )} + {hasChanges && ( + <span className="text-orange-600 font-medium"> + • Unsaved changes + </span> + )} + {validationErrors.length > 0 && ( + <span className="text-red-600 font-medium flex items-center"> + <AlertTriangle className="w-4 h-4 mr-1" /> + {validationErrors.length} validation errors + </span> + )} + </div> + )} + + <div className="flex items-center gap-4 text-xs"> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> + Editable fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> + Read-only fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> + Validation errors + </span> + {cellMappings.length > 0 && ( + <span className="text-blue-600"> + {editableFieldsCount} of {cellMappings.length} fields editable + </span> + )} + </div> + </div> + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden relative"> + {/* 🆕 로딩 프로그레스 오버레이 */} + <LoadingProgress + phase={loadingProgress?.phase || ''} + progress={loadingProgress?.progress || 0} + total={loadingProgress?.total || 100} + isVisible={isInitializing && !!loadingProgress} + /> + + {selectedTemplate && isClient && isDataValid ? ( + <Designer + key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} + workbookInitialized={initSpread} + hostStyle={hostStyle} + /> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {!isClient ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading... + </> + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} + </div> + )} + </div> + + <DialogFooter className="flex-shrink-0"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={onClose}> + Close + </Button> + + {hasChanges && ( + <Button + variant="default" + onClick={handleSaveChanges} + disabled={isPending || validationErrors.length > 0} + > + {isPending ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Saving... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + Save Changes + </> + )} + </Button> + )} + + {validationErrors.length > 0 && ( + <Button + variant="outline" + onClick={validateAllData} + className="text-red-600 border-red-300 hover:bg-red-50" + > + <AlertTriangle className="mr-2 h-4 w-4" /> + Check Errors ({validationErrors.length}) + </Button> + )} + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index 6de68a1a..c7ab83b5 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -103,17 +103,17 @@ export function UpdateTagSheet({ }, [rowData?.TAG_NO, editableFieldsMap]); const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { - if (column.shi === true) return false; // SHI‑only + if (column.shi === "OUT" || column.shi === null) return false; // SHI‑only if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false; if (column.key === "status") return false; - // return editableFields.includes(column.key); - return true + return editableFields.includes(column.key); + // return true }, [editableFields]); const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]); const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { - if (column.shi) return t("updateTagSheet.readOnlyReasons.shiOnly"); + if (column.shi === "OUT" || column.shi === null) return t("updateTagSheet.readOnlyReasons.shiOnly"); if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { return t("updateTagSheet.readOnlyReasons.noEditableFields"); diff --git a/components/information/information-client.tsx b/components/information/information-client.tsx index d863175f..50bc6a39 100644 --- a/components/information/information-client.tsx +++ b/components/information/information-client.tsx @@ -1,7 +1,8 @@ "use client"
-import { useState, useEffect, useTransition } from "react"
-import { useRouter } from "next/navigation"
+import React, { useState, useEffect, useTransition } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -19,11 +20,13 @@ import { FileText,
ChevronUp,
ChevronDown,
- Download
+ Download,
+ Database,
+ RefreshCw
} from "lucide-react"
import { toast } from "sonner"
import { formatDate } from "@/lib/utils"
-import { getInformationLists } from "@/lib/information/service"
+import { getInformationLists, syncInformationFromMenuAssignments, getInformationDetail } from "@/lib/information/service"
import type { PageInformation } from "@/db/schema/information"
import { UpdateInformationDialog } from "@/lib/information/table/update-information-dialog"
@@ -36,6 +39,25 @@ type SortDirection = "asc" | "desc" export function InformationClient({ initialData = [] }: InformationClientProps) {
const router = useRouter()
+ const params = useParams()
+ const lng = (params?.lng as string) || 'ko'
+ const { t } = useTranslation(lng, 'menu')
+
+ // 안전한 번역 함수 (키가 없을 때 원본 키 반환)
+ const safeTranslate = (key: string): string => {
+ try {
+ const translated = t(key)
+ // 번역 키가 그대로 반환되는 경우 원본 키 사용
+ if (translated === key) {
+ return key
+ }
+ return translated || key
+ } catch (error) {
+ console.warn(`Translation failed for key: ${key}`, error)
+ return key
+ }
+ }
+
const [informations, setInformations] = useState<PageInformation[]>(initialData)
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
@@ -43,28 +65,16 @@ export function InformationClient({ initialData = [] }: InformationClientProps) const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
const [editingInformation, setEditingInformation] = useState<PageInformation | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
+ const [isSyncing, setIsSyncing] = useState(false)
const [, startTransition] = useTransition()
// 정보 목록 조회
const fetchInformations = async () => {
try {
setLoading(true)
- const search = searchQuery || undefined
startTransition(async () => {
- const result = await getInformationLists({
- page: 1,
- perPage: 50,
- search: search,
- sort: [{ id: sortField, desc: sortDirection === "desc" }],
- flags: [],
- filters: [],
- joinOperator: "and",
- pagePath: "",
- pageName: "",
- informationContent: "",
- isActive: null,
- })
+ const result = await getInformationLists()
if (result?.data) {
setInformations(result.data)
@@ -80,9 +90,51 @@ export function InformationClient({ initialData = [] }: InformationClientProps) }
}
- // 검색 핸들러
+ // 클라이언트 사이드 필터링 및 정렬
+ const filteredAndSortedInformations = React.useMemo(() => {
+ let filtered = informations
+
+ // 검색 필터 (페이지명으로 검색)
+ if (searchQuery) {
+ filtered = filtered.filter(info =>
+ safeTranslate(info.pageName).toLowerCase().includes(searchQuery.toLowerCase()) ||
+ info.pagePath.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+ }
+
+ // 정렬
+ filtered = filtered.sort((a, b) => {
+ let aValue: string | Date
+ let bValue: string | Date
+
+ switch (sortField) {
+ case "pageName":
+ aValue = safeTranslate(a.pageName)
+ bValue = safeTranslate(b.pageName)
+ break
+ case "pagePath":
+ aValue = a.pagePath
+ bValue = b.pagePath
+ break
+ case "createdAt":
+ aValue = new Date(a.createdAt)
+ bValue = new Date(b.createdAt)
+ break
+ default:
+ return 0
+ }
+
+ if (aValue < bValue) return sortDirection === "asc" ? -1 : 1
+ if (aValue > bValue) return sortDirection === "asc" ? 1 : -1
+ return 0
+ })
+
+ return filtered
+ }, [informations, searchQuery, sortField, sortDirection, safeTranslate])
+
+ // 검색 핸들러 (클라이언트 사이드에서 필터링하므로 별도 동작 불필요)
const handleSearch = () => {
- fetchInformations()
+ // 클라이언트 사이드 필터링이므로 별도 서버 요청 불필요
}
// 정렬 함수
@@ -92,8 +144,8 @@ export function InformationClient({ initialData = [] }: InformationClientProps) let bValue: string | Date
if (sortField === "pageName") {
- aValue = a.pageName
- bValue = b.pageName
+ aValue = safeTranslate(a.pageName)
+ bValue = safeTranslate(b.pageName)
} else if (sortField === "pagePath") {
aValue = a.pagePath
bValue = b.pagePath
@@ -123,9 +175,23 @@ export function InformationClient({ initialData = [] }: InformationClientProps) }
// 편집 핸들러
- const handleEdit = (information: PageInformation) => {
- setEditingInformation(information)
- setIsEditDialogOpen(true)
+ const handleEdit = async (information: PageInformation) => {
+ try {
+ // 첨부파일 정보까지 포함해서 가져오기
+ const detailData = await getInformationDetail(information.id)
+ if (detailData) {
+ setEditingInformation(detailData)
+ } else {
+ // 실패시 기본 정보라도 사용
+ setEditingInformation(information)
+ }
+ setIsEditDialogOpen(true)
+ } catch (error) {
+ console.error("Failed to load information detail:", error)
+ // 에러시 기본 정보라도 사용
+ setEditingInformation(information)
+ setIsEditDialogOpen(true)
+ }
}
// 편집 완료 핸들러
@@ -136,20 +202,48 @@ export function InformationClient({ initialData = [] }: InformationClientProps) fetchInformations()
}
- // 다운로드 핸들러
- const handleDownload = (information: PageInformation) => {
- if (information.attachmentFilePath && information.attachmentFileName) {
- const link = document.createElement('a')
- link.href = information.attachmentFilePath
- link.download = information.attachmentFileName
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
+ // 다운로드 핸들러 (다중 첨부파일은 dialog에서 처리)
+ const handleDownload = async (information: PageInformation) => {
+ try {
+ // 첨부파일 정보까지 포함해서 가져오기
+ const detailData = await getInformationDetail(information.id)
+ if (detailData) {
+ setEditingInformation(detailData)
+ } else {
+ // 실패시 기본 정보라도 사용
+ setEditingInformation(information)
+ }
+ setIsEditDialogOpen(true)
+ } catch (error) {
+ console.error("Failed to load information detail:", error)
+ // 에러시 기본 정보라도 사용
+ setEditingInformation(information)
+ setIsEditDialogOpen(true)
+ }
+ }
+
+ // 메뉴 동기화 핸들러
+ const handleSync = async () => {
+ setIsSyncing(true)
+ try {
+ const result = await syncInformationFromMenuAssignments()
+
+ if (result.success) {
+ toast.success(result.message)
+ // 동기화 후 데이터 새로고침
+ fetchInformations()
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error("동기화 오류:", error)
+ toast.error("메뉴 동기화 중 오류가 발생했습니다.")
+ } finally {
+ setIsSyncing(false)
}
}
- // 정렬된 정보 목록
- const sortedInformations = sortInformations(informations)
+
useEffect(() => {
if (initialData.length > 0) {
@@ -159,13 +253,7 @@ export function InformationClient({ initialData = [] }: InformationClientProps) }
}, [])
- useEffect(() => {
- if (searchQuery !== "") {
- fetchInformations()
- } else if (initialData.length > 0) {
- setInformations(initialData)
- }
- }, [searchQuery])
+ // searchQuery 변경 시 클라이언트 사이드 필터링으로 처리되므로 useEffect 제거
return (
<div className="space-y-6">
@@ -179,11 +267,19 @@ export function InformationClient({ initialData = [] }: InformationClientProps) value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
- onKeyPress={(e) => e.key === "Enter" && handleSearch()}
+ onKeyPress={(e) => e.key === "Enter"}
/>
</div>
- <Button onClick={handleSearch} variant="outline">
- 검색
+
+ <Button
+ variant="outline"
+ onClick={handleSync}
+ disabled={isSyncing}
+ className="gap-2"
+ >
+ <Database className={`h-4 w-4 ${isSyncing ? 'animate-pulse' : ''}`} />
+ <RefreshCw className={`h-4 w-4 ${isSyncing ? 'animate-spin' : ''}`} />
+ {isSyncing ? '동기화 중...' : '메뉴에서 동기화'}
</Button>
<Button
variant="outline"
@@ -230,7 +326,6 @@ export function InformationClient({ initialData = [] }: InformationClientProps) </button>
</TableHead>
<TableHead>정보 내용</TableHead>
- <TableHead>첨부파일</TableHead>
<TableHead>상태</TableHead>
<TableHead>
<button
@@ -257,20 +352,20 @@ export function InformationClient({ initialData = [] }: InformationClientProps) 로딩 중...
</TableCell>
</TableRow>
- ) : informations.length === 0 ? (
+ ) : filteredAndSortedInformations.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-gray-500">
정보가 없습니다.
</TableCell>
</TableRow>
) : (
- sortedInformations.map((information) => (
+ filteredAndSortedInformations.map((information) => (
<TableRow key={information.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<span className="max-w-[200px] truncate">
- {information.pageName}
+ {(information as any).translatedPageName || safeTranslate(information.pageName)}
</span>
</div>
</TableCell>
@@ -288,23 +383,6 @@ export function InformationClient({ initialData = [] }: InformationClientProps) />
</TableCell>
<TableCell>
- {information.attachmentFileName ? (
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleDownload(information)}
- className="flex items-center gap-1"
- >
- <Download className="h-3 w-3" />
- <span className="max-w-[100px] truncate">
- {information.attachmentFileName}
- </span>
- </Button>
- ) : (
- <span className="text-gray-400">없음</span>
- )}
- </TableCell>
- <TableCell>
<Badge variant={information.isActive ? "default" : "secondary"}>
{information.isActive ? "활성" : "비활성"}
</Badge>
diff --git a/components/notice/notice-client.tsx b/components/notice/notice-client.tsx index e32a40c9..e5c05d84 100644 --- a/components/notice/notice-client.tsx +++ b/components/notice/notice-client.tsx @@ -1,6 +1,8 @@ "use client"
import { useState, useEffect, useTransition } from "react"
+import { useParams } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -55,6 +57,25 @@ type SortField = "title" | "pagePath" | "createdAt" type SortDirection = "asc" | "desc"
export function NoticeClient({ initialData = [], currentUserId }: NoticeClientProps) {
+ const params = useParams()
+ const lng = (params?.lng as string) || 'ko'
+ const { t } = useTranslation(lng, 'menu')
+
+ // 안전한 번역 함수 (키가 없을 때 원본 키 반환)
+ const safeTranslate = (key: string): string => {
+ try {
+ const translated = t(key)
+ // 번역 키가 그대로 반환되는 경우 원본 키 사용
+ if (translated === key) {
+ return key
+ }
+ return translated || key
+ } catch (error) {
+ console.warn(`Translation failed for key: ${key}`, error)
+ return key
+ }
+ }
+
const [notices, setNotices] = useState<NoticeWithAuthor[]>(initialData)
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
@@ -172,7 +193,7 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr const paths = await getPagePathList()
const options = paths.map(path => ({
value: path.pagePath,
- label: `${path.pageName} (${path.pagePath})`
+ label: path.pageName // i18n 키를 그대로 저장 (화면에서 번역)
}))
setPagePathOptions(options)
} catch (error) {
@@ -325,9 +346,17 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr </div>
</TableCell>
<TableCell>
- <span className="font-mono text-sm max-w-[200px] truncate block">
- {notice.pagePath}
- </span>
+ <div className="max-w-[200px]">
+ <div className="font-mono text-xs text-muted-foreground truncate">
+ {notice.pagePath}
+ </div>
+ <div className="text-sm truncate">
+ {(() => {
+ const pageOption = pagePathOptions.find(opt => opt.value === notice.pagePath)
+ return pageOption ? safeTranslate(pageOption.label) : notice.pagePath
+ })()}
+ </div>
+ </div>
</TableCell>
<TableCell>
<div className="flex flex-col">
diff --git a/components/notice/notice-create-dialog.tsx b/components/notice/notice-create-dialog.tsx index e3ce16a1..21cd46f6 100644 --- a/components/notice/notice-create-dialog.tsx +++ b/components/notice/notice-create-dialog.tsx @@ -4,6 +4,8 @@ import * as React from "react" import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
+import { useParams } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
import { toast } from "sonner"
import { Loader } from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -49,6 +51,24 @@ export function NoticeCreateDialog({ currentUserId,
onSuccess,
}: NoticeCreateDialogProps) {
+ const params = useParams()
+ const lng = (params?.lng as string) || 'ko'
+ const { t } = useTranslation(lng, 'menu')
+
+ // 안전한 번역 함수 (키가 없을 때 원본 키 반환)
+ const safeTranslate = (key: string): string => {
+ try {
+ const translated = t(key)
+ // 번역 키가 그대로 반환되는 경우 원본 키 사용
+ if (translated === key) {
+ return key
+ }
+ return translated || key
+ } catch (error) {
+ console.warn(`Translation failed for key: ${key}`, error)
+ return key
+ }
+ }
const [isLoading, setIsLoading] = useState(false)
const form = useForm<CreateNoticeSchema>({
@@ -127,7 +147,7 @@ export function NoticeCreateDialog({ <SelectContent>
{pagePathOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
- {option.label}
+ {safeTranslate(option.label)}
</SelectItem>
))}
</SelectContent>
diff --git a/components/notice/notice-edit-sheet.tsx b/components/notice/notice-edit-sheet.tsx index 91bcae3b..dc83d23a 100644 --- a/components/notice/notice-edit-sheet.tsx +++ b/components/notice/notice-edit-sheet.tsx @@ -3,6 +3,8 @@ import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
+import { useParams } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
@@ -57,6 +59,25 @@ export function UpdateNoticeSheet({ pagePathOptions,
onSuccess
}: UpdateNoticeSheetProps) {
+ const params = useParams()
+ const lng = (params?.lng as string) || 'ko'
+ const { t } = useTranslation(lng, 'menu')
+
+ // 안전한 번역 함수 (키가 없을 때 원본 키 반환)
+ const safeTranslate = (key: string): string => {
+ try {
+ const translated = t(key)
+ // 번역 키가 그대로 반환되는 경우 원본 키 사용
+ if (translated === key) {
+ return key
+ }
+ return translated || key
+ } catch (error) {
+ console.warn(`Translation failed for key: ${key}`, error)
+ return key
+ }
+ }
+
const [isUpdatePending, startUpdateTransition] = React.useTransition()
const form = useForm<UpdateNoticeSchema>({
@@ -149,7 +170,7 @@ export function UpdateNoticeSheet({ <SelectContent>
{pagePathOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
- {option.label}
+ {safeTranslate(option.label)}
</SelectItem>
))}
</SelectContent>
diff --git a/components/ship-vendor-document-all/user-vendor-document-table-container.tsx b/components/ship-vendor-document-all/user-vendor-document-table-container.tsx new file mode 100644 index 00000000..157bdb03 --- /dev/null +++ b/components/ship-vendor-document-all/user-vendor-document-table-container.tsx @@ -0,0 +1,898 @@ +// user-vendor-document-display.tsx - 수정된 버전 +"use client" + +import React from "react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Building, FileText, AlertCircle, Eye, Download, Loader2, Plus, Trash2, Edit } from "lucide-react" +import { SimplifiedDocumentsTable } from "@/lib/vendor-document-list/ship-all/enhanced-documents-table" +import { + getUserVendorDocumentsAll, + getUserVendorDocumentStatsAll, +} from "@/lib/vendor-document-list/enhanced-document-service" +import { SimplifiedDocumentsView } from "@/db/schema" +import { WebViewerInstance } from "@pdftron/webviewer" +import { useRouter } from 'next/navigation' + +/* ------------------------------------------------------------------------------------------------- + * Types & Constants + * -----------------------------------------------------------------------------------------------*/ +interface UserVendorDocumentDisplayProps { + allPromises: Promise<[ + Awaited<ReturnType<typeof getUserVendorDocumentsAll>>, // 문서 목록 + Awaited<ReturnType<typeof getUserVendorDocumentStatsAll>>, // 통계 데이터 + ]> +} + +interface StageInfo { + id: number + stageName: string + stageStatus: string + stageOrder: number + planDate: string | null + actualDate: string | null + assigneeName: string | null + priority: string + revisions: RevisionInfo[] +} + +interface RevisionInfo { + id: number + serialNo: string | null + issueStageId: number + revision: string + uploaderType: string + uploaderId: number | null + uploaderName: string | null + comment: string | null + usage: string | null + usageType: string | null + revisionStatus: string + submittedDate: string | null + approvedDate: string | null + uploadedAt: string | null + reviewStartDate: string | null + rejectedDate: string | null + reviewerId: number | null + reviewerName: string | null + reviewComments: string | null + createdAt: Date + updatedAt: Date + stageName?: string + attachments: AttachmentInfo[] +} + +interface AttachmentInfo { + id: number + revisionId: number + fileName: string + filePath: string + dolceFilePath: string | null + fileSize: number | null + fileType: string | null + createdAt: Date + updatedAt: Date +} + +interface DocumentSelectionContextType { + selectedDocumentId: number | null + selectedStageId: number | null + selectedRevisionId: number | null + setSelectedDocumentId: (id: number | null) => void + setSelectedStageId: (id: number | null) => void + setSelectedRevisionId: (id: number | null) => void + allData: SimplifiedDocumentsView[] | null + setAllData: (data: SimplifiedDocumentsView[]) => void +} + +export const DocumentSelectionContextAll = React.createContext<DocumentSelectionContextType>( + { + selectedDocumentId: null, + selectedStageId: null, + selectedRevisionId: null, + setSelectedDocumentId: (_id: number | null) => { }, + setSelectedStageId: (_id: number | null) => { }, + setSelectedRevisionId: (_id: number | null) => { }, + allData: null, + setAllData: (_data: SimplifiedDocumentsView[]) => { }, + }, +) + +/* ------------------------------------------------------------------------------------------------- + * Revision & Attachment Tables - 너비 최적화 + * -----------------------------------------------------------------------------------------------*/ + +function getUsageTypeDisplay(usageType: string | null): string { + if (!usageType) return '-' + + // B3 용도 타입 축약 표시 + const abbreviations: Record<string, string> = { + 'Approval Submission Full': 'AS-F', + 'Approval Submission Partial': 'AS-P', + 'Approval Completion Full': 'AC-F', + 'Approval Completion Partial': 'AC-P', + 'Working Full': 'W-F', + 'Working Partial': 'W-P', + 'Reference Full': 'R-F', + 'Reference Partial': 'R-P', + 'Reference Series Full': 'RS-F', + 'Reference Series Partial': 'RS-P', + } + + return abbreviations[usageType] || usageType +} + +function RevisionTable({ + revisions, + onViewRevision, +}: { + revisions: RevisionInfo[] + onViewRevision: (revision: RevisionInfo) => void +}) { + const { selectedRevisionId, setSelectedRevisionId } = + React.useContext(DocumentSelectionContextAll) + + const toggleSelect = (revisionId: number) => { + setSelectedRevisionId(revisionId === selectedRevisionId ? null : revisionId) + } + + const canEditRevision = React.useCallback((revision: RevisionInfo) => { + if ((!revision.attachments || revision.attachments.length === 0) && revision.uploaderType === "vendor") { + return true + } + + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, []) + + const getRevisionProcessStatus = React.useCallback((revision: RevisionInfo) => { + if (!revision.attachments || revision.attachments.length === 0) { + return 'no-files' + } + + const processedCount = revision.attachments.filter(attachment => + attachment.dolceFilePath && attachment.dolceFilePath.trim() !== '' + ).length + + if (processedCount === 0) { + return 'not-processed' + } else if (processedCount === revision.attachments.length) { + return 'fully-processed' + } else { + return 'partially-processed' + } + }, []) + + return ( + <Card className="flex-1 min-w-0 max-w-full"> + <CardHeader className="pb-3"> + <CardTitle className="text-lg">Revisions</CardTitle> + </CardHeader> + <CardContent className="p-2 overflow-hidden"> + <div className="w-full overflow-x-auto"> + <Table className="w-full" style={{ tableLayout: 'fixed', minWidth: '800px' }}> + <TableHeader> + <TableRow> + <TableHead style={{ width: '40px' }}>Sel</TableHead> + <TableHead style={{ width: '60px' }}>Serial No</TableHead> + <TableHead style={{ width: '80px' }}>Rev</TableHead> + <TableHead style={{ width: '60px' }}>Category</TableHead> + <TableHead style={{ width: '80px' }}>Usage</TableHead> + <TableHead style={{ width: '80px' }}>Type</TableHead> + <TableHead style={{ width: '90px' }}>Status</TableHead> + <TableHead style={{ width: '100px' }}>Uploader</TableHead> + <TableHead style={{ width: '120px' }}>Comment</TableHead> + <TableHead style={{ width: '100px' }}>Date</TableHead> + <TableHead style={{ width: '60px' }} className="text-center">Files</TableHead> + <TableHead style={{ width: '80px' }}>Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {revisions.map((revision) => { + const canEdit = canEditRevision(revision) + const processStatus = getRevisionProcessStatus(revision) + + return ( + <TableRow + key={revision.id} + className={`revision-table-row ${selectedRevisionId === revision.id ? 'selected' : ''}`} + > + <TableCell style={{ width: '40px' }}> + <input + type="checkbox" + checked={selectedRevisionId === revision.id} + onChange={() => toggleSelect(revision.id)} + className="h-3 w-3 cursor-pointer" + /> + </TableCell> + <TableCell style={{ width: '60px' }} className="font-mono font-medium"> + {revision.serialNo || ''} + </TableCell> + <TableCell style={{ width: '80px' }} className="font-mono font-medium"> + <div className="flex items-center gap-1"> + <span className="truncate text-xs">{revision.revision}</span> + {processStatus === 'fully-processed' && ( + <div + className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0" + title="All files processed" + /> + )} + {processStatus === 'partially-processed' && ( + <div + className="w-1.5 h-1.5 bg-yellow-500 rounded-full flex-shrink-0" + title="Some files processed" + /> + )} + </div> + </TableCell> + <TableCell style={{ width: '60px' }} className="text-xs"> + {revision.uploaderType === "vendor" ? "To" : "From"} + </TableCell> + <TableCell style={{ width: '80px' }}> + <span className="text-xs truncate block"> + {revision.usage || '-'} + </span> + </TableCell> + <TableCell style={{ width: '80px' }}> + <span className="text-xs truncate block"> + {revision.usageType ? ( + getUsageTypeDisplay(revision.usageType) + ) : ( + <span className="text-gray-400">-</span> + )} + </span> + </TableCell> + <TableCell style={{ width: '90px' }}> + <Badge + variant={ + revision.revisionStatus === 'APPROVED' + ? 'default' + : 'secondary' + } + className="text-xs truncate max-w-full" + > + {revision.revisionStatus.slice(0, 8)} + </Badge> + </TableCell> + <TableCell style={{ width: '100px' }}> + <span className="text-xs truncate block">{revision.uploaderName || '-'}</span> + </TableCell> + <TableCell style={{ width: '120px' }}> + {revision.comment ? ( + <div className="w-full"> + <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> + {revision.comment} + </p> + </div> + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </TableCell> + <TableCell style={{ width: '100px' }}> + <span className="text-xs truncate block"> + {revision.uploadedAt + ? new Date(revision.uploadedAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }) + : '-'} + </span> + </TableCell> + <TableCell style={{ width: '60px' }} className="text-center"> + <div className="flex items-center justify-center"> + <span className="text-xs">{revision.attachments.length}</span> + </div> + </TableCell> + <TableCell style={{ width: '80px' }}> + <div className="flex items-center justify-center"> + {revision.attachments.length > 0 && ( + <Button + variant="ghost" + size="sm" + onClick={() => onViewRevision(revision)} + className="h-6 w-6 p-0" + title="View attachments" + > + <Eye className="h-3 w-3" /> + </Button> + )} + </div> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + </CardContent> + </Card> + ) +} + +function AttachmentTable({ + attachments, + onDownloadFile, +}: { + attachments: AttachmentInfo[] + onDownloadFile: (attachment: AttachmentInfo) => void +}) { + const { selectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContextAll) + const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) + const router = useRouter() + + const selectedRevisionInfo = React.useMemo(() => { + if (!selectedRevisionId || !allData) return null + + for (const doc of allData) { + if (doc.allStages) { + for (const stage of doc.allStages as StageInfo[]) { + const revision = stage.revisions.find(r => r.id === selectedRevisionId) + if (revision) return revision + } + } + } + return null + }, [selectedRevisionId, allData]) + + const handleAddAttachment = React.useCallback(() => { + if (selectedRevisionInfo) { + setAddAttachmentDialogOpen(true) + } + }, [selectedRevisionInfo]) + + const canDeleteFile = React.useCallback((attachment: AttachmentInfo) => { + return !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + }, []) + + const handleAttachmentUploadSuccess = React.useCallback((uploadResult?: any) => { + if (!selectedRevisionId || !allData || !uploadResult?.data) { + console.log('🔄 Full refresh') + router.refresh() + return + } + + try { + const newAttachments: AttachmentInfo[] = uploadResult.data.uploadedFiles?.map((file: any) => ({ + id: file.id, + revisionId: selectedRevisionId, + fileName: file.fileName, + filePath: file.filePath, + dolceFilePath: null, + fileSize: file.fileSize, + fileType: file.fileType || null, + createdAt: new Date(), + updatedAt: new Date(), + })) || [] + + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === selectedRevisionId) + if (revisionIndex !== -1) { + stage.revisions[revisionIndex] = { + ...stage.revisions[revisionIndex], + attachments: [...stage.revisions[revisionIndex].attachments, ...newAttachments] + } + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + console.log('✅ AttachmentTable update complete') + + setTimeout(() => { + router.refresh() + }, 1500) + + } catch (error) { + console.error('❌ AttachmentTable update failed:', error) + router.refresh() + } + }, [selectedRevisionId, allData, setAllData, router]) + + return ( + <Card className="w-72 flex-shrink-0 max-w-full min-w-0"> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg truncate">Attachments</CardTitle> + {selectedRevisionId && selectedRevisionInfo && ( + <Button + onClick={handleAddAttachment} + size="sm" + variant="outline" + className="flex items-center gap-1 h-7 px-2 flex-shrink-0" + > + <Plus className="h-3 w-3" /> + <span className="text-xs">Add</span> + </Button> + )} + </div> + </CardHeader> + <CardContent className="p-2 overflow-hidden"> + <div className="w-full overflow-x-auto"> + <Table className="w-full" style={{ tableLayout: 'fixed', minWidth: '280px' }}> + <TableHeader> + <TableRow> + <TableHead style={{ width: '200px' }}>File Name</TableHead> + <TableHead style={{ width: '80px' }}>Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {!selectedRevisionId || attachments.length === 0 ? ( + <TableRow> + <TableCell colSpan={2} className="h-24 text-center"> + <div className="flex flex-col items-center gap-2 text-muted-foreground"> + <FileText className="h-6 w-6" /> + <span className="text-xs"> + {!selectedRevisionId + ? 'Please select a revision' + : 'No attached files'} + </span> + {selectedRevisionId && selectedRevisionInfo && ( + <Button + onClick={handleAddAttachment} + size="sm" + variant="outline" + className="mt-2 h-7 px-2" + > + <Plus className="h-3 w-3 mr-1" /> + <span className="text-xs">Add First File</span> + </Button> + )} + </div> + </TableCell> + </TableRow> + ) : ( + attachments.map((file) => ( + <TableRow key={file.id}> + <TableCell style={{ width: '200px' }} className="font-medium"> + <div className="min-w-0"> + <div className="truncate text-xs" title={file.fileName}> + {file.fileName} + </div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span> + {file.fileSize + ? file.fileSize >= 1024 * 1024 + ? `${(file.fileSize / 1024 / 1024).toFixed(1)}MB` + : `${(file.fileSize / 1024).toFixed(1)}KB` + : '-'} + </span> + {file.dolceFilePath && file.dolceFilePath.trim() !== '' && ( + <span className="text-blue-600 font-medium">Processed</span> + )} + </div> + </div> + </TableCell> + <TableCell style={{ width: '80px' }}> + <div className="flex items-center justify-center"> + <Button + variant="ghost" + size="sm" + onClick={() => onDownloadFile(file)} + className="h-6 w-6 p-0" + title="Download file" + > + <Download className="h-3 w-3" /> + </Button> + </div> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + </CardContent> + </Card> + ) +} + +// SubTables 컴포넌트 - 컨테이너 너비 제한 강화 +function SubTables() { + const router = useRouter() + const { selectedDocumentId, selectedRevisionId, setSelectedRevisionId, allData, setAllData } = + React.useContext(DocumentSelectionContextAll) + + // PDF 뷰어 상태 관리 + const [viewerOpen, setViewerOpen] = React.useState(false) + const [selectedRevision, setSelectedRevision] = React.useState<RevisionInfo | null>(null) + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) + const [viewerLoading, setViewerLoading] = React.useState(true) + const [fileSetLoading, setFileSetLoading] = React.useState(true) + const viewer = React.useRef<HTMLDivElement>(null) + const initialized = React.useRef(false) + const isCancelled = React.useRef(false) + + const selectedDocument = React.useMemo(() => { + if (!selectedDocumentId || !allData) return null + return allData.find((d) => d.documentId === selectedDocumentId) || null + }, [selectedDocumentId, allData]) + + const allRevisions = React.useMemo(() => { + if (!selectedDocument?.allStages) return [] + + const revisions: RevisionInfo[] = [] + for (const stage of selectedDocument.allStages as StageInfo[]) { + const stageRevisions = stage.revisions.map(revision => ({ + ...revision, + stageName: stage.stageName + })) + revisions.push(...stageRevisions) + } + + return revisions.sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + }, [selectedDocument]) + + const selectedRevisionData = React.useMemo(() => { + if (!selectedRevisionId) return null + return allRevisions.find(r => r.id === selectedRevisionId) || null + }, [selectedRevisionId, allRevisions]) + + const cleanupHtmlStyle = React.useCallback(() => { + const htmlElement = window.document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + }, []) + + const handleViewRevision = React.useCallback((revision: RevisionInfo) => { + setSelectedRevision(revision) + setViewerOpen(true) + setViewerLoading(true) + setFileSetLoading(true) + initialized.current = false + }, []) + + const handleDownloadFile = React.useCallback(async (attachment: AttachmentInfo) => { + try { + const queryParam = attachment.id + ? `id=${encodeURIComponent(attachment.id)}` + : `path=${encodeURIComponent(attachment.filePath)}` + + const response = await fetch(`/api/document-download?${queryParam}`) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to download file.') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const link = window.document.createElement('a') + link.href = url + link.download = attachment.fileName + window.document.body.appendChild(link) + link.click() + window.document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } catch (error) { + console.error('File download error:', error) + alert(`File download failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, []) + + // WebViewer 초기화 + React.useEffect(() => { + if (viewerOpen && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current && !isCancelled.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("WebViewer initialization cancelled (Dialog closed)") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + if (!isCancelled.current) { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]) + setViewerLoading(false) + } + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [viewerOpen, cleanupHtmlStyle, instance]) + + // 문서 로드 + React.useEffect(() => { + const loadDocument = async () => { + if (instance && selectedRevision?.attachments?.length) { + const { UI } = instance + + const tabIds = [] + for (const attachment of selectedRevision.attachments) { + try { + const response = await fetch(attachment.filePath) + const blob = await response.blob() + const options = { + filename: attachment.fileName, + ...(attachment.fileType?.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }), + } + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error("File load failed:", attachment.filePath, error) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + loadDocument() + }, [instance, selectedRevision]) + + const handleCloseViewer = React.useCallback(async () => { + if (!fileSetLoading) { + isCancelled.current = true + + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setViewerLoading(false) + setViewerOpen(false) + setTimeout(() => cleanupHtmlStyle(), 1000) + } + }, [fileSetLoading, instance, cleanupHtmlStyle]) + + if (!selectedDocument) return null + + return ( + <> + {/* 컨테이너 너비 제한 강화 */} + <div className="w-full max-w-full overflow-hidden"> + <div className="flex flex-col lg:flex-row gap-4 min-w-0"> + <RevisionTable + revisions={allRevisions} + onViewRevision={handleViewRevision} + /> + <AttachmentTable + attachments={selectedRevisionData?.attachments || []} + onDownloadFile={handleDownloadFile} + /> + </div> + </div> + + {/* 문서 뷰어 다이얼로그 */} + <Dialog open={viewerOpen} onOpenChange={handleCloseViewer}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>Document Preview</DialogTitle> + <DialogDescription> + Revision {selectedRevision?.revision} attachments + </DialogDescription> + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + Loading document viewer... + </p> + </div> + )} + </div> + </DialogContent> + </Dialog> + </> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * High‑level Selected Document Summary + * -----------------------------------------------------------------------------------------------*/ +function SelectedDocumentInfo() { + const { selectedDocumentId, selectedRevisionId, allData } = + React.useContext(DocumentSelectionContextAll) + + if (!selectedDocumentId || !allData) return null + + const doc = allData.find((d) => d.documentId === selectedDocumentId) + if (!doc) return null + + const totalRevisions = doc.allStages + ? (doc.allStages as StageInfo[]).reduce( + (acc, s) => acc + s.revisions.length, + 0, + ) + : 0 + + let selectedRevision: RevisionInfo | null = null + if (selectedRevisionId && doc.allStages) { + for (const stage of doc.allStages as StageInfo[]) { + const rev = stage.revisions.find((r) => r.id === selectedRevisionId) + if (rev) { + selectedRevision = rev + break + } + } + } + + return ( + <div className="w-full max-w-full overflow-hidden"> + <div className="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 p-4"> + <div className="flex items-center gap-2 min-w-0"> + <Badge variant="secondary" className="text-sm flex-shrink-0"> + Document: {doc.docNumber} + </Badge> + <span className="max-w-[300px] truncate text-sm font-medium text-gray-700"> + {doc.title} + </span> + </div> + <div className="flex items-center gap-2 text-sm text-gray-600"> + <span>•</span> + <span>Total {totalRevisions} revisions</span> + {selectedRevision && ( + <> + <span>•</span> + <Badge variant="outline" className="text-sm"> + Selected revision: {selectedRevision.revision} + </Badge> + <span>({selectedRevision.attachments.length} files)</span> + </> + )} + </div> + </div> + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Exported Component + * -----------------------------------------------------------------------------------------------*/ +export function UserVendorALLDocumentDisplay({ + allPromises, +}: UserVendorDocumentDisplayProps) { + const [selectedDocumentId, setSelectedDocumentId] = + React.useState<number | null>(null) + const [selectedStageId, setSelectedStageId] = React.useState<number | null>( + null, + ) + const [selectedRevisionId, setSelectedRevisionId] = + React.useState<number | null>(null) + const [allData, setAllData] = + React.useState<SimplifiedDocumentsView[] | null>(null) + + const handleDocumentSelect = React.useCallback((id: number | null) => { + setSelectedDocumentId(id) + setSelectedStageId(null) + setSelectedRevisionId(null) + }, []) + + const ctx = React.useMemo<DocumentSelectionContextType>( + () => ({ + selectedDocumentId, + selectedStageId, + selectedRevisionId, + setSelectedDocumentId: handleDocumentSelect, + setSelectedStageId, + setSelectedRevisionId, + allData, + setAllData, + }), + [ + selectedDocumentId, + selectedStageId, + selectedRevisionId, + handleDocumentSelect, + allData, + setAllData, + ], + ) + + if (!allPromises) { + return ( + <Card> + <CardContent className="flex items-center justify-center py-8"> + <div className="text-center"> + <AlertCircle className="mx-auto mb-2 h-8 w-8 text-gray-400" /> + <p className="text-gray-600">Unable to load data.</p> + </div> + </CardContent> + </Card> + ) + } + + return ( + <DocumentSelectionContextAll.Provider value={ctx}> + <div className="space-y-4 w-full max-w-full overflow-hidden"> + <Card className="w-full max-w-full"> + <CardContent className="p-4 overflow-hidden"> + <div className="w-full max-w-full"> + <SimplifiedDocumentsTable + allPromises={allPromises} + onDataLoaded={setAllData} + onDocumentSelect={handleDocumentSelect} + /> + </div> + </CardContent> + </Card> + <SelectedDocumentInfo /> + <SubTables /> + </div> + </DocumentSelectionContextAll.Provider> + ) +}
\ No newline at end of file diff --git a/components/ship-vendor-document/user-vendor-document-table-container.tsx b/components/ship-vendor-document/user-vendor-document-table-container.tsx index 61d52c28..9e45df6b 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -81,6 +81,7 @@ interface RevisionInfo { reviewerId: number | null reviewerName: string | null reviewComments: string | null + serialNo: string | null createdAt: Date updatedAt: Date stageName?: string @@ -222,6 +223,7 @@ function RevisionTable({ <TableHeader> <TableRow> <TableHead className="w-12">Select</TableHead> + <TableHead>Serial No</TableHead> <TableHead>Revision</TableHead> <TableHead>Category</TableHead> <TableHead>Usage</TableHead> @@ -254,6 +256,9 @@ function RevisionTable({ /> </TableCell> <TableCell className="font-mono font-medium"> + {revision.serialNo || ''} + </TableCell> + <TableCell className="font-mono font-medium"> <div className="flex items-center gap-2"> {revision.revision} {/* ✅ 처리 상태 인디케이터 */} diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 71ecbd1c..999b87dc 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -114,6 +114,8 @@ interface VendorData { items: string; taxId: string; address: string; + addressDetail: string; + postalCode: string; email: string; phone: string; country: string; @@ -165,6 +167,46 @@ const getCountryData = (lng: string): CountryOption[] => { const MAX_FILE_SIZE = 3e9; +// ========== 전화번호 정규화 함수 ========== + +/** + * 전화번호를 E.164 형식으로 정규화 (저장용) + */ +function normalizePhoneForStorage(phoneNumber: string, countryCode: string): string | null { + try { + if (!phoneNumber || !countryCode) return null; + + const parsed = parsePhoneNumberFromString(phoneNumber, countryCode); + + if (!parsed || !parsed.isValid()) { + return null; + } + + // E.164 형식으로 반환 (+821012345678) + return parsed.format('E.164'); + } catch (error) { + console.error('Phone normalization error:', error); + return null; + } +} + +/** + * E.164 형식의 전화번호를 표시용으로 포맷팅 + */ +function formatPhoneForDisplay(phoneNumber: string): string { + try { + if (!phoneNumber) return ''; + + const parsed = parsePhoneNumberFromString(phoneNumber); + if (parsed) { + return parsed.formatNational(); // 국내 형식으로 표시 + } + return phoneNumber; + } catch { + return phoneNumber; + } +} + // ========== 전화번호 처리 유틸리티 함수들 ========== /** @@ -338,8 +380,10 @@ function PhoneInput({ const formatPhone = usePhoneFormatter(countryCode); useEffect(() => { - setLocalValue(value || ''); - }, [value]); + // E.164 형식으로 저장된 번호를 표시용으로 변환 + const displayValue = value ? formatPhoneForDisplay(value) : ''; + setLocalValue(displayValue); + }, [value, countryCode]); const validation = validatePhoneNumber(localValue, countryCode, t); @@ -432,6 +476,8 @@ export default function JoinForm() { items: "", taxId: defaultTaxId, address: "", + addressDetail: "", + postalCode: "", email: "", phone: "", country: "", @@ -686,12 +732,23 @@ function AccountStep({ setIsLoading(true); setEmailCheckError(''); + try { + // 전화번호 정규화 + const normalizedPhone = normalizePhoneForStorage(data.phone, data.country); + if (!normalizedPhone) { + setEmailCheckError('전화번호 형식이 올바르지 않습니다'); + return; + } + const isUsed = await checkEmailExists(data.email); if (isUsed) { setEmailCheckError(t('emailAlreadyInUse')); return; } + + // 정규화된 전화번호로 데이터 업데이트 + onChange(prev => ({ ...prev, phone: normalizedPhone })); onNext(); } catch (error) { setEmailCheckError(t('emailCheckError')); @@ -914,6 +971,7 @@ function CompleteVendorForm({ const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles); const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles); + // 유효성 검사 const validateRequiredFiles = (): string[] => { const errors: string[] = []; @@ -945,10 +1003,10 @@ function CompleteVendorForm({ contact.contactPhone ? validatePhoneNumber(contact.contactPhone, data.country, t).isValid : true ); - const isFormValid = data.vendorName && data.vendorTypeId && data.items && + const isFormValid = data.vendorName && data.vendorTypeId && data.items && data.country && data.phone && vendorPhoneValidation.isValid && data.email && contactsValid && - validateRequiredFiles().length === 0; + validateRequiredFiles().length === 0 // 최종 제출 const handleSubmit = async () => { @@ -964,12 +1022,51 @@ function CompleteVendorForm({ setIsSubmitting(true); try { + // 업체 전화번호 정규화 + const normalizedVendorPhone = normalizePhoneForStorage(data.phone, data.country); + if (!normalizedVendorPhone) { + toast({ + variant: "destructive", + title: t('error'), + description: '업체 전화번호 형식이 올바르지 않습니다', + }); + return; + } + + // 담당자 전화번호들 정규화 + const normalizedContacts = data.contacts.map(contact => { + if (contact.contactPhone) { + const normalizedContactPhone = normalizePhoneForStorage(contact.contactPhone, data.country); + if (!normalizedContactPhone) { + throw new Error(`담당자 ${contact.contactName}의 전화번호 형식이 올바르지 않습니다`); + } + return { ...contact, contactPhone: normalizedContactPhone }; + } + return contact; + }); + + // 대표자 전화번호 정규화 (한국 업체인 경우) + let normalizedRepresentativePhone = data.representativePhone; + if (data.country === "KR" && data.representativePhone) { + const normalized = normalizePhoneForStorage(data.representativePhone, "KR"); + if (!normalized) { + throw new Error('대표자 전화번호 형식이 올바르지 않습니다'); + } + normalizedRepresentativePhone = normalized; + } + const formData = new FormData(); const completeData = { - account: accountData, + account: { + ...accountData, + // accountData.phone은 이미 AccountStep에서 정규화됨 + }, vendor: { ...data, + phone: normalizedVendorPhone, + representativePhone: normalizedRepresentativePhone, + contacts: normalizedContacts, email: data.email || accountData.email, }, consents: { @@ -1004,7 +1101,7 @@ function CompleteVendorForm({ if (data.country !== "KR") { bankAccountFiles.forEach(file => { - formData.append('bankAccountCopy', file); + formData.append('bankAccount', file); }); } @@ -1032,8 +1129,8 @@ function CompleteVendorForm({ console.error(error); toast({ variant: "destructive", - title: t('serverError'), - description: t('errorOccurred'), + title: t('error'), + description: error instanceof Error ? error.message : t('errorOccurred'), }); } finally { setIsSubmitting(false); @@ -1152,7 +1249,9 @@ function CompleteVendorForm({ {/* 주소 */} <div> - <label className="block text-sm font-medium mb-1">{t('address')}</label> + <label className="block text-sm font-medium mb-1"> + {t('address')} <span className="text-red-500">*</span> + </label> <Input value={data.address} onChange={(e) => handleInputChange('address', e.target.value)} @@ -1160,6 +1259,32 @@ function CompleteVendorForm({ /> </div> + {/* 상세주소 */} + <div> + <label className="block text-sm font-medium mb-1"> + 상세주소 <span className="text-red-500">*</span> + </label> + <Input + value={data.addressDetail} + onChange={(e) => handleInputChange('addressDetail', e.target.value)} + disabled={isSubmitting} + placeholder="상세주소를 입력해주세요" + /> + </div> + + {/* 우편번호 */} + <div> + <label className="block text-sm font-medium mb-1"> + 우편번호 <span className="text-red-500">*</span> + </label> + <Input + value={data.postalCode} + onChange={(e) => handleInputChange('postalCode', e.target.value)} + disabled={isSubmitting} + placeholder="우편번호를 입력해주세요" + /> + </div> + {/* 국가 */} <div> <label className="block text-sm font-medium mb-1"> diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx index 3974b791..207abcf1 100644 --- a/components/vendor-data/vendor-data-container.tsx +++ b/components/vendor-data/vendor-data-container.tsx @@ -1,42 +1,63 @@ "use client" import * as React from "react" -import { usePathname, useRouter, useSearchParams, useParams } from "next/navigation" -import { useAtom } from "jotai" -import { selectedModeAtom } from "@/atoms" -import { Sidebar } from "./sidebar" -import { ProjectSwitcher } from "./project-swicher" +import { TooltipProvider } from "@/components/ui/tooltip" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" import { cn } from "@/lib/utils" +import { ProjectSwitcher } from "./project-swicher" +import { Sidebar } from "./sidebar" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" import { Separator } from "@/components/ui/separator" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" import { Button } from "@/components/ui/button" -import { TooltipProvider } from "@/components/ui/tooltip" +import { FormInput } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { selectedModeAtom } from '@/atoms' +import { useAtom } from 'jotai' interface PackageData { itemId: number itemName: string } +interface ContractData { + contractId: number + contractName: string + packages: PackageData[] +} + +interface ProjectData { + projectId: number + projectCode: string + projectName: string + projectType: string + contracts: ContractData[] +} + interface VendorDataContainerProps { - projects: { - projectId: number - projectCode: string - projectName: string - projectType: string - contracts: { - contractId: number - contractNo: string - contractName: string - packages: PackageData[] - }[] - }[] + projects: ProjectData[] defaultLayout?: number[] defaultCollapsed?: boolean - navCollapsedSize?: number + navCollapsedSize: number children: React.ReactNode } +function getTagIdFromPathname(path: string | null): number | null { + if (!path) return null; + + // 태그 패턴 검사 (/tag/123) + const tagMatch = path.match(/\/tag\/(\d+)/) + if (tagMatch) return parseInt(tagMatch[1], 10) + + // 폼 패턴 검사 (/form/123/...) + const formMatch = path.match(/\/form\/(\d+)/) + if (formMatch) return parseInt(formMatch[1], 10) + + return null +} + export function VendorDataContainer({ projects, defaultLayout = [20, 80], @@ -47,8 +68,8 @@ export function VendorDataContainer({ const pathname = usePathname() const router = useRouter() const searchParams = useSearchParams() - const params = useParams() - const currentLng = params?.lng as string || 'en' + + const tagIdNumber = getTagIdFromPathname(pathname) // 기본 상태 const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) @@ -57,12 +78,15 @@ export function VendorDataContainer({ projects[0]?.contracts[0]?.contractId || 0 ) const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) + const [formList, setFormList] = React.useState<FormInfo[]>([]) + const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) + const [isLoadingForms, setIsLoadingForms] = React.useState(false) // 현재 선택된 프로젝트/계약/패키지 const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) ?? currentProject?.contracts[0] - + // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 const isShipProject = currentProject?.projectType === "ship" @@ -78,6 +102,30 @@ export function VendorDataContainer({ React.useEffect(() => { setSelectedMode(initialMode as "IM" | "ENG") }, [initialMode, setSelectedMode]) + + const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false + const currentPackageName = isTagOrFormRoute + ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" + : "None" + + // 폼 목록에서 고유한 폼 이름만 추출 + const formNames = React.useMemo(() => { + return [...new Set(formList.map((form) => form.formName))] + }, [formList]) + + // URL에서 현재 폼 코드 추출 + const getCurrentFormCode = (path: string): string | null => { + const segments = path.split("/").filter(Boolean) + const formIndex = segments.indexOf("form") + if (formIndex !== -1 && segments[formIndex + 2]) { + return segments[formIndex + 2] + } + return null + } + + const currentFormCode = React.useMemo(() => { + return pathname ? getCurrentFormCode(pathname) : null + }, [pathname]) // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) React.useEffect(() => { @@ -101,89 +149,184 @@ export function VendorDataContainer({ } }, [isShipProject, router]) - // (1) 프로젝트 변경 시 계약 초기화 + // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 React.useEffect(() => { - if (currentProject?.contracts.length) { - setSelectedContractId(currentProject.contracts[0].contractId) + if (!currentContract) return + + if (tagIdNumber) { + setSelectedPackageId(tagIdNumber) } else { - setSelectedContractId(0) + // tagIdNumber가 없으면, 현재 계약의 첫 번째 패키지로 + if (currentContract.packages?.length) { + setSelectedPackageId(currentContract.packages[0].itemId) + } else { + setSelectedPackageId(null) + } } - }, [currentProject]) + }, [tagIdNumber, currentContract]) - // 핸들러들 - function handleSelectContract(projId: number, cId: number) { - setSelectedProjectId(projId) - setSelectedContractId(cId) - } + // (2) 프로젝트 변경 시 계약 초기화 + // React.useEffect(() => { + // if (currentProject?.contracts.length) { + // setSelectedContractId(currentProject.contracts[0].contractId) + // } else { + // setSelectedContractId(0) + // } + // }, [currentProject]) - function handleSelectPackage(itemId: number) { - setSelectedPackageId(itemId) + // (3) 패키지 ID와 모드가 변경될 때마다 폼 로딩 + React.useEffect(() => { + const packageId = getTagIdFromPathname(pathname) - // partners와 동일하게: 패키지 선택 시 해당 페이지로 이동 - if (itemId && pathname) { - // 더 안전한 URL 생성 로직 - let baseSegments: string; - const vendorDataIndex = pathname.split("/").filter(Boolean).indexOf("vendor-data"); + if (packageId) { + setSelectedPackageId(packageId) - if (vendorDataIndex !== -1) { - baseSegments = pathname.split("/").filter(Boolean).slice(0, vendorDataIndex + 1).join("/"); + // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 + loadFormsList(packageId, selectedMode); + } else if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + setSelectedPackageId(firstPackageId); + loadFormsList(firstPackageId, selectedMode); + } + }, [pathname, currentContract, selectedMode]) + + // 모드에 따른 폼 로드 함수 + const loadFormsList = async (packageId: number, mode: "IM" | "ENG") => { + if (!packageId) return; + + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(packageId, mode); + setFormList(result.forms || []); + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + setFormList([]); + } finally { + setIsLoadingForms(false); + } + }; + + // 핸들러들 +// 수정된 handleSelectContract 함수 +async function handleSelectContract(projId: number, cId: number) { + setSelectedProjectId(projId) + setSelectedContractId(cId) + + // 선택된 계약의 첫 번째 패키지 찾기 + const selectedProject = projects.find(p => p.projectId === projId) + const selectedContract = selectedProject?.contracts.find(c => c.contractId === cId) + + if (selectedContract?.packages?.length) { + const firstPackageId = selectedContract.packages[0].itemId + setSelectedPackageId(firstPackageId) + + // ENG 모드로 폼 목록 로드 + setIsLoadingForms(true) + try { + const result = await getFormsByContractItemId(firstPackageId, "ENG") + setFormList(result.forms || []) + + // 첫 번째 폼이 있으면 자동 선택 및 네비게이션 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0] + setSelectedFormCode(firstForm.formCode) + + // ENG 모드로 설정 + setSelectedMode("ENG") + + // 첫 번째 폼으로 네비게이션 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/") + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${projId}/${cId}?mode=ENG`) } else { - // vendor-data가 없으면 기본 경로 사용 - baseSegments = `${currentLng}/evcp/vendor-data`; + // 폼이 없는 경우에도 ENG 모드로 설정 + setSelectedMode("ENG") + setSelectedFormCode(null) + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/") + router.push(`/${baseSegments}/form/0/0/${projId}/${cId}?mode=ENG`) } + } catch (error) { + console.error("폼 로딩 오류:", error) + setFormList([]) + setSelectedFormCode(null) - const targetUrl = `/${baseSegments}/tag/${itemId}?mode=${selectedMode}`; - router.push(targetUrl); + // 오류 발생 시에도 ENG 모드로 설정 + setSelectedMode("ENG") + } finally { + setIsLoadingForms(false) } + } else { + // 패키지가 없는 경우 + setSelectedPackageId(null) + setFormList([]) + setSelectedFormCode(null) + setSelectedMode("ENG") + } +} + + function handleSelectPackage(itemId: number) { + setSelectedPackageId(itemId) } function handleSelectForm(formName: string) { - // partners와 동일하게: 폼 선택 시 해당 페이지로 이동 - if (selectedPackageId && pathname) { - // 더 안전한 URL 생성 로직 - let baseSegments: string; - const vendorDataIndex = pathname.split("/").filter(Boolean).indexOf("vendor-data"); - - if (vendorDataIndex !== -1) { - baseSegments = pathname.split("/").filter(Boolean).slice(0, vendorDataIndex + 1).join("/"); - } else { - // vendor-data가 없으면 기본 경로 사용 - baseSegments = `${currentLng}/evcp/vendor-data`; - } - - const targetUrl = `/${baseSegments}/form/${selectedPackageId}/${formName}/${selectedProjectId}/${selectedContractId}?mode=${selectedMode}`; - router.push(targetUrl); + const form = formList.find((f) => f.formName === formName) + if (form) { + setSelectedFormCode(form.formCode) } } // 모드 변경 핸들러 - const handleModeChange = async (mode: "IM" | "ENG") => { - // ship 프로젝트인 경우 모드 변경 금지 - if (isShipProject && mode !== "ENG") return; - - setSelectedMode(mode); +// 모드 변경 핸들러 +const handleModeChange = async (mode: "IM" | "ENG") => { + // ship 프로젝트인 경우 모드 변경 금지 + if (isShipProject && mode !== "ENG") return; + + setSelectedMode(mode); + + // 모드가 변경될 때 자동 네비게이션 + if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; - // 모드가 변경될 때 자동 네비게이션 - if (currentContract?.packages?.length) { - const firstPackageId = currentContract.packages[0].itemId; - - if (pathname) { - // 더 안전한 URL 생성 로직 - let baseSegments: string; - const vendorDataIndex = pathname.split("/").filter(Boolean).indexOf("vendor-data"); + if (mode === "IM") { + // IM 모드: 첫 번째 패키지로 이동 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); + } else { + // ENG 모드: 폼 목록을 먼저 로드 + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(firstPackageId, mode); + setFormList(result.forms || []); - if (vendorDataIndex !== -1) { - baseSegments = pathname.split("/").filter(Boolean).slice(0, vendorDataIndex + 1).join("/"); + // 폼이 있으면 첫 번째 폼으로 이동 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0]; + setSelectedFormCode(firstForm.formCode); + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); } else { - // vendor-data가 없으면 기본 경로 사용 - baseSegments = `${currentLng}/evcp/vendor-data`; + // 폼이 없으면 모드만 변경 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); } - - const targetUrl = `/${baseSegments}/tag/${firstPackageId}?mode=${mode}`; - router.push(targetUrl); + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + // 오류 발생 시 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } finally { + setIsLoadingForms(false); } } + } else { + // 패키지가 없는 경우, 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); } +}; return ( <TooltipProvider delayDuration={0}> @@ -224,7 +367,14 @@ export function VendorDataContainer({ selectedProjectId={selectedProjectId} selectedContractId={selectedContractId} onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} mode="ENG" className="hidden lg:block" /> @@ -250,7 +400,14 @@ export function VendorDataContainer({ selectedContractId={selectedContractId} selectedProjectId={selectedProjectId} onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} mode="IM" className="hidden lg:block" /> @@ -264,7 +421,14 @@ export function VendorDataContainer({ selectedContractId={selectedContractId} selectedProjectId={selectedProjectId} onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} mode="ENG" className="hidden lg:block" /> @@ -303,7 +467,14 @@ export function VendorDataContainer({ selectedProjectId={selectedProjectId} selectedContractId={selectedContractId} onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} mode={isShipProject ? "ENG" : selectedMode} className="hidden lg:block" /> @@ -319,7 +490,7 @@ export function VendorDataContainer({ <h2 className="text-lg font-bold"> {isShipProject || selectedMode === "ENG" ? "Engineering Mode" - : `Package: ${currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None"}`} + : `Package: ${currentPackageName}`} </h2> </div> {children} diff --git a/components/vendor-info/pq-simple-dialog.tsx b/components/vendor-info/pq-simple-dialog.tsx new file mode 100644 index 00000000..bb26685d --- /dev/null +++ b/components/vendor-info/pq-simple-dialog.tsx @@ -0,0 +1,417 @@ +"use client" + +import { useState, useEffect } from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Download, FileText, ChevronDown, ChevronUp, Search } from "lucide-react" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" +import { getPQProjectsByVendorId, ProjectPQ, getPQDataByVendorId, PQGroupData } from "@/lib/pq/service" +import { downloadFile } from "@/lib/file-download" + +interface PQSimpleDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorId: string +} + +interface PQItemData { + groupName: string + code: string + checkPoint: string + description: string + answer: string | null + inputFormat: string + fileName?: string | null + filePath?: string | null +} + +export function PQSimpleDialog({ + open, + onOpenChange, + vendorId, +}: PQSimpleDialogProps) { + const [projects, setProjects] = useState<ProjectPQ[]>([]) + const [selectedProject, setSelectedProject] = useState<ProjectPQ | null>(null) + const [pqData, setPqData] = useState<PQGroupData[]>([]) + const [loading, setLoading] = useState(false) + const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()) + const [searchTerm, setSearchTerm] = useState("") + + // vendorId를 숫자로 변환 + const numericVendorId = parseInt(vendorId) + + useEffect(() => { + if (open && !isNaN(numericVendorId)) { + loadProjects() + } + }, [open, numericVendorId]) + + const loadProjects = async () => { + try { + setLoading(true) + const projectList = await getPQProjectsByVendorId(numericVendorId) + setProjects(projectList) + + if (projectList.length > 0) { + setSelectedProject(projectList[0]) + await loadPQData(projectList[0].projectId) + } + } catch (error) { + console.error("프로젝트 목록 로드 실패:", error) + toast.error("PQ 프로젝트 목록을 불러오는데 실패했습니다.") + } finally { + setLoading(false) + } + } + + const loadPQData = async (projectId: number | null) => { + if (projectId === null) return + + try { + setLoading(true) + const data = await getPQDataByVendorId(numericVendorId, projectId) + setPqData(data) + } catch (error) { + console.error("PQ 데이터 로드 실패:", error) + toast.error("PQ 데이터를 불러오는데 실패했습니다.") + } finally { + setLoading(false) + } + } + + const handleProjectChange = async (project: ProjectPQ) => { + setSelectedProject(project) + await loadPQData(project.projectId) + } + + const handleFileDownload = async (filePath: string, fileName: string) => { + try { + const result = await downloadFile(filePath, fileName) + if (result.success) { + toast.success(`${fileName} 파일이 다운로드되었습니다.`) + } else { + toast.error(result.error || "파일 다운로드에 실패했습니다.") + } + } catch (error) { + console.error("파일 다운로드 오류:", error) + toast.error("파일 다운로드에 실패했습니다.") + } + } + + // 코드 순서로 정렬하는 함수 (1-1-1, 1-1-2, 1-2-1 순서) + const sortByCode = (items: any[]) => { + return [...items].sort((a, b) => { + const parseCode = (code: string) => { + return code.split('-').map(part => parseInt(part, 10)) + } + + const aCode = parseCode(a.code) + const bCode = parseCode(b.code) + + for (let i = 0; i < Math.max(aCode.length, bCode.length); i++) { + const aPart = aCode[i] || 0 + const bPart = bCode[i] || 0 + if (aPart !== bPart) { + return aPart - bPart + } + } + return 0 + }) + } + + // 검색 필터링 함수 + const filterItems = (items: any[], searchTerm: string) => { + if (!searchTerm.trim()) return items + + const search = searchTerm.toLowerCase() + return items.filter(item => + item.checkPoint?.toLowerCase().includes(search) || + item.description?.toLowerCase().includes(search) || + item.code?.toLowerCase().includes(search) + ) + } + + // 그룹별로 정렬 및 필터링된 데이터 계산 + const processedPQData = pqData.map(group => ({ + ...group, + items: filterItems(sortByCode(group.items), searchTerm) + })).filter(group => group.items.length > 0) // 검색 결과가 없는 그룹은 제외 + + const toggleGroup = (groupName: string) => { + setExpandedGroups(prev => { + const newSet = new Set(prev) + if (newSet.has(groupName)) { + newSet.delete(groupName) + } else { + newSet.add(groupName) + } + return newSet + }) + } + + const renderPQContent = (groupData: PQGroupData) => { + const isExpanded = expandedGroups.has(groupData.groupName) + const itemCount = groupData.items.length + + return ( + <Card key={groupData.groupName} className="mb-4"> + <CardHeader + className="cursor-pointer hover:bg-muted/50 transition-colors" + onClick={() => toggleGroup(groupData.groupName)} + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <CardTitle className="text-base font-medium"> + {groupData.groupName} + </CardTitle> + <Badge variant="secondary" className="text-xs"> + {itemCount}개 항목 + </Badge> + </div> + {isExpanded ? ( + <ChevronUp className="w-4 h-4 text-muted-foreground" /> + ) : ( + <ChevronDown className="w-4 h-4 text-muted-foreground" /> + )} + </div> + </CardHeader> + + {isExpanded && ( + <CardContent className="pt-0"> + <div className="space-y-3"> + {groupData.items.map((item, index) => ( + <div + key={`${groupData.groupName}-${index}`} + className="border rounded-lg p-4 hover:bg-muted/30 transition-colors" + > + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="font-mono text-xs"> + {item.code} + </Badge> + <Badge variant="secondary" className="text-xs"> + {item.inputFormat} + </Badge> + </div> + <h4 className="font-medium text-sm"> + {item.checkPoint} + </h4> + {item.description && ( + <p className="text-sm text-muted-foreground"> + {item.description} + </p> + )} + </div> + + <div className="space-y-2"> + <div> + <label className="text-xs font-medium text-muted-foreground">답변</label> + <p className="text-sm mt-1 p-2 bg-muted/50 rounded border min-h-[2.5rem]"> + {item.answer || "답변 없음"} + </p> + </div> + + {item.attachments && item.attachments.length > 0 && ( + <div> + <label className="text-xs font-medium text-muted-foreground">첨부파일</label> + <div className="mt-1 space-y-1"> + {item.attachments.map((attachment, idx) => ( + <Button + key={idx} + variant="outline" + size="sm" + onClick={() => handleFileDownload(attachment.filePath, attachment.fileName)} + className="h-8 w-full justify-start text-xs" + > + <FileText className="w-3 h-3 mr-2" /> + <span className="truncate flex-1 text-left"> + {attachment.fileName} + </span> + <Download className="w-3 h-3 ml-2" /> + </Button> + ))} + </div> + </div> + )} + </div> + </div> + </div> + ))} + </div> + </CardContent> + )} + </Card> + ) + } + + if (projects.length === 0 && !loading) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>PQ 조회</DialogTitle> + </DialogHeader> + <div className="text-center py-8"> + <p className="text-muted-foreground">제출된 PQ가 없습니다.</p> + </div> + </DialogContent> + </Dialog> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>PQ 조회</DialogTitle> + </DialogHeader> + + {loading ? ( + <div className="text-center py-8"> + <p className="text-muted-foreground">로딩 중...</p> + </div> + ) : selectedProject ? ( + <div className="space-y-4"> + {/* 프로젝트 선택 */} + {projects.length > 1 && ( + <div className="space-y-2"> + <label className="text-sm font-medium">프로젝트 선택</label> + <Select + value={selectedProject.id.toString()} + onValueChange={(value) => { + const project = projects.find(p => p.id.toString() === value) + if (project) handleProjectChange(project) + }} + > + <SelectTrigger> + <SelectValue placeholder="프로젝트를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {projects.map((project) => ( + <SelectItem key={project.id} value={project.id.toString()}> + <div className="flex flex-col items-start"> + <span className="font-medium">{project.projectCode}</span> + <span className="text-xs text-muted-foreground"> + {project.projectName} + </span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {/* 프로젝트 정보 카드 */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <CardTitle className="text-lg">{selectedProject.projectName}</CardTitle> + <Badge + variant={selectedProject.status === 'APPROVED' ? 'default' : 'secondary'} + className="text-xs" + > + {selectedProject.status} + </Badge> + </div> + </div> + <div className="text-sm text-muted-foreground"> + <span className="font-medium">프로젝트 코드:</span> {selectedProject.projectCode} • + <span className="font-medium">제출일:</span> {selectedProject.submittedAt ? new Date(selectedProject.submittedAt).toLocaleDateString('ko-KR') : '-'} + </div> + </CardHeader> + </Card> + + {/* 검색 및 PQ 그룹 데이터 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">PQ 항목</h3> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setExpandedGroups(new Set(processedPQData.map(g => g.groupName)))} + > + 모두 펼치기 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => setExpandedGroups(new Set())} + > + 모두 접기 + </Button> + </div> + </div> + + {/* 검색 박스 */} + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" /> + <Input + placeholder="항목 검색 (체크포인트, 세부내용, 코드)" + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-10" + /> + {searchTerm && ( + <Button + variant="ghost" + size="sm" + onClick={() => setSearchTerm("")} + className="absolute right-2 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0" + > + × + </Button> + )} + </div> + + {/* 검색 결과 카운트 */} + {searchTerm && ( + <div className="text-sm text-muted-foreground"> + 검색 결과: {processedPQData.reduce((total, group) => total + group.items.length, 0)}개 항목 + ({processedPQData.length}개 그룹) + </div> + )} + + {/* PQ 그룹 목록 */} + {processedPQData.length > 0 ? ( + processedPQData.map((groupData) => renderPQContent(groupData)) + ) : ( + <div className="text-center py-8"> + <p className="text-muted-foreground"> + {searchTerm ? "검색 결과가 없습니다." : "PQ 데이터가 없습니다."} + </p> + </div> + )} + </div> + </div> + ) : null} + </DialogContent> + </Dialog> + ) +} diff --git a/components/vendor-regular-registrations/additional-info-dialog.tsx b/components/vendor-regular-registrations/additional-info-dialog.tsx index 84475877..303c6d7e 100644 --- a/components/vendor-regular-registrations/additional-info-dialog.tsx +++ b/components/vendor-regular-registrations/additional-info-dialog.tsx @@ -69,6 +69,7 @@ interface AdditionalInfoDialogProps { onOpenChange: (open: boolean) => void;
vendorId: number;
onSave?: () => void;
+ readonly?: boolean;
}
const contactTypes = [
@@ -86,6 +87,7 @@ export function AdditionalInfoDialog({ onOpenChange,
vendorId,
onSave,
+ readonly = false,
}: AdditionalInfoDialogProps) {
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
@@ -204,9 +206,12 @@ export function AdditionalInfoDialog({ <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
- <DialogTitle>추가정보 입력</DialogTitle>
+ <DialogTitle>{readonly ? "추가정보 조회" : "추가정보 입력"}</DialogTitle>
<p className="text-sm text-muted-foreground">
- 정규업체 등록을 위한 추가정보를 입력해주세요. <span className="text-red-500">*</span> 표시는 필수 입력 항목입니다.
+ {readonly
+ ? "정규업체 등록을 위한 추가 정보를 조회합니다."
+ : "정규업체 등록을 위한 추가정보를 입력해주세요. * 표시는 필수 입력 항목입니다."
+ }
</p>
</DialogHeader>
@@ -240,9 +245,13 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.contactName`}
render={({ field }) => (
<FormItem>
- <FormLabel>담당자명 *</FormLabel>
+ <FormLabel>담당자명 {!readonly && "*"}</FormLabel>
<FormControl>
- <Input placeholder="담당자명 입력" {...field} />
+ <Input
+ placeholder={readonly ? "" : "담당자명 입력"}
+ readOnly={readonly}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -253,9 +262,13 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.position`}
render={({ field }) => (
<FormItem>
- <FormLabel>직급 *</FormLabel>
+ <FormLabel>직급 {!readonly && "*"}</FormLabel>
<FormControl>
- <Input placeholder="직급 입력" {...field} />
+ <Input
+ placeholder={readonly ? "" : "직급 입력"}
+ readOnly={readonly}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -268,9 +281,13 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.department`}
render={({ field }) => (
<FormItem>
- <FormLabel>부서 *</FormLabel>
+ <FormLabel>부서 {!readonly && "*"}</FormLabel>
<FormControl>
- <Input placeholder="부서명 입력" {...field} />
+ <Input
+ placeholder={readonly ? "" : "부서명 입력"}
+ readOnly={readonly}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -281,9 +298,14 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.email`}
render={({ field }) => (
<FormItem>
- <FormLabel>Email *</FormLabel>
+ <FormLabel>Email {!readonly && "*"}</FormLabel>
<FormControl>
- <Input placeholder="이메일 입력" type="email" {...field} />
+ <Input
+ placeholder={readonly ? "" : "이메일 입력"}
+ type="email"
+ readOnly={readonly}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -295,11 +317,12 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.responsibility`}
render={({ field }) => (
<FormItem>
- <FormLabel>담당업무 *</FormLabel>
+ <FormLabel>담당업무 {!readonly && "*"}</FormLabel>
<FormControl>
<Textarea
- placeholder="담당업무 상세 입력"
+ placeholder={readonly ? "" : "담당업무 상세 입력"}
className="h-20"
+ readOnly={readonly}
{...field}
/>
</FormControl>
@@ -325,9 +348,13 @@ export function AdditionalInfoDialog({ name="additionalInfo.businessType"
render={({ field }) => (
<FormItem>
- <FormLabel>사업유형 *</FormLabel>
+ <FormLabel>사업유형 {!readonly && "*"}</FormLabel>
<FormControl>
- <Input placeholder="사업유형 입력" {...field} />
+ <Input
+ placeholder={readonly ? "" : "사업유형 입력"}
+ readOnly={readonly}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -338,9 +365,13 @@ export function AdditionalInfoDialog({ name="additionalInfo.industryType"
render={({ field }) => (
<FormItem>
- <FormLabel>산업유형 *</FormLabel>
+ <FormLabel>산업유형 {!readonly && "*"}</FormLabel>
<FormControl>
- <Input placeholder="산업유형 입력" {...field} />
+ <Input
+ placeholder={readonly ? "" : "산업유형 입력"}
+ readOnly={readonly}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -353,9 +384,13 @@ export function AdditionalInfoDialog({ name="additionalInfo.companySize"
render={({ field }) => (
<FormItem>
- <FormLabel>기업규모 *</FormLabel>
+ <FormLabel>기업규모 {!readonly && "*"}</FormLabel>
<FormControl>
- <Input placeholder="기업규모 입력" {...field} />
+ <Input
+ placeholder={readonly ? "" : "기업규모 입력"}
+ readOnly={readonly}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -366,11 +401,12 @@ export function AdditionalInfoDialog({ name="additionalInfo.revenue"
render={({ field }) => (
<FormItem>
- <FormLabel>매출액 (억원) *</FormLabel>
+ <FormLabel>매출액 (억원) {!readonly && "*"}</FormLabel>
<FormControl>
<Input
- placeholder="매출액 입력"
+ placeholder={readonly ? "" : "매출액 입력"}
type="number"
+ readOnly={readonly}
{...field}
/>
</FormControl>
@@ -385,11 +421,12 @@ export function AdditionalInfoDialog({ name="additionalInfo.factoryEstablishedDate"
render={({ field }) => (
<FormItem>
- <FormLabel>공장설립일 *</FormLabel>
+ <FormLabel>공장설립일 {!readonly && "*"}</FormLabel>
<FormControl>
<Input
- placeholder="YYYY-MM-DD"
+ placeholder={readonly ? "" : "YYYY-MM-DD"}
type="date"
+ readOnly={readonly}
{...field}
/>
</FormControl>
@@ -403,11 +440,12 @@ export function AdditionalInfoDialog({ name="additionalInfo.preferredContractTerms"
render={({ field }) => (
<FormItem>
- <FormLabel>선호계약조건 *</FormLabel>
+ <FormLabel>선호계약조건 {!readonly && "*"}</FormLabel>
<FormControl>
<Textarea
- placeholder="선호하는 계약조건을 상세히 입력해주세요"
+ placeholder={readonly ? "" : "선호하는 계약조건을 상세히 입력해주세요"}
className="h-32"
+ readOnly={readonly}
{...field}
/>
</FormControl>
@@ -421,17 +459,28 @@ export function AdditionalInfoDialog({ </Tabs>
<DialogFooter className="mt-6">
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={saving}
- >
- 취소
- </Button>
- <Button type="submit" disabled={saving}>
- {saving ? "저장 중..." : "저장"}
- </Button>
+ {readonly ? (
+ <Button
+ type="button"
+ onClick={() => onOpenChange(false)}
+ >
+ 닫기
+ </Button>
+ ) : (
+ <>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={saving}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={saving}>
+ {saving ? "저장 중..." : "저장"}
+ </Button>
+ </>
+ )}
</DialogFooter>
</form>
</Form>
|
