summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-21 06:57:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-21 06:57:36 +0000
commit02b1cf005cf3e1df64183d20ba42930eb2767a9f (patch)
treee932c54d5260b0e6fda2b46be2a6ba1c3ee30434 /components
parentd78378ecd7ceede1429359f8058c7a99ac34b1b7 (diff)
(대표님, 최겸) 설계메뉴추가, 작업사항 업데이트
설계메뉴 - 문서관리 설계메뉴 - 벤더 데이터 gtc 메뉴 업데이트 정보시스템 - 메뉴리스트 및 정보 업데이트 파일 라우트 업데이트 엑셀임포트 개선 기본계약 개선 벤더 가입과정 변경 및 개선 벤더 기본정보 - pq 돌체 오류 수정 및 개선 벤더 로그인 과정 이메일 오류 수정
Diffstat (limited to 'components')
-rw-r--r--components/additional-info/join-form.tsx40
-rw-r--r--components/data-table/data-table-view-options.tsx25
-rw-r--r--components/form-data/export-excel-form.tsx156
-rw-r--r--components/form-data/form-data-table-columns.tsx2
-rw-r--r--components/form-data/form-data-table.tsx3
-rw-r--r--components/form-data/import-excel-form.tsx76
-rw-r--r--components/form-data/spreadJS-dialog copy 4.tsx1491
-rw-r--r--components/form-data/spreadJS-dialog.tsx500
-rw-r--r--components/form-data/spreadJS-dialog_designer.tsx1404
-rw-r--r--components/form-data/update-form-sheet.tsx8
-rw-r--r--components/information/information-client.tsx212
-rw-r--r--components/notice/notice-client.tsx37
-rw-r--r--components/notice/notice-create-dialog.tsx22
-rw-r--r--components/notice/notice-edit-sheet.tsx23
-rw-r--r--components/ship-vendor-document-all/user-vendor-document-table-container.tsx898
-rw-r--r--components/ship-vendor-document/user-vendor-document-table-container.tsx5
-rw-r--r--components/signup/join-form.tsx143
-rw-r--r--components/vendor-data/vendor-data-container.tsx333
-rw-r--r--components/vendor-info/pq-simple-dialog.tsx417
-rw-r--r--components/vendor-regular-registrations/additional-info-dialog.tsx119
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>