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.
This commit is contained in:
kjs 2026-02-11 18:29:36 +09:00
parent e065835c4d
commit 56d069f853
5 changed files with 454 additions and 42 deletions

View File

@ -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) {

View File

@ -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<columnName, numberingRuleId>
*/
private async detectAllNumberingColumns(
tableName: string,
companyCode?: string
): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>();
try {
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`;
const params = companyCode && companyCode !== "*"
? [tableName, companyCode]
: [tableName];
const result = await query<any>(
`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<string[]> {
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<string, any> = {};
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<string, any> = {};
// 마스터 키 컬럼은 항상 설정 (분할패널 컬럼 목록에 없어도)
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<string, any>, existingCols: Set<string>) => {
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<string, any> = {};
@ -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,
});

View File

@ -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<string>("");
// 저장 중 상태 (중복 실행 방지)
const [isSaving, setIsSaving] = useState(false);
// 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>("");
@ -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() {
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
<Button
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0}
disabled={!selectedTable || columns.length === 0 || isSaving}
className="h-10 gap-2 text-sm font-medium"
>
<Settings className="h-4 w-4" />
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Settings className="h-4 w-4" />
)}
{isSaving ? "저장 중..." : "전체 설정 저장"}
</Button>
</div>

View File

@ -453,6 +453,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
// 채번 정보 병합: 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<string>();
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<ExcelUploadModalProps> = ({
}
}
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
if (currentStep === 2) {
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
const mappedSystemCols = new Set<string>();
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<ExcelUploadModalProps> = ({
<SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem>
{systemColumns.map((col) => (
{systemColumns.map((col) => {
const isRequired = !col.nullable && !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && !(col as any).isNumbering;
return (
<SelectItem
key={col.name}
value={col.name}
className="text-xs sm:text-sm"
>
{isRequired && <span className="text-destructive mr-1">*</span>}
{col.label || col.name} ({col.type})
</SelectItem>
))}
);
})}
</SelectContent>
</Select>
{/* 중복 체크 체크박스 */}
@ -1427,6 +1501,38 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
</div>
{/* 미매핑 필수(NOT NULL) 컬럼 경고 */}
{(() => {
const mappedCols = new Set<string>();
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 (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
<div className="text-[10px] text-destructive sm:text-xs">
<p className="font-medium">(NOT NULL) :</p>
<p className="mt-1">
{missing.map((c) => c.label || c.name).join(", ")}
</p>
</div>
</div>
</div>
);
})()}
{/* 중복 체크 안내 */}
{duplicateCheckCount > 0 ? (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">

View File

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