From e065835c4dc7a6c0b4bdef8382cb39e7ccf79d1f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 16:07:44 +0900 Subject: [PATCH 1/9] feat: Add PK and index management APIs for table management - Implemented new API endpoints for managing primary keys and indexes in the table management system. - Added functionality to retrieve table constraints, set primary keys, toggle indexes, and manage NOT NULL constraints. - Enhanced the frontend to support PK and index management, including loading constraints and handling user interactions for toggling indexes and setting primary keys. - Improved error handling and logging for better debugging and user feedback during these operations. --- .../controllers/tableManagementController.ts | 257 ++++++++++++ .../src/routes/tableManagementRoutes.ts | 28 ++ .../admin/systemMng/tableMngList/page.tsx | 386 +++++++++++------- 3 files changed, 529 insertions(+), 142 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index a494ae3d..320ab74b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2447,3 +2447,260 @@ export async function getReferencedByTables( res.status(500).json(response); } } + +// ======================================== +// PK / 인덱스 관리 API +// ======================================== + +/** + * PK/인덱스 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +export async function getTableConstraints( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + if (!tableName) { + res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); + return; + } + + // PK 조회 + const pkResult = await query( + `SELECT tc.conname AS constraint_name, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p' + GROUP BY tc.conname`, + [tableName] + ); + + // array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환 + const parseColumns = (cols: any): string[] => { + if (Array.isArray(cols)) return cols; + if (typeof cols === "string") { + // PostgreSQL 배열 형식: {col1,col2} + return cols.replace(/[{}]/g, "").split(",").filter(Boolean); + } + return []; + }; + + const primaryKey = pkResult.length > 0 + ? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) } + : { name: "", columns: [] }; + + // 인덱스 조회 (PK 인덱스 제외) + const indexResult = await query( + `SELECT i.relname AS index_name, + ix.indisunique AS is_unique, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_namespace ns ON t.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND t.relname = $1 + AND ix.indisprimary = false + GROUP BY i.relname, ix.indisunique + ORDER BY i.relname`, + [tableName] + ); + + const indexes = indexResult.map((row: any) => ({ + name: row.index_name, + columns: parseColumns(row.columns), + isUnique: row.is_unique, + })); + + logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`); + + res.status(200).json({ + success: true, + data: { primaryKey, indexes }, + }); + } catch (error) { + logger.error("제약조건 조회 오류:", error); + res.status(500).json({ + success: false, + message: "제약조건 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * PK 설정 + * PUT /api/table-management/tables/:tableName/primary-key + */ +export async function setTablePrimaryKey( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columns } = req.body; + + if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) { + res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." }); + return; + } + + logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`); + + // 기존 PK 제약조건 이름 조회 + const existingPk = await query( + `SELECT conname FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`, + [tableName] + ); + + // 기존 PK 삭제 + if (existingPk.length > 0) { + const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`; + logger.info(`기존 PK 삭제: ${dropSql}`); + await query(dropSql); + } + + // 새 PK 추가 + const colList = columns.map((c: string) => `"${c}"`).join(", "); + const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`; + logger.info(`새 PK 추가: ${addSql}`); + await query(addSql); + + res.status(200).json({ + success: true, + message: `PK가 설정되었습니다: ${columns.join(", ")}`, + }); + } catch (error) { + logger.error("PK 설정 오류:", error); + res.status(500).json({ + success: false, + message: "PK 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +export async function toggleTableIndex( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columnName, indexType, action } = req.body; + + if (!tableName || !columnName || !indexType || !action) { + res.status(400).json({ + success: false, + message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.", + }); + return; + } + + const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`; + + logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); + + if (action === "create") { + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; + const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + logger.info(`인덱스 생성: ${sql}`); + await query(sql); + } else if (action === "drop") { + const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`; + logger.info(`인덱스 삭제: ${sql}`); + await query(sql); + } else { + res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." }); + return; + } + + res.status(200).json({ + success: true, + message: action === "create" + ? `인덱스가 생성되었습니다: ${indexName}` + : `인덱스가 삭제되었습니다: ${indexName}`, + }); + } catch (error: any) { + logger.error("인덱스 토글 오류:", error); + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 + const errorMsg = error.message?.includes("duplicate key") + ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." + : "인덱스 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +export async function toggleColumnNullable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { nullable } = req.body; + + if (!tableName || !columnName || typeof nullable !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, nullable(boolean)이 필요합니다.", + }); + return; + } + + if (nullable) { + // NOT NULL 해제 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; + logger.info(`NOT NULL 해제: ${sql}`); + await query(sql); + } else { + // NOT NULL 설정 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; + logger.info(`NOT NULL 설정: ${sql}`); + await query(sql); + } + + res.status(200).json({ + success: true, + message: nullable + ? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.` + : `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`, + }); + } catch (error: any) { + logger.error("NOT NULL 토글 오류:", error); + + // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 + const errorMsg = error.message?.includes("contains null values") + ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." + : "NOT NULL 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index b9cf43c5..d02a5615 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -28,6 +28,10 @@ import { multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 + getTableConstraints, // 🆕 PK/인덱스 상태 조회 + setTablePrimaryKey, // 🆕 PK 설정 + toggleTableIndex, // 🆕 인덱스 토글 + toggleColumnNullable, // 🆕 NOT NULL 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings); */ router.get("/tables/:tableName/schema", getTableSchema); +/** + * PK/인덱스 제약조건 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +router.get("/tables/:tableName/constraints", getTableConstraints); + +/** + * PK 설정 (기존 PK DROP 후 재생성) + * PUT /api/table-management/tables/:tableName/primary-key + */ +router.put("/tables/:tableName/primary-key", setTablePrimaryKey); + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +router.post("/tables/:tableName/indexes", toggleTableIndex); + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 17c52897..3a159700 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -145,6 +145,14 @@ export default function TableManagementPage() { const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + // PK/인덱스 관리 상태 + const [constraints, setConstraints] = useState<{ + primaryKey: { name: string; columns: string[] }; + indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>; + }>({ primaryKey: { name: "", columns: [] }, indexes: [] }); + const [pkDialogOpen, setPkDialogOpen] = useState(false); + const [pendingPkColumns, setPendingPkColumns] = useState([]); + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -397,6 +405,19 @@ export default function TableManagementPage() { } }, []); + // PK/인덱스 제약조건 로드 + const loadConstraints = useCallback(async (tableName: string) => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`); + if (response.data.success) { + setConstraints(response.data.data); + } + } catch (error) { + console.error("제약조건 로드 실패:", error); + setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] }); + } + }, []); + // 테이블 선택 const handleTableSelect = useCallback( (tableName: string) => { @@ -410,8 +431,9 @@ export default function TableManagementPage() { setTableDescription(tableInfo?.description || ""); loadColumnTypes(tableName, 1, pageSize); + loadConstraints(tableName); }, - [loadColumnTypes, pageSize, tables], + [loadColumnTypes, loadConstraints, pageSize, tables], ); // 입력 타입 변경 @@ -1000,6 +1022,123 @@ export default function TableManagementPage() { } }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); + // PK 체크박스 변경 핸들러 + const handlePkToggle = useCallback( + (columnName: string, checked: boolean) => { + const currentPkCols = [...constraints.primaryKey.columns]; + let newPkCols: string[]; + if (checked) { + newPkCols = [...currentPkCols, columnName]; + } else { + newPkCols = currentPkCols.filter((c) => c !== columnName); + } + // PK 변경은 확인 다이얼로그 표시 + setPendingPkColumns(newPkCols); + setPkDialogOpen(true); + }, + [constraints.primaryKey.columns], + ); + + // PK 변경 확인 + const handlePkConfirm = async () => { + if (!selectedTable) return; + try { + if (pendingPkColumns.length === 0) { + toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다."); + setPkDialogOpen(false); + return; + } + const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, { + columns: pendingPkColumns, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "PK 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다."); + } finally { + setPkDialogOpen(false); + } + }; + + // 인덱스 토글 핸들러 + const handleIndexToggle = useCallback( + async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + if (!selectedTable) return; + const action = checked ? "create" : "drop"; + try { + const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, { + columnName, + indexType, + action, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "인덱스 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable, loadConstraints], + ); + + // 컬럼별 인덱스 상태 헬퍼 + const getColumnIndexState = useCallback( + (columnName: string) => { + const isPk = constraints.primaryKey.columns.includes(columnName); + const hasIndex = constraints.indexes.some( + (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + const hasUnique = constraints.indexes.some( + (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + return { isPk, hasIndex, hasUnique }; + }, + [constraints], + ); + + // NOT NULL 토글 핸들러 + const handleNullableToggle = useCallback( + async (columnName: string, currentIsNullable: string) => { + if (!selectedTable) return; + // isNullable이 "YES"면 nullable, "NO"면 NOT NULL + // 체크박스 체크 = NOT NULL 설정 (nullable: false) + // 체크박스 해제 = NOT NULL 해제 (nullable: true) + const isCurrentlyNotNull = currentIsNullable === "NO"; + const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정 + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/nullable`, + { nullable: newNullable }, + ); + if (response.data.success) { + toast.success(response.data.message); + // 컬럼 상태 로컬 업데이트 + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isNullable: newNullable ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "NOT NULL 설정 실패"); + } + } catch (error: any) { + toast.error( + error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.", + ); + } + }, + [selectedTable], + ); + // 테이블 삭제 확인 const handleDeleteTableClick = (tableName: string) => { setTableToDelete(tableName); @@ -1391,12 +1530,16 @@ export default function TableManagementPage() { {/* 컬럼 헤더 (고정) */}
-
컬럼명
-
라벨
+
라벨
+
컬럼명
입력 타입
설명
+
Primary
+
NotNull
+
Index
+
Unique
{/* 컬럼 리스트 (스크롤 영역) */} @@ -1410,16 +1553,15 @@ export default function TableManagementPage() { } }} > - {columns.map((column, index) => ( + {columns.map((column, index) => { + const idxState = getColumnIndexState(column.columnName); + return (
-
-
{column.columnName}
-
-
+
handleLabelChange(column.columnName, e.target.value)} @@ -1427,6 +1569,9 @@ export default function TableManagementPage() { className="h-8 text-xs" />
+
+
{column.columnName}
+
{/* 입력 타입 선택 */} @@ -1689,141 +1834,11 @@ export default function TableManagementPage() {
)} - {/* 표시 컬럼 - 검색 가능한 Combobox */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && ( -
- - - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: open, - }, - })) - } - > - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - "none", - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - - -- 선택 안함 -- - - {referenceTableColumns[column.referenceTable]?.map((refCol) => ( - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - refCol.columnName, - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - -
- {refCol.columnName} - {refCol.columnLabel && ( - - {refCol.columnLabel} - - )} -
-
- ))} -
-
-
-
-
-
- )} - {/* 설정 완료 표시 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( + column.referenceColumn !== "none" && (
설정 완료 @@ -1953,8 +1968,49 @@ export default function TableManagementPage() { className="h-8 w-full text-xs" />
+ {/* PK 체크박스 */} +
+ + handlePkToggle(column.columnName, checked as boolean) + } + aria-label={`${column.columnName} PK 설정`} + /> +
+ {/* NN (NOT NULL) 체크박스 */} +
+ + handleNullableToggle(column.columnName, column.isNullable) + } + aria-label={`${column.columnName} NOT NULL 설정`} + /> +
+ {/* IDX 체크박스 */} +
+ + handleIndexToggle(column.columnName, "index", checked as boolean) + } + aria-label={`${column.columnName} 인덱스 설정`} + /> +
+ {/* UQ 체크박스 */} +
+ + handleIndexToggle(column.columnName, "unique", checked as boolean) + } + aria-label={`${column.columnName} 유니크 설정`} + /> +
- ))} + ); + })} {/* 로딩 표시 */} {columnsLoading && ( @@ -2120,6 +2176,52 @@ export default function TableManagementPage() { )} + {/* PK 변경 확인 다이얼로그 */} + + + + PK 변경 확인 + + PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다. +
데이터 무결성에 영향을 줄 수 있습니다. +
+
+ +
+
+

변경될 PK 컬럼:

+ {pendingPkColumns.length > 0 ? ( +
+ {pendingPkColumns.map((col) => ( + + {col} + + ))} +
+ ) : ( +

PK가 모두 제거됩니다

+ )} +
+
+ + + + + +
+
+ {/* Scroll to Top 버튼 */}
From 56d069f853e86b5f63411d2e50afe659761696c2 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 18:29:36 +0900 Subject: [PATCH 2/9] feat: Enhance master-detail Excel upload functionality with detail update tracking - Added support for tracking updated detail records during the Excel upload process, improving feedback to users on the number of records inserted and updated. - Updated response messages to provide clearer information about the processing results, including the number of newly inserted and updated detail records. - Refactored related components to ensure consistency in handling detail updates and improve overall user experience during uploads. --- backend-node/src/routes/dataRoutes.ts | 8 +- .../src/services/masterDetailExcelService.ts | 316 ++++++++++++++++-- .../admin/systemMng/tableMngList/page.tsx | 39 ++- .../components/common/ExcelUploadModal.tsx | 110 +++++- .../dataflow/node-editor/FlowToolbar.tsx | 23 +- 5 files changed, 454 insertions(+), 42 deletions(-) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 02dfc1e8..c4c80e19 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -166,14 +166,20 @@ router.post( masterInserted: result.masterInserted, masterUpdated: result.masterUpdated, detailInserted: result.detailInserted, + detailUpdated: result.detailUpdated, errors: result.errors.length, }); + const detailTotal = result.detailInserted + (result.detailUpdated || 0); + const detailMsg = result.detailUpdated + ? `디테일 신규 ${result.detailInserted}건, 수정 ${result.detailUpdated}건` + : `디테일 ${result.detailInserted}건`; + return res.json({ success: result.success, data: result, message: result.success - ? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` + ? `마스터 ${result.masterInserted + result.masterUpdated}건, ${detailMsg} 처리되었습니다.` : "업로드 중 오류가 발생했습니다.", }); } catch (error: any) { diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index fa19c0a0..40cd58e3 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -78,6 +78,7 @@ export interface ExcelUploadResult { masterInserted: number; masterUpdated: number; detailInserted: number; + detailUpdated: number; detailDeleted: number; errors: string[]; } @@ -517,11 +518,6 @@ class MasterDetailExcelService { params ); - logger.info(`채번 컬럼 조회 결과: ${tableName}.${columnName}`, { - rowCount: result.length, - rows: result.map((r: any) => ({ input_type: r.input_type, company_code: r.company_code })), - }); - // 채번 타입인 행 찾기 (회사별 우선) for (const row of result) { if (row.input_type === "numbering") { @@ -530,13 +526,11 @@ class MasterDetailExcelService { : row.detail_settings; if (settings?.numberingRuleId) { - logger.info(`채번 컬럼 감지: ${tableName}.${columnName} → 규칙 ID: ${settings.numberingRuleId} (company: ${row.company_code})`); return { numberingRuleId: settings.numberingRuleId }; } } } - logger.info(`채번 컬럼 아님: ${tableName}.${columnName}`); return null; } catch (error) { logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error); @@ -544,6 +538,118 @@ class MasterDetailExcelService { } } + /** + * 특정 테이블의 모든 채번 컬럼을 한 번에 조회 + * 회사별 설정 우선, 공통(*) 설정 fallback + * @returns Map + */ + private async detectAllNumberingColumns( + tableName: string, + companyCode?: string + ): Promise> { + const numberingCols = new Map(); + try { + const companyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($2, '*')` + : `AND company_code = '*'`; + const params = companyCode && companyCode !== "*" + ? [tableName, companyCode] + : [tableName]; + + const result = await query( + `SELECT column_name, detail_settings, company_code + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition} + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + params + ); + + // 컬럼별로 회사 설정 우선 적용 + for (const row of result) { + if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + if (settings?.numberingRuleId) { + numberingCols.set(row.column_name, settings.numberingRuleId); + } + } + + if (numberingCols.size > 0) { + logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols)); + } + } catch (error) { + logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error); + } + return numberingCols; + } + + /** + * 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용) + * PK가 비즈니스 키이면 사용, auto-increment 'id'만이면 유니크 인덱스 탐색 + * @returns 고유 키 컬럼 배열 (빈 배열이면 매칭 불가 → INSERT만 수행) + */ + private async detectUniqueKeyColumns( + client: any, + tableName: string + ): Promise { + try { + // 1. PK 컬럼 조회 + const pkResult = await client.query( + `SELECT array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`, + [tableName] + ); + + if (pkResult.rows.length > 0 && pkResult.rows[0].columns) { + const pkCols: string[] = typeof pkResult.rows[0].columns === "string" + ? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()) + : pkResult.rows[0].columns; + + // PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가 + if (!(pkCols.length === 1 && pkCols[0] === "id")) { + logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`); + return pkCols; + } + } + + // 2. PK가 'id'뿐이면 유니크 인덱스 탐색 + const uqResult = await client.query( + `SELECT array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE n.nspname = 'public' AND t.relname = $1 + AND ix.indisunique = true AND ix.indisprimary = false + GROUP BY i.relname + LIMIT 1`, + [tableName] + ); + + if (uqResult.rows.length > 0 && uqResult.rows[0].columns) { + const uqCols: string[] = typeof uqResult.rows[0].columns === "string" + ? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()) + : uqResult.rows[0].columns; + logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`); + return uqCols; + } + + logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`); + return []; + } catch (error) { + logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error); + return []; + } + } + /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * @@ -551,7 +657,7 @@ class MasterDetailExcelService { * 1. 마스터 키 컬럼이 채번 타입인지 확인 * 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT * 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT - * 3. 디테일 데이터 INSERT + * 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반) */ async uploadJoinedData( relation: MasterDetailRelation, @@ -564,6 +670,7 @@ class MasterDetailExcelService { masterInserted: 0, masterUpdated: 0, detailInserted: 0, + detailUpdated: 0, detailDeleted: 0, errors: [], }; @@ -633,30 +740,78 @@ class MasterDetailExcelService { logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`); } + // 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회) + const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode); + // 마스터 테이블의 비-키 채번 컬럼도 감지 + const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode); + + // 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용) + // PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색 + const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable); + // 각 그룹 처리 for (const [groupKey, rows] of groupedData.entries()) { try { // 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키) let masterKey: string; + let existingMasterKey: string | null = null; + // 마스터 데이터 추출 (첫 번째 행에서, 키 제외) + const masterDataWithoutKey: Record = {}; + for (const col of masterColumns) { + if (col.name === masterKeyColumn) continue; + if (rows[0][col.name] !== undefined) { + masterDataWithoutKey[col.name] = rows[0][col.name]; + } + } + if (isAutoNumbering) { - // 채번 규칙으로 마스터 키 자동 생성 - masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode); - logger.info(`채번 생성: ${masterKey}`); + // 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인 + // 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지) + const matchCols = Object.keys(masterDataWithoutKey) + .filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id" + && masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== ""); + + if (matchCols.length > 0) { + const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND "); + const companyIdx = matchCols.length + 1; + const matchResult = await client.query( + `SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`, + [...matchCols.map(k => masterDataWithoutKey[k]), companyCode] + ); + if (matchResult.rows.length > 0) { + existingMasterKey = matchResult.rows[0][masterKeyColumn]; + logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`); + } + } + + if (existingMasterKey) { + // 기존 마스터 사용 (UPDATE) + masterKey = existingMasterKey; + const updateKeys = matchCols.filter(k => k !== masterKeyColumn); + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map(k => masterDataWithoutKey[k]); + const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + await client.query( + `UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`, + [...setValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + // 새 마스터 생성 (채번) + masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode); + logger.info(`채번 생성: ${masterKey}`); + } } else { masterKey = groupKey; } - // 마스터 데이터 추출 (첫 번째 행에서) + // 마스터 데이터 조립 const masterData: Record = {}; - // 마스터 키 컬럼은 항상 설정 (분할패널 컬럼 목록에 없어도) masterData[masterKeyColumn] = masterKey; - for (const col of masterColumns) { - if (col.name === masterKeyColumn) continue; // 이미 위에서 설정 - if (rows[0][col.name] !== undefined) { - masterData[col.name] = rows[0][col.name]; - } - } + Object.assign(masterData, masterDataWithoutKey); // 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만) if (masterExistingCols.has("company_code")) { @@ -666,6 +821,16 @@ class MasterDetailExcelService { masterData.writer = userId; } + // 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우) + for (const [colName, ruleId] of masterNumberingCols) { + if (colName === masterKeyColumn) continue; + if (!masterData[colName] || masterData[colName] === "") { + const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode); + masterData[colName] = generatedValue; + logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`); + } + } + // INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가) const buildInsertSQL = (table: string, data: Record, existingCols: Set) => { const cols = Object.keys(data); @@ -680,12 +845,12 @@ class MasterDetailExcelService { }; }; - if (isAutoNumbering) { - // 채번 모드: 항상 INSERT (새 마스터 생성) + if (isAutoNumbering && !existingMasterKey) { + // 채번 모드 + 새 마스터: INSERT const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); await client.query(sql, values); result.masterInserted++; - } else { + } else if (!isAutoNumbering) { // 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT) const existingMaster = await client.query( `SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, @@ -716,15 +881,9 @@ class MasterDetailExcelService { result.masterInserted++; } - // 일반 모드에서만 기존 디테일 삭제 (채번 모드는 새 마스터이므로 삭제할 디테일 없음) - const deleteResult = await client.query( - `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); - result.detailDeleted += deleteResult.rowCount || 0; } - // 디테일 INSERT + // 디테일 개별 행 UPSERT 처리 for (const row of rows) { const detailData: Record = {}; @@ -737,16 +896,105 @@ class MasterDetailExcelService { detailData.writer = userId; } - // 디테일 컬럼 데이터 추출 + // 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준) for (const col of detailColumns) { if (row[col.name] !== undefined) { detailData[col.name] = row[col.name]; } } - const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); - await client.query(sql, values); - result.detailInserted++; + // 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함 + // (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리) + const detailColNames = new Set(detailColumns.map(c => c.name)); + const skipCols = new Set([ + detailFkColumn, masterKeyColumn, + "company_code", "writer", "created_date", "updated_date", "id", + ]); + for (const key of Object.keys(row)) { + if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") { + const isMasterCol = masterColumns.some(mc => mc.name === key); + if (!isMasterCol) { + detailData[key] = row[key]; + } + } + } + + // 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입) + for (const [colName, ruleId] of detailNumberingCols) { + if (!detailData[colName] || detailData[colName] === "") { + const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode); + detailData[colName] = generatedValue; + logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`); + } + } + + // 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT + const hasUniqueKey = detailUniqueKeyCols.length > 0; + const uniqueKeyValues = hasUniqueKey + ? detailUniqueKeyCols.map(col => detailData[col]) + : []; + // 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함) + const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== ""); + + if (canMatch) { + // 기존 행 존재 여부 확인 + const whereClause = detailUniqueKeyCols + .map((col, i) => `"${col}" = $${i + 1}`) + .join(" AND "); + const companyParam = detailExistingCols.has("company_code") + ? ` AND company_code = $${detailUniqueKeyCols.length + 1}` + : ""; + const checkParams = detailExistingCols.has("company_code") + ? [...uniqueKeyValues, companyCode] + : uniqueKeyValues; + + const existingRow = await client.query( + `SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`, + checkParams + ); + + if (existingRow.rows.length > 0) { + // UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트 + const updateExclude = new Set([ + ...detailUniqueKeyCols, "id", "company_code", "created_date", + ]); + const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k)); + + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map(k => detailData[k]); + const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + + const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`); + const companyWhere = detailExistingCols.has("company_code") + ? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}` + : ""; + const allValues = [ + ...setValues, + ...uniqueKeyValues, + ...(detailExistingCols.has("company_code") ? [companyCode] : []), + ]; + + await client.query( + `UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`, + allValues + ); + result.detailUpdated = (result.detailUpdated || 0) + 1; + logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`); + } + } else { + // INSERT: 새로운 행 + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); + result.detailInserted++; + logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`); + } + } else { + // 고유 키가 없거나 값이 없으면 INSERT 전용 + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); + result.detailInserted++; + } } } catch (error: any) { result.errors.push(`그룹 처리 실패: ${error.message}`); @@ -761,7 +1009,7 @@ class MasterDetailExcelService { masterInserted: result.masterInserted, masterUpdated: result.masterUpdated, detailInserted: result.detailInserted, - detailDeleted: result.detailDeleted, + detailUpdated: result.detailUpdated, errors: result.errors.length, }); diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 3a159700..cf89df73 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -19,6 +19,7 @@ import { Copy, Check, ChevronsUpDown, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; @@ -140,6 +141,9 @@ export default function TableManagementPage() { const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerTableName, setLogViewerTableName] = useState(""); + // 저장 중 상태 (중복 실행 방지) + const [isSaving, setIsSaving] = useState(false); + // 테이블 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); @@ -779,7 +783,9 @@ export default function TableManagementPage() { // 전체 저장 (테이블 라벨 + 모든 컬럼 설정) const saveAllSettings = async () => { if (!selectedTable) return; + if (isSaving) return; // 저장 중 중복 실행 방지 + setIsSaving(true); try { // 1. 테이블 라벨 저장 (변경된 경우에만) if (tableLabel !== selectedTable || tableDescription) { @@ -974,9 +980,30 @@ export default function TableManagementPage() { } catch (error) { // console.error("설정 저장 실패:", error); toast.error("설정 저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); } }; + // Ctrl+S 단축키: 테이블 설정 전체 저장 + // saveAllSettings를 ref로 참조하여 useEffect 의존성 문제 방지 + const saveAllSettingsRef = useRef(saveAllSettings); + saveAllSettingsRef.current = saveAllSettings; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); // 브라우저 기본 저장 동작 방지 + if (selectedTable && columns.length > 0) { + saveAllSettingsRef.current(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedTable, columns.length]); + // 필터링된 테이블 목록 (메모이제이션) const filteredTables = useMemo( () => @@ -1506,11 +1533,15 @@ export default function TableManagementPage() { {/* 저장 버튼 (항상 보이도록 상단에 배치) */}
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 067d3a45..4797a34a 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -453,6 +453,48 @@ export const ExcelUploadModal: React.FC = ({ } } + // 채번 정보 병합: table_type_columns에서 inputType 가져오기 + try { + const { getTableColumns } = await import("@/lib/api/tableManagement"); + const targetTables = isMasterDetail && masterDetailRelation + ? [masterDetailRelation.masterTable, masterDetailRelation.detailTable] + : [tableName]; + + // 테이블별 채번 컬럼 수집 + const numberingColSet = new Set(); + for (const tbl of targetTables) { + const typeResponse = await getTableColumns(tbl); + if (typeResponse.success && typeResponse.data?.columns) { + for (const tc of typeResponse.data.columns) { + if (tc.inputType === "numbering") { + try { + const settings = typeof tc.detailSettings === "string" + ? JSON.parse(tc.detailSettings) : tc.detailSettings; + if (settings?.numberingRuleId) { + numberingColSet.add(tc.columnName); + } + } catch { /* 파싱 실패 무시 */ } + } + } + } + } + + // systemColumns에 isNumbering 플래그 추가 + if (numberingColSet.size > 0) { + allColumns = allColumns.map((col) => { + const rawName = (col as any).originalName || col.name; + const colName = rawName.includes(".") ? rawName.split(".")[1] : rawName; + if (numberingColSet.has(colName)) { + return { ...col, isNumbering: true } as any; + } + return col; + }); + console.log("✅ 채번 컬럼 감지:", Array.from(numberingColSet)); + } + } catch (error) { + console.warn("채번 정보 로드 실패 (무시):", error); + } + console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns); setSystemColumns(allColumns); @@ -613,6 +655,34 @@ export const ExcelUploadModal: React.FC = ({ } } + // 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증 + if (currentStep === 2) { + // 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장) + const mappedSystemCols = new Set(); + columnMappings.filter((m) => m.systemColumn).forEach((m) => { + const colName = m.systemColumn!; + mappedSystemCols.add(colName); // 원본 (예: user_info.user_id) + if (colName.includes(".")) { + mappedSystemCols.add(colName.split(".")[1]); // dot 뒤 (예: user_id) + } + }); + + const unmappedRequired = systemColumns.filter((col) => { + const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false; + if (col.nullable) return false; + if (mappedSystemCols.has(col.name) || mappedSystemCols.has(rawName)) return false; + if ((col as any).isNumbering) return false; + return true; + }); + + if (unmappedRequired.length > 0) { + const colNames = unmappedRequired.map((c) => c.label || c.name).join(", "); + toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`); + return; + } + } + setCurrentStep((prev) => Math.min(prev + 1, 3)); }; @@ -1397,15 +1467,19 @@ export const ExcelUploadModal: React.FC = ({ 매핑 안함 - {systemColumns.map((col) => ( + {systemColumns.map((col) => { + const isRequired = !col.nullable && !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && !(col as any).isNumbering; + return ( + {isRequired && *} {col.label || col.name} ({col.type}) - ))} + ); + })} {/* 중복 체크 체크박스 */} @@ -1427,6 +1501,38 @@ export const ExcelUploadModal: React.FC = ({ + {/* 미매핑 필수(NOT NULL) 컬럼 경고 */} + {(() => { + const mappedCols = new Set(); + columnMappings.filter((m) => m.systemColumn).forEach((m) => { + const n = m.systemColumn!; + mappedCols.add(n); + if (n.includes(".")) mappedCols.add(n.split(".")[1]); + }); + const missing = systemColumns.filter((col) => { + const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false; + if (col.nullable) return false; + if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false; + if ((col as any).isNumbering) return false; + return true; + }); + if (missing.length === 0) return null; + return ( +
+
+ +
+

필수(NOT NULL) 컬럼이 매핑되지 않았습니다:

+

+ {missing.map((c) => c.label || c.name).join(", ")} +

+
+
+
+ ); + })()} + {/* 중복 체크 안내 */} {duplicateCheckCount > 0 ? (
diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index f136d216..607886f3 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -4,7 +4,7 @@ * 플로우 에디터 상단 툴바 */ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro const [showSaveDialog, setShowSaveDialog] = useState(false); + // Ctrl+S 단축키: 플로우 저장 + const handleSaveRef = useRef<() => void>(); + + useEffect(() => { + handleSaveRef.current = handleSave; + }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + if (!isSaving) { + handleSaveRef.current?.(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isSaving]); + const handleSave = async () => { // 검증 수행 const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges); From 5c6efa861dd0a1a01eaaddedbbfa4a2ae66aa92b Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 10:30:37 +0900 Subject: [PATCH 3/9] feat: Add support for selected rows data handling in TabsWidget - Introduced new props for managing selected rows data, enabling better interaction with tab components. - Added `selectedRowsData` and `onSelectedRowsChange` callbacks to facilitate row selection and updates. - Enhanced the TabsWidget functionality to improve user experience when interacting with tabbed content. --- frontend/components/screen/widgets/TabsWidget.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 6c770e48..2dd0899c 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -26,6 +26,15 @@ interface TabsWidgetProps { isDesignMode?: boolean; onComponentSelect?: (tabId: string, componentId: string) => void; selectedComponentId?: string; + // 테이블 선택된 행 데이터 (버튼 활성화 및 수정/삭제 동작에 필요) + selectedRowsData?: any[]; + onSelectedRowsChange?: ( + selectedRows: any[], + selectedRowsData: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + ) => void; } export function TabsWidget({ @@ -38,6 +47,8 @@ export function TabsWidget({ isDesignMode = false, onComponentSelect, selectedComponentId, + selectedRowsData, + onSelectedRowsChange, }: TabsWidgetProps) { const { setActiveTab, removeTabsComponent } = useActiveTab(); const { @@ -345,6 +356,8 @@ export function TabsWidget({ menuObjid={menuObjid} isDesignMode={isDesignMode} isInteractive={!isDesignMode} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={onSelectedRowsChange} />
); From 14d6406a61a7183075abff9378168340a74c35af Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 11:42:32 +0900 Subject: [PATCH 4/9] feat: Improve selected rows data management in TabsWidget and SplitPanelLayoutComponent - Refactored TabsWidget to manage local selected rows data, enhancing responsiveness to user interactions. - Introduced a new callback for handling selected rows changes, ensuring updates are reflected in both local and parent states. - Updated SplitPanelLayoutComponent to share selected rows data between tabs and buttons, improving data consistency across components. - Enhanced overall user experience by ensuring immediate recognition of selection changes within the tabbed interface. --- .../components/screen/widgets/TabsWidget.tsx | 38 ++++++++++++++++--- .../SplitPanelLayoutComponent.tsx | 29 ++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 2dd0899c..6b0d0864 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { X, Loader2 } from "lucide-react"; @@ -35,6 +35,8 @@ interface TabsWidgetProps { sortOrder?: "asc" | "desc", columnOrder?: string[], ) => void; + // 추가 props (부모에서 전달받은 나머지 props) + [key: string]: any; } export function TabsWidget({ @@ -47,8 +49,9 @@ export function TabsWidget({ isDesignMode = false, onComponentSelect, selectedComponentId, - selectedRowsData, - onSelectedRowsChange, + selectedRowsData: _externalSelectedRowsData, + onSelectedRowsChange: externalOnSelectedRowsChange, + ...restProps }: TabsWidgetProps) { const { setActiveTab, removeTabsComponent } = useActiveTab(); const { @@ -62,6 +65,30 @@ export function TabsWidget({ const storageKey = `tabs-${component.id}-selected`; + // 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용) + // 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식 + const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]); + + // 선택 변경 핸들러: 로컬 상태 업데이트 + 부모 콜백 호출 + const handleSelectedRowsChange = useCallback( + ( + selectedRows: any[], + selectedRowsDataNew: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + ) => { + // 로컬 상태 업데이트 (탭 내부 버튼이 즉시 인식) + setLocalSelectedRowsData(selectedRowsDataNew); + + // 부모 콜백 호출 (부모 상태도 업데이트) + if (externalOnSelectedRowsChange) { + externalOnSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder); + } + }, + [externalOnSelectedRowsChange], + ); + // 초기 선택 탭 결정 const getInitialTab = () => { if (persistSelection && typeof window !== "undefined") { @@ -342,6 +369,7 @@ export function TabsWidget({ }} > ); diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 923c1aa3..83aabc85 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -194,6 +194,17 @@ export const SplitPanelLayoutComponent: React.FC const [selectedLeftItem, setSelectedLeftItem] = useState(null); const [expandedRightItems, setExpandedRightItems] = useState>(new Set()); // 확장된 우측 아이템 const [customLeftSelectedData, setCustomLeftSelectedData] = useState>({}); // 커스텀 모드: 좌측 선택 데이터 + // 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용) + const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]); + const handleLocalSelectedRowsChange = useCallback( + (selectedRows: any[], selectedRowsDataNew: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => { + setLocalSelectedRowsData(selectedRowsDataNew); + if ((props as any).onSelectedRowsChange) { + (props as any).onSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder); + } + }, + [(props as any).onSelectedRowsChange], + ); const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); @@ -2757,8 +2768,17 @@ export const SplitPanelLayoutComponent: React.FC { // 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처 if (data?.selectedRowsData && data.selectedRowsData.length > 0) { @@ -3645,8 +3665,17 @@ export const SplitPanelLayoutComponent: React.FC ); From 70cb50e44647616fcd3e51c01546d96e389f86af Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 14:18:46 +0900 Subject: [PATCH 5/9] feat: Update SplitPanelLayoutComponent to manage custom left selected data - Initialized custom left selected data to an empty object when deselecting an item, ensuring a clean state for the right form. - Passed the selected item to the custom left selected data when an item is selected, improving data handling in custom mode. - Enhanced overall data management within the SplitPanelLayoutComponent for better user experience. --- .../v2-split-panel-layout/SplitPanelLayoutComponent.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index e21977f9..210abe18 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1564,6 +1564,7 @@ export const SplitPanelLayoutComponent: React.FC if (isSameItem) { // 선택 해제 → 전체 데이터 로드 setSelectedLeftItem(null); + setCustomLeftSelectedData({}); // 커스텀 모드 우측 폼 데이터 초기화 setExpandedRightItems(new Set()); setTabsData({}); if (activeTabIndex === 0) { @@ -1584,6 +1585,7 @@ export const SplitPanelLayoutComponent: React.FC } setSelectedLeftItem(item); + setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달 setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setTabsData({}); // 모든 탭 데이터 초기화 From beb873f9f165fb3323f47cd671d97c6fcf886732 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 14:19:22 +0900 Subject: [PATCH 6/9] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node From fb02e5b389b1241423652d714a1ddb778f52139a Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 14:54:14 +0900 Subject: [PATCH 7/9] feat: Enhance SplitPanelLayout with modal support for add and edit buttons - Implemented modal configuration for add and edit buttons in the SplitPanelLayoutComponent, allowing for custom modal screens based on user interactions. - Added settings for button visibility and modes (auto or modal) in the SplitPanelLayoutConfigPanel, improving flexibility in UI configuration. - Enhanced data handling by storing selected left panel items in modalDataStore for use in modal screens, ensuring seamless data flow. - Updated types to include new properties for add and edit button configurations, facilitating better type safety and clarity in component usage. --- backend-node/package-lock.json | 14 +- .../SplitPanelLayoutComponent.tsx | 186 +++++++++-- .../SplitPanelLayoutConfigPanel.tsx | 298 ++++++++++++++++++ .../components/v2-split-panel-layout/types.ts | 33 ++ 4 files changed, 496 insertions(+), 35 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index c365a102..ae55e3c4 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1045,7 +1045,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2373,7 +2372,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3477,7 +3475,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3714,7 +3711,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3932,7 +3928,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4459,7 +4454,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5670,7 +5664,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5949,7 +5942,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7443,7 +7435,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8413,6 +8404,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9301,7 +9293,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10152,6 +10143,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10960,7 +10952,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11066,7 +11057,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index ad26004b..689a2407 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1993,6 +1993,88 @@ export const SplitPanelLayoutComponent: React.FC // 추가 버튼 핸들러 const handleAddClick = useCallback( (panel: "left" | "right") => { + // 좌측 패널 추가 시, addButton 모달 모드 확인 + if (panel === "left") { + const addButtonConfig = componentConfig.leftPanel?.addButton; + if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { + const leftTableName = componentConfig.leftPanel?.tableName || ""; + + // ScreenModal 열기 이벤트 발생 + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: addButtonConfig.modalScreenId, + urlParams: { + mode: "add", + tableName: leftTableName, + }, + }, + }), + ); + + console.log("✅ [SplitPanel] 좌측 추가 모달 화면 열기:", { + screenId: addButtonConfig.modalScreenId, + tableName: leftTableName, + }); + return; + } + } + + // 우측 패널 추가 시, addButton 모달 모드 확인 + if (panel === "right") { + const addButtonConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel?.addButton + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton; + + if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { + // 커스텀 모달 화면 열기 + const currentTableName = + activeTabIndex === 0 + ? componentConfig.rightPanel?.tableName || "" + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; + + // 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능) + if (selectedLeftItem && componentConfig.leftPanel?.tableName) { + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]); + }); + } + + // ScreenModal 열기 이벤트 발생 + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: addButtonConfig.modalScreenId, + urlParams: { + mode: "add", + tableName: currentTableName, + // 좌측 선택 항목의 연결 키 값 전달 + ...(selectedLeftItem && (() => { + const relation = activeTabIndex === 0 + ? componentConfig.rightPanel?.relation + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; + const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn; + const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey; + if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) { + return { [rightColumn]: selectedLeftItem[leftColumn] }; + } + return {}; + })()), + }, + }, + }), + ); + + console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { + screenId: addButtonConfig.modalScreenId, + tableName: currentTableName, + }); + return; + } + } + + // 기존 내장 추가 모달 로직 setAddModalPanel(panel); // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 @@ -2012,12 +2094,66 @@ export const SplitPanelLayoutComponent: React.FC setShowAddModal(true); }, - [selectedLeftItem, componentConfig], + [selectedLeftItem, componentConfig, activeTabIndex], ); // 수정 버튼 핸들러 const handleEditClick = useCallback( (panel: "left" | "right", item: any) => { + // 좌측 패널 수정 버튼 설정 확인 (모달 모드) + if (panel === "left") { + const editButtonConfig = componentConfig.leftPanel?.editButton; + if (editButtonConfig?.mode === "modal" && editButtonConfig?.modalScreenId) { + const leftTableName = componentConfig.leftPanel?.tableName || ""; + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; + + // Primary Key 찾기 + let primaryKeyName = sourceColumn; + let primaryKeyValue = item[sourceColumn]; + + if (primaryKeyValue === undefined || primaryKeyValue === null) { + if (item.id !== undefined && item.id !== null) { + primaryKeyName = "id"; + primaryKeyValue = item.id; + } else if (item.ID !== undefined && item.ID !== null) { + primaryKeyName = "ID"; + primaryKeyValue = item.ID; + } else { + const firstKey = Object.keys(item)[0]; + primaryKeyName = firstKey; + primaryKeyValue = item[firstKey]; + } + } + + // modalDataStore에 저장 + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(leftTableName, [item]); + }); + + // ScreenModal 열기 이벤트 발생 + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: editButtonConfig.modalScreenId, + urlParams: { + mode: "edit", + editId: primaryKeyValue, + tableName: leftTableName, + }, + }, + }), + ); + + console.log("✅ [SplitPanel] 좌측 수정 모달 화면 열기:", { + screenId: editButtonConfig.modalScreenId, + tableName: leftTableName, + primaryKeyName, + primaryKeyValue, + }); + return; + } + } + // 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원) if (panel === "right") { const editButtonConfig = @@ -3339,29 +3475,33 @@ export const SplitPanelLayoutComponent: React.FC {/* 항목별 버튼들 */} {!isDesignMode && (
- {/* 수정 버튼 */} - + {/* 수정 버튼 (showEdit 활성화 시에만 표시) */} + {(componentConfig.leftPanel?.showEdit !== false) && ( + + )} - {/* 삭제 버튼 */} - + {/* 삭제 버튼 (showDelete 활성화 시에만 표시) */} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} {/* 항목별 추가 버튼 */} {componentConfig.leftPanel?.showItemAddButton && ( diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index ab3e9af8..7c17c979 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1066,6 +1066,62 @@ const AdditionalTabConfigPanel: React.FC = ({
)} + {/* ===== 10-1. 추가 버튼 설정 ===== */} + {tab.showAdd && ( +
+

추가 버튼 설정

+
+
+ + +
+ + {tab.addButton?.mode === "modal" && ( +
+ + { + updateTab({ + addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }); + }} + /> +
+ )} + +
+ + { + updateTab({ + addButton: { ...tab.addButton, enabled: true, buttonLabel: e.target.value || undefined }, + }); + }} + placeholder="추가" + className="h-7 text-xs" + /> +
+
+
+ )} + {/* ===== 11. 삭제 버튼 설정 ===== */} {tab.showDelete && (
@@ -2071,6 +2127,169 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ + {/* 좌측 패널 버튼 설정 */} +
+

좌측 패널 버튼 설정

+ + {/* 버튼 표시 체크박스 */} +
+
+ updateLeftPanel({ showSearch: !!checked })} + /> + +
+
+ updateLeftPanel({ showAdd: !!checked })} + /> + +
+
+ updateLeftPanel({ showEdit: !!checked })} + /> + +
+
+ updateLeftPanel({ showDelete: !!checked })} + /> + +
+
+ + {/* 추가 버튼 상세 설정 */} + {config.leftPanel?.showAdd && ( +
+

추가 버튼 설정

+
+
+ + +
+ + {config.leftPanel?.addButton?.mode === "modal" && ( +
+ + + updateLeftPanel({ + addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }) + } + /> +
+ )} + +
+ + + updateLeftPanel({ + addButton: { + ...config.leftPanel?.addButton, + enabled: true, + mode: config.leftPanel?.addButton?.mode || "auto", + buttonLabel: e.target.value || undefined, + }, + }) + } + placeholder="추가" + className="h-7 text-xs" + /> +
+
+
+ )} + + {/* 수정 버튼 상세 설정 */} + {(config.leftPanel?.showEdit ?? true) && ( +
+

수정 버튼 설정

+
+
+ + +
+ + {config.leftPanel?.editButton?.mode === "modal" && ( +
+ + + updateLeftPanel({ + editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }) + } + /> +
+ )} + +
+ + + updateLeftPanel({ + editButton: { + ...config.leftPanel?.editButton, + enabled: true, + mode: config.leftPanel?.editButton?.mode || "auto", + buttonLabel: e.target.value || undefined, + }, + }) + } + placeholder="수정" + className="h-7 text-xs" + /> +
+
+
+ )} +
@@ -2775,6 +2994,85 @@ export const SplitPanelLayoutConfigPanel: React.FC + {/* 🆕 우측 패널 추가 버튼 설정 */} + {config.rightPanel?.showAdd && ( +
+
+
+

추가 버튼 설정

+

우측 리스트의 추가 버튼 동작 방식 설정

+
+
+ +
+
+ + +

+ {config.rightPanel?.addButton?.mode === "modal" + ? "지정한 화면을 모달로 열어 데이터를 추가합니다" + : "내장 폼으로 데이터를 추가합니다"} +

+
+ + {config.rightPanel?.addButton?.mode === "modal" && ( +
+ + + updateRightPanel({ + addButton: { + ...config.rightPanel?.addButton!, + modalScreenId: screenId, + }, + }) + } + /> +
+ )} + +
+ + + updateRightPanel({ + addButton: { + ...config.rightPanel?.addButton!, + buttonLabel: e.target.value, + enabled: true, + mode: config.rightPanel?.addButton?.mode || "auto", + }, + }) + } + placeholder="추가" + className="h-8 text-xs" + /> +
+
+
+ )} + {/* 🆕 우측 패널 삭제 버튼 설정 */}
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index a8e6618d..b738d317 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -115,6 +115,14 @@ export interface AdditionalTabConfig { groupByColumns?: string[]; }; + // 추가 버튼 설정 (모달 화면 연결 지원) + addButton?: { + enabled: boolean; + mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "추가") + }; + deleteButton?: { enabled: boolean; buttonLabel?: string; @@ -141,6 +149,23 @@ export interface SplitPanelLayoutConfig { showAdd?: boolean; showEdit?: boolean; // 수정 버튼 showDelete?: boolean; // 삭제 버튼 + + // 수정 버튼 설정 (모달 화면 연결 지원) + editButton?: { + enabled: boolean; + mode: "auto" | "modal"; // auto: 내장 편집, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "수정") + }; + + // 추가 버튼 설정 (모달 화면 연결 지원) + addButton?: { + enabled: boolean; + mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "추가") + }; + columns?: Array<{ name: string; label: string; @@ -307,6 +332,14 @@ export interface SplitPanelLayoutConfig { groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"]) }; + // 🆕 추가 버튼 설정 (모달 화면 연결 지원) + addButton?: { + enabled: boolean; // 추가 버튼 표시 여부 (기본: true) + mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "추가") + }; + // 🆕 삭제 버튼 설정 deleteButton?: { enabled: boolean; // 삭제 버튼 표시 여부 (기본: true) From 505930b3ecc1da57ee086026bada7d4d761a35a8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 15:03:56 +0900 Subject: [PATCH 8/9] feat: Implement custom right panel save functionality in SplitPanelLayoutComponent - Added a new save handler for the custom right panel, allowing users to save inline edit data. - Implemented validation checks to ensure data integrity before saving, including checks for selected items and primary keys. - Enhanced user feedback with toast notifications for success and error states during the save process. - Integrated company_code automatically into the saved data to maintain multi-tenancy compliance. - Updated the UI to include a save button in the custom mode, improving user interaction and data management. --- .../SplitPanelLayoutComponent.tsx | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 689a2407..05c16943 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2261,6 +2261,82 @@ export const SplitPanelLayoutComponent: React.FC [componentConfig, activeTabIndex], ); + // 커스텀 모드 우측 패널 저장 (인라인 편집 데이터) + const handleCustomRightSave = useCallback(async () => { + if (!selectedLeftItem || !customLeftSelectedData || Object.keys(customLeftSelectedData).length === 0) { + toast({ + title: "저장 실패", + description: "저장할 데이터가 없습니다. 좌측에서 항목을 선택해주세요.", + variant: "destructive", + }); + return; + } + + const tableName = componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName; + if (!tableName) { + toast({ + title: "저장 실패", + description: "테이블 정보가 없습니다.", + variant: "destructive", + }); + return; + } + + // Primary Key 찾기 + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; + const primaryKey = selectedLeftItem[sourceColumn] || selectedLeftItem.id || selectedLeftItem.ID; + + if (!primaryKey) { + toast({ + title: "저장 실패", + description: "Primary Key를 찾을 수 없습니다.", + variant: "destructive", + }); + return; + } + + try { + // 프론트엔드 전용 필드 제거 + const cleanData = { ...customLeftSelectedData }; + delete cleanData.children; + delete cleanData.level; + delete cleanData._originalItems; + + // company_code 자동 추가 + if (companyCode && !cleanData.company_code) { + cleanData.company_code = companyCode; + } + + console.log("📝 [SplitPanel] 커스텀 우측 패널 저장:", { tableName, sourceColumn, primaryKey, data: cleanData }); + + const response = await dataApi.updateRecord(tableName, primaryKey, cleanData); + + if (response.success) { + toast({ + title: "저장 완료", + description: "데이터가 저장되었습니다.", + }); + // 좌측 데이터 새로고침 (변경된 항목 반영) + loadLeftData(); + // selectedLeftItem도 업데이트 + setSelectedLeftItem(customLeftSelectedData); + } else { + toast({ + title: "저장 실패", + description: response.error || "데이터 저장에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("커스텀 우측 패널 저장 오류:", error); + toast({ + title: "저장 오류", + description: "데이터 저장 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]); + // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { const tableName = @@ -3618,6 +3694,13 @@ export const SplitPanelLayoutComponent: React.FC
{!isDesignMode && (
+ {/* 커스텀 모드 기본정보 탭: 저장 버튼 */} + {activeTabIndex === 0 && componentConfig.rightPanel?.displayMode === "custom" && selectedLeftItem && ( + + )} {activeTabIndex === 0 ? componentConfig.rightPanel?.showAdd && (
+ ) ) : isLoadingRight ? ( // 로딩 중