@@ -3647,9 +3712,9 @@ export const ButtonConfigPanel: React.FC = ({
1. 소스 컴포넌트에서 데이터를 선택합니다
- 2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
+ 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
- 3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다
+ 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx
index 3be70840..d6ed8c62 100644
--- a/frontend/components/table-category/CategoryColumnList.tsx
+++ b/frontend/components/table-category/CategoryColumnList.tsx
@@ -72,9 +72,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
allColumns = response.data;
}
- // category 타입 컬럼만 필터링
+ // category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
const categoryColumns = allColumns.filter(
- (col: any) => col.inputType === "category" || col.input_type === "category"
+ (col: any) => (col.inputType === "category" || col.input_type === "category")
+ && !col.categoryRef && !col.category_ref
);
console.log("✅ 카테고리 컬럼 필터링 완료:", {
diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx
index bd50ffdb..1853ebe7 100644
--- a/frontend/components/v2/V2Repeater.tsx
+++ b/frontend/components/v2/V2Repeater.tsx
@@ -75,6 +75,15 @@ export const V2Repeater: React.FC = ({
const [selectedRows, setSelectedRows] = useState>(new Set());
const [modalOpen, setModalOpen] = useState(false);
+ // 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref
+ const dataRef = useRef(data);
+ useEffect(() => {
+ dataRef.current = data;
+ }, [data]);
+
+ // 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용)
+ const loadedIdsRef = useRef>(new Set());
+
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
@@ -91,17 +100,67 @@ export const V2Repeater: React.FC = ({
return;
}
- // 데이터 정규화: {0: {...}} 형태 처리
+ // 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
+ const metaFieldsToStrip = new Set([
+ "id",
+ "created_date",
+ "updated_date",
+ "created_by",
+ "updated_by",
+ "company_code",
+ ]);
const normalizedData = incomingData.map((item: any) => {
+ let raw = item;
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
const { 0: originalData, ...additionalFields } = item;
- return { ...originalData, ...additionalFields };
+ raw = { ...originalData, ...additionalFields };
}
- return item;
+ const cleaned: Record = {};
+ for (const [key, value] of Object.entries(raw)) {
+ if (!metaFieldsToStrip.has(key)) {
+ cleaned[key] = value;
+ }
+ }
+ return cleaned;
});
const mode = configOrMode?.mode || configOrMode || "append";
+ // 카테고리 코드 → 라벨 변환
+ // allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
+ const codesToResolve = new Set();
+ for (const item of normalizedData) {
+ for (const [key, val] of Object.entries(item)) {
+ if (key.startsWith("_")) continue;
+ if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) {
+ codesToResolve.add(val as string);
+ }
+ }
+ }
+
+ if (codesToResolve.size > 0) {
+ try {
+ const resp = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: Array.from(codesToResolve),
+ });
+ if (resp.data?.success && resp.data.data) {
+ const labelData = resp.data.data as Record;
+ setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
+ for (const item of normalizedData) {
+ for (const key of Object.keys(item)) {
+ if (key.startsWith("_")) continue;
+ const val = item[key];
+ if (typeof val === "string" && labelData[val]) {
+ item[key] = labelData[val];
+ }
+ }
+ }
+ }
+ } catch {
+ // 변환 실패 시 코드 유지
+ }
+ }
+
setData((prev) => {
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
onDataChangeRef.current?.(next);
@@ -137,6 +196,10 @@ export const V2Repeater: React.FC = ({
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState>({});
+ const categoryLabelMapRef = useRef>({});
+ useEffect(() => {
+ categoryLabelMapRef.current = categoryLabelMap;
+ }, [categoryLabelMap]);
// 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({});
@@ -170,35 +233,54 @@ export const V2Repeater: React.FC = ({
};
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
- // 저장 이벤트 리스너
+ // 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조)
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
- // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
- const tableName =
- config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
- const eventParentId = event.detail?.parentId;
- const mainFormData = event.detail?.mainFormData;
+ const currentData = dataRef.current;
+ const currentCategoryMap = categoryLabelMapRef.current;
- // 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
+ const configTableName =
+ config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
+ const tableName = configTableName || event.detail?.tableName;
+ const mainFormData = event.detail?.mainFormData;
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
- if (!tableName || data.length === 0) {
+ console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", {
+ configTableName,
+ tableName,
+ masterRecordId,
+ dataLength: currentData.length,
+ foreignKeyColumn: config.foreignKeyColumn,
+ foreignKeySourceColumn: config.foreignKeySourceColumn,
+ dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })),
+ });
+ toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`);
+
+ if (!tableName || currentData.length === 0) {
+ console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
+ toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
+ window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return;
}
- // V2Repeater 저장 시작
- const saveInfo = {
+ if (config.foreignKeyColumn) {
+ const sourceCol = config.foreignKeySourceColumn;
+ const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
+ if (!hasFkSource && !masterRecordId) {
+ console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
+ window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
+ return;
+ }
+ }
+
+ console.log("V2Repeater 저장 시작", {
tableName,
- useCustomTable: config.useCustomTable,
- mainTableName: config.mainTableName,
foreignKeyColumn: config.foreignKeyColumn,
masterRecordId,
- dataLength: data.length,
- };
- console.log("V2Repeater 저장 시작", saveInfo);
+ dataLength: currentData.length,
+ });
try {
- // 테이블 유효 컬럼 조회
let validColumns: Set = new Set();
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
@@ -209,13 +291,10 @@ export const V2Repeater: React.FC = ({
console.warn("테이블 컬럼 정보 조회 실패");
}
- for (let i = 0; i < data.length; i++) {
- const row = data[i];
-
- // 내부 필드 제거
+ for (let i = 0; i < currentData.length; i++) {
+ const row = currentData[i];
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
- // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record;
if (config.useCustomTable && config.mainTableName) {
mergedData = { ...cleanRow };
@@ -242,59 +321,83 @@ export const V2Repeater: React.FC = ({
};
}
- // 유효하지 않은 컬럼 제거
const filteredData: Record = {};
for (const [key, value] of Object.entries(mergedData)) {
if (validColumns.size === 0 || validColumns.has(key)) {
- filteredData[key] = value;
+ if (typeof value === "string" && currentCategoryMap[value]) {
+ filteredData[key] = currentCategoryMap[value];
+ } else {
+ filteredData[key] = value;
+ }
}
}
- // 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
const rowId = row.id;
+ console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, {
+ rowId,
+ isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"),
+ filteredDataKeys: Object.keys(filteredData),
+ });
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
- // UUID 형태의 id가 있으면 기존 데이터 → UPDATE
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: rowId },
updatedData: updateFields,
});
} else {
- // 새 행 → INSERT
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
}
}
+ // 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE
+ const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean));
+ const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id));
+ if (deletedIds.length > 0) {
+ console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds);
+ try {
+ await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
+ data: deletedIds.map((id) => ({ id })),
+ });
+ console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`);
+ } catch (deleteError) {
+ console.error("❌ [V2Repeater] 삭제 실패:", deleteError);
+ }
+ }
+
+ // 저장 완료 후 loadedIdsRef 갱신
+ loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean));
+
+ toast.success(`V2Repeater ${currentData.length}건 저장 완료`);
} catch (error) {
console.error("❌ V2Repeater 저장 실패:", error);
- throw error;
+ toast.error(`V2Repeater 저장 실패: ${error}`);
+ } finally {
+ window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
}
};
- // V2 EventBus 구독
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.REPEATER_SAVE,
async (payload) => {
- const tableName =
+ const configTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
- if (payload.tableName === tableName) {
+ if (!configTableName || payload.tableName === configTableName) {
await handleSaveEvent({ detail: payload } as CustomEvent);
}
},
- { componentId: `v2-repeater-${config.dataSource?.tableName}` },
+ { componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` },
);
- // 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => {
unsubscribe();
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
}, [
- data,
config.dataSource?.tableName,
config.useCustomTable,
config.mainTableName,
config.foreignKeyColumn,
+ config.foreignKeySourceColumn,
parentId,
]);
@@ -362,7 +465,6 @@ export const V2Repeater: React.FC = ({
});
// 각 행에 소스 테이블의 표시 데이터 병합
- // RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
@@ -380,12 +482,50 @@ export const V2Repeater: React.FC = ({
}
}
+ // DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
+ const codesToResolve = new Set();
+ for (const row of rows) {
+ for (const val of Object.values(row)) {
+ if (typeof val === "string" && val.startsWith("CATEGORY_")) {
+ codesToResolve.add(val);
+ }
+ }
+ }
+
+ if (codesToResolve.size > 0) {
+ try {
+ const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: Array.from(codesToResolve),
+ });
+ if (labelResp.data?.success && labelResp.data.data) {
+ const labelData = labelResp.data.data as Record;
+ setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
+ for (const row of rows) {
+ for (const key of Object.keys(row)) {
+ if (key.startsWith("_")) continue;
+ const val = row[key];
+ if (typeof val === "string" && labelData[val]) {
+ row[key] = labelData[val];
+ }
+ }
+ }
+ }
+ } catch {
+ // 라벨 변환 실패 시 코드 유지
+ }
+ }
+
+ // 원본 ID 목록 기록 (삭제 추적용)
+ const ids = rows.map((r: any) => r.id).filter(Boolean);
+ loadedIdsRef.current = new Set(ids);
+ console.log("📋 [V2Repeater] 원본 ID 기록:", ids);
+
setData(rows);
dataLoadedRef.current = true;
if (onDataChange) onDataChange(rows);
}
} catch (error) {
- console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
+ console.error("[V2Repeater] 기존 데이터 로드 실패:", error);
}
};
@@ -407,16 +547,28 @@ export const V2Repeater: React.FC = ({
if (!tableName) return;
try {
- const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
- const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
+ const [colResponse, typeResponse] = await Promise.all([
+ apiClient.get(`/table-management/tables/${tableName}/columns`),
+ apiClient.get(`/table-management/tables/${tableName}/web-types`),
+ ]);
+ const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || [];
+ const inputTypes = typeResponse.data?.data || [];
+
+ // inputType/categoryRef 매핑 생성
+ const typeMap: Record = {};
+ inputTypes.forEach((t: any) => {
+ typeMap[t.columnName] = t;
+ });
const columnMap: Record = {};
columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name;
+ const typeInfo = typeMap[name];
columnMap[name] = {
- inputType: col.inputType || col.input_type || col.webType || "text",
+ inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text",
displayName: col.displayName || col.display_name || col.label || name,
detailSettings: col.detailSettings || col.detail_settings,
+ categoryRef: typeInfo?.categoryRef || null,
};
});
setCurrentTableColumnInfo(columnMap);
@@ -548,14 +700,18 @@ export const V2Repeater: React.FC = ({
else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
- // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
- // category 타입인 경우 현재 테이블명과 컬럼명을 조합
+ // 카테고리 참조 ID 결정
+ // DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용
let categoryRef: string | undefined;
if (inputType === "category") {
- // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
- const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
- if (tableName) {
- categoryRef = `${tableName}.${col.key}`;
+ const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef;
+ if (dbCategoryRef) {
+ categoryRef = dbCategoryRef;
+ } else {
+ const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
+ if (tableName) {
+ categoryRef = `${tableName}.${col.key}`;
+ }
}
}
@@ -574,63 +730,78 @@ export const V2Repeater: React.FC = ({
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
+ // repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영)
const allCategoryColumns = useMemo(() => {
- const fromConfig = config.columns
- .filter((col) => col.inputType === "category")
- .map((col) => col.key);
- const merged = new Set([...sourceCategoryColumns, ...fromConfig]);
+ const fromRepeater = repeaterColumns
+ .filter((col) => col.type === "category")
+ .map((col) => col.field.replace(/^_display_/, ""));
+ const merged = new Set([...sourceCategoryColumns, ...fromRepeater]);
return Array.from(merged);
- }, [sourceCategoryColumns, config.columns]);
+ }, [sourceCategoryColumns, repeaterColumns]);
- // 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
+ // CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수
+ const fetchCategoryLabels = useCallback(async (codes: string[]) => {
+ if (codes.length === 0) return;
+ try {
+ const response = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: codes,
+ });
+ if (response.data?.success && response.data.data) {
+ setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data }));
+ }
+ } catch (error) {
+ console.error("카테고리 라벨 조회 실패:", error);
+ }
+ }, []);
+
+ // parentFormData(마스터 행)에서 카테고리 코드를 미리 로드
+ // fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보
useEffect(() => {
- const loadCategoryLabels = async () => {
- if (allCategoryColumns.length === 0 || data.length === 0) {
- return;
- }
+ if (!parentFormData) return;
+ const codes: string[] = [];
- // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
- const allCodes = new Set();
- for (const row of data) {
- for (const col of allCategoryColumns) {
- // _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
- const val = row[`_display_${col}`] || row[col];
- if (val && typeof val === "string") {
- const codes = val
- .split(",")
- .map((c: string) => c.trim())
- .filter(Boolean);
- for (const code of codes) {
- if (!categoryLabelMap[code] && code.length > 0) {
- allCodes.add(code);
- }
- }
- }
+ // fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집
+ for (const col of config.columns) {
+ if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) {
+ const val = parentFormData[col.autoFill.sourceField];
+ if (typeof val === "string" && val && !categoryLabelMap[val]) {
+ codes.push(val);
}
}
-
- if (allCodes.size === 0) {
- return;
- }
-
- try {
- const response = await apiClient.post("/table-categories/labels-by-codes", {
- valueCodes: Array.from(allCodes),
- });
-
- if (response.data?.success && response.data.data) {
- setCategoryLabelMap((prev) => ({
- ...prev,
- ...response.data.data,
- }));
+ // receiveFromParent 패턴
+ if ((col as any).receiveFromParent) {
+ const parentField = (col as any).parentFieldName || col.key;
+ const val = parentFormData[parentField];
+ if (typeof val === "string" && val && !categoryLabelMap[val]) {
+ codes.push(val);
}
- } catch (error) {
- console.error("카테고리 라벨 조회 실패:", error);
}
- };
+ }
- loadCategoryLabels();
- }, [data, allCategoryColumns]);
+ if (codes.length > 0) {
+ fetchCategoryLabels(codes);
+ }
+ }, [parentFormData, config.columns, fetchCategoryLabels]);
+
+ // 데이터 변경 시 카테고리 라벨 로드
+ useEffect(() => {
+ if (data.length === 0) return;
+
+ const allCodes = new Set();
+
+ for (const row of data) {
+ for (const col of allCategoryColumns) {
+ const val = row[`_display_${col}`] || row[col];
+ if (val && typeof val === "string") {
+ val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => {
+ if (!categoryLabelMap[code]) allCodes.add(code);
+ });
+ }
+ }
+ }
+
+ fetchCategoryLabels(Array.from(allCodes));
+ }, [data, allCategoryColumns, fetchCategoryLabels]);
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
const applyCalculationRules = useCallback(
@@ -747,7 +918,12 @@ export const V2Repeater: React.FC = ({
case "fromMainForm":
if (col.autoFill.sourceField && mainFormData) {
- return mainFormData[col.autoFill.sourceField];
+ const rawValue = mainFormData[col.autoFill.sourceField];
+ // categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관)
+ if (typeof rawValue === "string" && categoryLabelMap[rawValue]) {
+ return categoryLabelMap[rawValue];
+ }
+ return rawValue;
}
return "";
@@ -767,7 +943,7 @@ export const V2Repeater: React.FC = ({
return undefined;
}
},
- [],
+ [categoryLabelMap],
);
// 🆕 채번 API 호출 (비동기)
@@ -801,7 +977,12 @@ export const V2Repeater: React.FC = ({
const row: any = { _id: `grouped_${Date.now()}_${index}` };
for (const col of config.columns) {
- const sourceValue = item[(col as any).sourceKey || col.key];
+ let sourceValue = item[(col as any).sourceKey || col.key];
+
+ // 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
+ if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
+ sourceValue = categoryLabelMap[sourceValue];
+ }
if (col.isSourceDisplay) {
row[col.key] = sourceValue ?? "";
@@ -822,6 +1003,48 @@ export const V2Repeater: React.FC = ({
return row;
});
+ // 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
+ const categoryColSet = new Set(allCategoryColumns);
+ const codesToResolve = new Set();
+ for (const row of newRows) {
+ for (const col of config.columns) {
+ const val = row[col.key] || row[`_display_${col.key}`];
+ if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
+ if (!categoryLabelMap[val]) {
+ codesToResolve.add(val);
+ }
+ }
+ }
+ }
+
+ if (codesToResolve.size > 0) {
+ apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: Array.from(codesToResolve),
+ }).then((resp) => {
+ if (resp.data?.success && resp.data.data) {
+ const labelData = resp.data.data as Record;
+ setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
+ const convertedRows = newRows.map((row) => {
+ const updated = { ...row };
+ for (const col of config.columns) {
+ const val = updated[col.key];
+ if (typeof val === "string" && labelData[val]) {
+ updated[col.key] = labelData[val];
+ }
+ const dispKey = `_display_${col.key}`;
+ const dispVal = updated[dispKey];
+ if (typeof dispVal === "string" && labelData[dispVal]) {
+ updated[dispKey] = labelData[dispVal];
+ }
+ }
+ return updated;
+ });
+ setData(convertedRows);
+ onDataChange?.(convertedRows);
+ }
+ }).catch(() => {});
+ }
+
setData(newRows);
onDataChange?.(newRows);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -856,7 +1079,7 @@ export const V2Repeater: React.FC = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentFormData, config.columns, generateAutoFillValueSync]);
- // 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
+ // 행 추가 (inline 모드 또는 모달 열기)
const handleAddRow = useCallback(async () => {
if (isModalMode) {
setModalOpen(true);
@@ -864,11 +1087,10 @@ export const V2Repeater: React.FC = ({
const newRow: any = { _id: `new_${Date.now()}` };
const currentRowCount = data.length;
- // 먼저 동기적 자동 입력 값 적용
+ // 동기적 자동 입력 값 적용
for (const col of config.columns) {
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
- // 채번 규칙: 즉시 API 호출
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) {
newRow[col.key] = autoValue;
@@ -877,10 +1099,51 @@ export const V2Repeater: React.FC = ({
}
}
+ // fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환
+ // allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환
+ const categoryColSet = new Set(allCategoryColumns);
+ const unresolvedCodes: string[] = [];
+ for (const col of config.columns) {
+ const val = newRow[col.key];
+ if (typeof val !== "string" || !val) continue;
+
+ // 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우
+ const isCategoryCol = categoryColSet.has(col.key);
+ const isFromMainForm = col.autoFill?.type === "fromMainForm";
+
+ if (isCategoryCol || isFromMainForm) {
+ if (categoryLabelMap[val]) {
+ newRow[col.key] = categoryLabelMap[val];
+ } else {
+ unresolvedCodes.push(val);
+ }
+ }
+ }
+
+ if (unresolvedCodes.length > 0) {
+ try {
+ const resp = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: unresolvedCodes,
+ });
+ if (resp.data?.success && resp.data.data) {
+ const labelData = resp.data.data as Record;
+ setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
+ for (const col of config.columns) {
+ const val = newRow[col.key];
+ if (typeof val === "string" && labelData[val]) {
+ newRow[col.key] = labelData[val];
+ }
+ }
+ }
+ } catch {
+ // 변환 실패 시 코드 유지
+ }
+ }
+
const newData = [...data, newRow];
handleDataChange(newData);
}
- }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]);
+ }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]);
// 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback(
@@ -905,8 +1168,12 @@ export const V2Repeater: React.FC = ({
// 모든 컬럼 처리 (순서대로)
for (const col of config.columns) {
if (col.isSourceDisplay) {
- // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
- row[`_display_${col.key}`] = item[col.key] || "";
+ let displayVal = item[col.key] || "";
+ // 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관)
+ if (typeof displayVal === "string" && categoryLabelMap[displayVal]) {
+ displayVal = categoryLabelMap[displayVal];
+ }
+ row[`_display_${col.key}`] = displayVal;
} else {
// 자동 입력 값 적용
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
@@ -926,6 +1193,43 @@ export const V2Repeater: React.FC = ({
}),
);
+ // 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환
+ const categoryColSet = new Set(allCategoryColumns);
+ const unresolvedCodes = new Set();
+ for (const row of newRows) {
+ for (const col of config.columns) {
+ const val = row[col.key];
+ if (typeof val !== "string" || !val) continue;
+ const isCategoryCol = categoryColSet.has(col.key);
+ const isFromMainForm = col.autoFill?.type === "fromMainForm";
+ if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) {
+ unresolvedCodes.add(val);
+ }
+ }
+ }
+
+ if (unresolvedCodes.size > 0) {
+ try {
+ const resp = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: Array.from(unresolvedCodes),
+ });
+ if (resp.data?.success && resp.data.data) {
+ const labelData = resp.data.data as Record;
+ setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
+ for (const row of newRows) {
+ for (const col of config.columns) {
+ const val = row[col.key];
+ if (typeof val === "string" && labelData[val]) {
+ row[col.key] = labelData[val];
+ }
+ }
+ }
+ }
+ } catch {
+ // 변환 실패 시 코드 유지
+ }
+ }
+
const newData = [...data, ...newRows];
handleDataChange(newData);
setModalOpen(false);
@@ -939,6 +1243,8 @@ export const V2Repeater: React.FC = ({
generateAutoFillValueSync,
generateNumberingCode,
parentFormData,
+ categoryLabelMap,
+ allCategoryColumns,
],
);
@@ -951,9 +1257,6 @@ export const V2Repeater: React.FC = ({
}, [config.columns]);
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
- const dataRef = useRef(data);
- dataRef.current = data;
-
useEffect(() => {
const handleBeforeFormSave = async (event: Event) => {
const customEvent = event as CustomEvent;
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
index 78969fd0..5ad6d0eb 100644
--- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
@@ -480,15 +480,20 @@ export function RepeaterTable({
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field];
- // 🆕 카테고리 라벨 변환 함수
+ // 카테고리 라벨 변환 함수
const getCategoryDisplayValue = (val: any): string => {
if (!val || typeof val !== "string") return val || "-";
- // 카테고리 컬럼이 아니면 그대로 반환
- const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거
- if (!categoryColumns.includes(fieldName)) return val;
+ const fieldName = column.field.replace(/^_display_/, "");
+ const isCategoryColumn = categoryColumns.includes(fieldName);
- // 쉼표로 구분된 다중 값 처리
+ // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
+ if (categoryLabelMap[val]) return categoryLabelMap[val];
+
+ // 카테고리 컬럼이 아니면 원래 값 반환
+ if (!isCategoryColumn) return val;
+
+ // 콤마 구분된 다중 값 처리
const codes = val
.split(",")
.map((c: string) => c.trim())
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 2e8ca106..aee70dd2 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -781,6 +781,7 @@ export const TableListComponent: React.FC = ({
const dataProvider: DataProvidable = {
componentId: component.id,
componentType: "table-list",
+ tableName: tableConfig.selectedTable,
getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index 120022a5..06226c9e 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -554,6 +554,69 @@ export function TableSectionRenderer({
loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]);
+ // receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
+ useEffect(() => {
+ if (!formData || Object.keys(formData).length === 0) return;
+ if (!tableConfig.columns) return;
+
+ const codesToResolve: string[] = [];
+ for (const col of tableConfig.columns) {
+ // receiveFromParent 컬럼
+ if ((col as any).receiveFromParent) {
+ const parentField = (col as any).parentFieldName || col.field;
+ const val = formData[parentField];
+ if (typeof val === "string" && val) {
+ codesToResolve.push(val);
+ }
+ }
+ // internal 매핑 컬럼
+ const mapping = (col as any).valueMapping;
+ if (mapping?.type === "internal" && mapping.internalField) {
+ const val = formData[mapping.internalField];
+ if (typeof val === "string" && val) {
+ codesToResolve.push(val);
+ }
+ }
+ }
+
+ if (codesToResolve.length === 0) return;
+
+ const loadParentLabels = async () => {
+ try {
+ const resp = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: codesToResolve,
+ });
+ if (resp.data?.success && resp.data.data) {
+ const labelData = resp.data.data as Record;
+ // categoryOptionsMap에 추가 (receiveFromParent 컬럼별로)
+ const newOptionsMap: Record = {};
+ for (const col of tableConfig.columns) {
+ let val: string | undefined;
+ if ((col as any).receiveFromParent) {
+ const parentField = (col as any).parentFieldName || col.field;
+ val = formData[parentField] as string;
+ }
+ const mapping = (col as any).valueMapping;
+ if (mapping?.type === "internal" && mapping.internalField) {
+ val = formData[mapping.internalField] as string;
+ }
+ if (val && typeof val === "string" && labelData[val]) {
+ newOptionsMap[col.field] = [{ value: val, label: labelData[val] }];
+ }
+ }
+ if (Object.keys(newOptionsMap).length > 0) {
+ setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
+ }
+ }
+ } catch {
+ // 라벨 조회 실패 시 무시
+ }
+ };
+
+ loadParentLabels();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [formData, tableConfig.columns]);
+
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => {
if (!isConditionalMode) return;
@@ -1005,6 +1068,23 @@ export function TableSectionRenderer({
});
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
+ // categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
+ const tableCategoryColumns = useMemo(() => {
+ return Object.keys(categoryOptionsMap);
+ }, [categoryOptionsMap]);
+
+ const tableCategoryLabelMap = useMemo(() => {
+ const map: Record = {};
+ for (const options of Object.values(categoryOptionsMap)) {
+ for (const opt of options) {
+ if (opt.value && opt.label) {
+ map[opt.value] = opt.label;
+ }
+ }
+ }
+ return map;
+ }, [categoryOptionsMap]);
+
// 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo(
() => tableConfig.calculations || [],
@@ -1312,6 +1392,67 @@ export function TableSectionRenderer({
}),
);
+ // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
+ const categoryFields = (tableConfig.columns || [])
+ .filter((col) => col.type === "category" || col.type === "select")
+ .reduce>>((acc, col) => {
+ const options = categoryOptionsMap[col.field];
+ if (options && options.length > 0) {
+ acc[col.field] = {};
+ for (const opt of options) {
+ acc[col.field][opt.value] = opt.label;
+ }
+ }
+ return acc;
+ }, {});
+
+ // receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환
+ if (Object.keys(categoryFields).length > 0) {
+ for (const item of mappedItems) {
+ for (const [field, codeToLabel] of Object.entries(categoryFields)) {
+ const val = item[field];
+ if (typeof val === "string" && codeToLabel[val]) {
+ item[field] = codeToLabel[val];
+ }
+ }
+ }
+ }
+
+ // categoryOptionsMap에 없는 경우 API fallback
+ const unresolvedCodes = new Set();
+ const categoryColFields = new Set(
+ (tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field),
+ );
+ for (const item of mappedItems) {
+ for (const field of categoryColFields) {
+ const val = item[field];
+ if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) {
+ unresolvedCodes.add(val);
+ }
+ }
+ }
+
+ if (unresolvedCodes.size > 0) {
+ try {
+ const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: Array.from(unresolvedCodes),
+ });
+ if (labelResp.data?.success && labelResp.data.data) {
+ const labelData = labelResp.data.data as Record;
+ for (const item of mappedItems) {
+ for (const field of categoryColFields) {
+ const val = item[field];
+ if (typeof val === "string" && labelData[val]) {
+ item[field] = labelData[val];
+ }
+ }
+ }
+ }
+ } catch {
+ // 변환 실패 시 코드 유지
+ }
+ }
+
// 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems);
@@ -1319,7 +1460,7 @@ export function TableSectionRenderer({
const newData = [...tableData, ...calculatedItems];
handleDataChange(newData);
},
- [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
+ [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap],
);
// 컬럼 모드/조회 옵션 변경 핸들러
@@ -1667,6 +1808,31 @@ export function TableSectionRenderer({
}),
);
+ // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
+ const categoryFields = (tableConfig.columns || [])
+ .filter((col) => col.type === "category" || col.type === "select")
+ .reduce>>((acc, col) => {
+ const options = categoryOptionsMap[col.field];
+ if (options && options.length > 0) {
+ acc[col.field] = {};
+ for (const opt of options) {
+ acc[col.field][opt.value] = opt.label;
+ }
+ }
+ return acc;
+ }, {});
+
+ if (Object.keys(categoryFields).length > 0) {
+ for (const item of mappedItems) {
+ for (const [field, codeToLabel] of Object.entries(categoryFields)) {
+ const val = item[field];
+ if (typeof val === "string" && codeToLabel[val]) {
+ item[field] = codeToLabel[val];
+ }
+ }
+ }
+ }
+
// 현재 조건의 데이터에 추가
const currentData = conditionalTableData[modalCondition] || [];
const newData = [...currentData, ...mappedItems];
@@ -1964,6 +2130,8 @@ export function TableSectionRenderer({
[conditionValue]: newSelected,
}));
}}
+ categoryColumns={tableCategoryColumns}
+ categoryLabelMap={tableCategoryLabelMap}
equalizeWidthsTrigger={widthTrigger}
/>
@@ -2055,6 +2223,8 @@ export function TableSectionRenderer({
}));
}}
equalizeWidthsTrigger={widthTrigger}
+ categoryColumns={tableCategoryColumns}
+ categoryLabelMap={tableCategoryLabelMap}
/>
);
@@ -2185,6 +2355,8 @@ export function TableSectionRenderer({
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={widthTrigger}
+ categoryColumns={tableCategoryColumns}
+ categoryLabelMap={tableCategoryLabelMap}
/>
{/* 항목 선택 모달 */}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index a937f5b2..c6673d8d 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -393,7 +393,7 @@ export interface TableModalFilter {
export interface TableColumnConfig {
field: string; // 필드명 (저장할 컬럼명)
label: string; // 컬럼 헤더 라벨
- type: "text" | "number" | "date" | "select"; // 입력 타입
+ type: "text" | "number" | "date" | "select" | "category"; // 입력 타입
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
index 9505d3dd..371814b5 100644
--- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
@@ -897,11 +897,30 @@ export const ButtonPrimaryComponent: React.FC = ({
}
}
- // 4. 매핑 규칙 적용 + 추가 데이터 병합
- const mappedData = sourceData.map((row) => {
- const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
+ // 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑
+ let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || [];
+
+ const sourceTableName = sourceProvider?.tableName;
+ const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> =
+ dataTransferConfig.multiTableMappings || [];
+
+ if (multiTableMappings.length > 0 && sourceTableName) {
+ const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
+ if (matchedGroup) {
+ effectiveMappingRules = matchedGroup.mappingRules || [];
+ console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules);
+ } else {
+ console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`);
+ effectiveMappingRules = [];
+ }
+ } else if (multiTableMappings.length > 0 && !sourceTableName) {
+ console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용");
+ effectiveMappingRules = multiTableMappings[0]?.mappingRules || [];
+ }
+
+ const mappedData = sourceData.map((row) => {
+ const mappedRow = applyMappingRules(row, effectiveMappingRules);
- // 추가 데이터를 모든 행에 포함
return {
...mappedRow,
...additionalData,
diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
index 22c2a6f4..1eaef469 100644
--- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
@@ -654,7 +654,7 @@ export const TableListComponent: React.FC = ({
const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState([]);
const [columnMeta, setColumnMeta] = useState<
- Record
+ Record
>({});
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
@@ -865,6 +865,7 @@ export const TableListComponent: React.FC = ({
const dataProvider: DataProvidable = {
componentId: component.id,
componentType: "table-list",
+ tableName: tableConfig.selectedTable,
getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
@@ -1233,13 +1234,16 @@ export const TableListComponent: React.FC = ({
const cached = tableColumnCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const labels: Record = {};
- const meta: Record = {};
+ const meta: Record = {};
- // 캐시된 inputTypes 맵 생성
const inputTypeMap: Record = {};
+ const categoryRefMap: Record = {};
if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
+ if (col.categoryRef) {
+ categoryRefMap[col.columnName] = col.categoryRef;
+ }
});
}
@@ -1248,7 +1252,8 @@ export const TableListComponent: React.FC = ({
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
- inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
+ inputType: inputTypeMap[col.columnName],
+ categoryRef: categoryRefMap[col.columnName],
};
});
@@ -1259,11 +1264,14 @@ export const TableListComponent: React.FC = ({
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
- // 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
const inputTypeMap: Record = {};
+ const categoryRefMap: Record = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
+ if (col.categoryRef) {
+ categoryRefMap[col.columnName] = col.categoryRef;
+ }
});
tableColumnCache.set(cacheKey, {
@@ -1273,7 +1281,7 @@ export const TableListComponent: React.FC = ({
});
const labels: Record = {};
- const meta: Record = {};
+ const meta: Record = {};
columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
@@ -1281,6 +1289,7 @@ export const TableListComponent: React.FC = ({
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName],
+ categoryRef: categoryRefMap[col.columnName],
};
});
@@ -1355,14 +1364,22 @@ export const TableListComponent: React.FC = ({
for (const columnName of categoryColumns) {
try {
- // 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
let targetTable = tableConfig.selectedTable;
let targetColumn = columnName;
- if (columnName.includes(".")) {
+ // category_ref가 있으면 참조 테이블.컬럼 기준으로 조회
+ const meta = columnMeta[columnName];
+ if (meta?.categoryRef) {
+ const refParts = meta.categoryRef.split(".");
+ if (refParts.length === 2) {
+ targetTable = refParts[0];
+ targetColumn = refParts[1];
+ }
+ } else if (columnName.includes(".")) {
+ // 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태
const parts = columnName.split(".");
- targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
- targetColumn = parts[1]; // 실제 컬럼명 (예: material)
+ targetTable = parts[0];
+ targetColumn = parts[1];
}
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
@@ -1563,7 +1580,8 @@ export const TableListComponent: React.FC = ({
categoryColumns.length,
JSON.stringify(categoryColumns),
JSON.stringify(tableConfig.columns),
- ]); // 더 명확한 의존성
+ columnMeta,
+ ]);
// ========================================
// 데이터 가져오기
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 7f094517..7dc5e573 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -559,6 +559,7 @@ export class ButtonActionExecutor {
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
+ // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave) {
try {
await onSave();
@@ -626,6 +627,7 @@ export class ButtonActionExecutor {
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
+ // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave && !hasTableSectionData) {
try {
await onSave();
@@ -1494,13 +1496,24 @@ export class ButtonActionExecutor {
// @ts-ignore - window에 동적 속성 사용
const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []);
+ // V2Repeater가 동일 테이블에 존재하는지 allComponents로 감지
+ // (useCustomTable 미설정 = 화면 테이블에 직접 저장하는 리피터)
+ const hasRepeaterOnSameTable = context.allComponents?.some((c: any) => {
+ const compType = c.componentType || c.overrides?.type;
+ if (compType !== "v2-repeater") return false;
+ const compConfig = c.componentConfig || c.overrides || {};
+ return !compConfig.useCustomTable;
+ }) || false;
+
// 메인 저장 건너뛰기 조건:
// 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
// 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨)
+ // 3. allComponents에서 useCustomTable 미설정 V2Repeater 감지 (글로벌 등록 없는 경우)
const shouldSkipMainSave =
repeatScreenModalTables.includes(tableName) ||
repeaterFieldGroupTables.includes(tableName) ||
- v2RepeaterTables.includes(tableName);
+ v2RepeaterTables.includes(tableName) ||
+ hasRepeaterOnSameTable;
if (shouldSkipMainSave) {
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" };
@@ -1779,16 +1792,7 @@ export class ButtonActionExecutor {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
}
- // 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
- context.onRefresh?.();
- context.onFlowRefresh?.();
-
- // 저장 성공 후 이벤트 발생
- window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
- window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
-
- // V2Repeater 저장 이벤트 발생 (메인 폼 데이터 + 리피터 데이터 병합 저장)
- // 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
+ // V2Repeater 저장 이벤트 발생 (모달 닫기 전에 실행해야 V2Repeater가 이벤트를 수신할 수 있음)
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
// _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑)
@@ -1866,17 +1870,45 @@ export class ButtonActionExecutor {
}
}
+ console.log("🟢 [buttonActions] repeaterSave 이벤트 발행:", {
+ parentId: savedId,
+ tableName: context.tableName,
+ masterRecordId: savedId,
+ mainFormDataKeys: Object.keys(mainFormData),
+ });
+
+ // V2Repeater 저장 완료를 기다리기 위한 Promise
+ const repeaterSavePromise = new Promise((resolve) => {
+ const fallbackTimeout = setTimeout(resolve, 5000);
+ const handler = () => {
+ clearTimeout(fallbackTimeout);
+ window.removeEventListener("repeaterSaveComplete", handler);
+ resolve();
+ };
+ window.addEventListener("repeaterSaveComplete", handler);
+ });
+
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
- mainFormData, // 🆕 메인 폼 데이터 전달
- masterRecordId: savedId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
+ mainFormData,
+ masterRecordId: savedId,
},
}),
);
+ await repeaterSavePromise;
+
+ // 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
+ context.onRefresh?.();
+ context.onFlowRefresh?.();
+
+ // 저장 성공 후 모달 닫기 이벤트 발생
+ window.dispatchEvent(new CustomEvent("closeEditModal"));
+ window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
+
return true;
} catch (error) {
console.error("저장 오류:", error);
@@ -1884,6 +1916,50 @@ export class ButtonActionExecutor {
}
}
+ /**
+ * V2Repeater 디테일 데이터 저장 이벤트 발행 (onSave 콜백 경로에서도 사용)
+ */
+ private static async dispatchRepeaterSave(context: ButtonActionContext): Promise {
+ const formData = context.formData || {};
+ const savedId = formData.id;
+
+ if (!savedId) {
+ console.log("⚠️ [dispatchRepeaterSave] savedId(formData.id) 없음 - 스킵");
+ return;
+ }
+
+ console.log("🟢 [dispatchRepeaterSave] repeaterSave 이벤트 발행:", {
+ parentId: savedId,
+ tableName: context.tableName,
+ masterRecordId: savedId,
+ formDataKeys: Object.keys(formData),
+ });
+
+ const repeaterSavePromise = new Promise((resolve) => {
+ const fallbackTimeout = setTimeout(resolve, 5000);
+ const handler = () => {
+ clearTimeout(fallbackTimeout);
+ window.removeEventListener("repeaterSaveComplete", handler);
+ resolve();
+ };
+ window.addEventListener("repeaterSaveComplete", handler);
+ });
+
+ window.dispatchEvent(
+ new CustomEvent("repeaterSave", {
+ detail: {
+ parentId: savedId,
+ tableName: context.tableName,
+ mainFormData: formData,
+ masterRecordId: savedId,
+ },
+ }),
+ );
+
+ await repeaterSavePromise;
+ console.log("✅ [dispatchRepeaterSave] repeaterSave 완료");
+ }
+
/**
* DB에서 조회한 실제 기본키로 formData에서 값 추출
* @param formData 폼 데이터
diff --git a/frontend/types/data-transfer.ts b/frontend/types/data-transfer.ts
index cdb5f55f..61aad8db 100644
--- a/frontend/types/data-transfer.ts
+++ b/frontend/types/data-transfer.ts
@@ -57,6 +57,15 @@ export interface MappingRule {
required?: boolean; // 필수 여부
}
+/**
+ * 멀티 테이블 매핑 그룹
+ * 소스 테이블별로 별도의 매핑 규칙을 정의
+ */
+export interface MultiTableMappingGroup {
+ sourceTable: string;
+ mappingRules: MappingRule[];
+}
+
/**
* 데이터 수신자 설정
* 데이터를 받을 타겟 컴포넌트의 설정
@@ -155,6 +164,7 @@ export interface DataReceivable {
export interface DataProvidable {
componentId: string;
componentType: string;
+ tableName?: string;
/**
* 선택된 데이터를 가져오는 메서드