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);