@@ -3567,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 d2b288ff..1853ebe7 100644
--- a/frontend/components/v2/V2Repeater.tsx
+++ b/frontend/components/v2/V2Repeater.tsx
@@ -23,6 +23,9 @@ import {
import { apiClient } from "@/lib/api/client";
import { allocateNumberingCode } from "@/lib/api/numberingRule";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
+import { useScreenContextOptional } from "@/contexts/ScreenContext";
+import { DataReceivable } from "@/types/data-transfer";
+import { toast } from "sonner";
// modal-repeater-table 컴포넌트 재사용
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
@@ -38,6 +41,7 @@ declare global {
export const V2Repeater: React.FC = ({
config: propConfig,
+ componentId,
parentId,
data: initialData,
onDataChange,
@@ -48,6 +52,12 @@ export const V2Repeater: React.FC = ({
}) => {
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
+
+ // componentId 결정: 직접 전달 또는 component 객체에서 추출
+ const effectiveComponentId = componentId || (restProps as any).component?.id;
+
+ // ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null)
+ const screenContext = useScreenContextOptional();
// 설정 병합
const config: V2RepeaterConfig = useMemo(
() => ({
@@ -65,9 +75,119 @@ 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);
+ // ScreenContext DataReceiver 등록 (데이터 전달 액션 수신)
+ const onDataChangeRef = useRef(onDataChange);
+ onDataChangeRef.current = onDataChange;
+
+ const handleReceiveData = useCallback(
+ async (incomingData: any[], configOrMode?: any) => {
+ console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
+
+ if (!incomingData || incomingData.length === 0) {
+ toast.warning("전달할 데이터가 없습니다");
+ return;
+ }
+
+ // 데이터 정규화: {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;
+ raw = { ...originalData, ...additionalFields };
+ }
+ 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);
+ return next;
+ });
+
+ toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
+ },
+ [],
+ );
+
+ useEffect(() => {
+ if (screenContext && effectiveComponentId) {
+ const receiver: DataReceivable = {
+ componentId: effectiveComponentId,
+ componentType: "v2-repeater",
+ receiveData: handleReceiveData,
+ };
+ console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId);
+ screenContext.registerDataReceiver(effectiveComponentId, receiver);
+
+ return () => {
+ screenContext.unregisterDataReceiver(effectiveComponentId);
+ };
+ }
+ }, [screenContext, effectiveComponentId, handleReceiveData]);
+
// 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState>({});
@@ -76,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>({});
@@ -109,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`);
@@ -148,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 };
@@ -181,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,
]);
@@ -301,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) {
@@ -319,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);
}
};
@@ -346,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);
@@ -487,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}`;
+ }
}
}
@@ -512,55 +729,79 @@ export const V2Repeater: React.FC = ({
});
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
- // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
+ // 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
+ // repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영)
+ const allCategoryColumns = useMemo(() => {
+ 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, repeaterColumns]);
+
+ // 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 (sourceCategoryColumns.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 sourceCategoryColumns) {
- // _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.startsWith("CATEGORY_")) {
- 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, sourceCategoryColumns]);
+ 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(
@@ -677,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 "";
@@ -697,7 +943,7 @@ export const V2Repeater: React.FC = ({
return undefined;
}
},
- [],
+ [categoryLabelMap],
);
// 🆕 채번 API 호출 (비동기)
@@ -731,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 ?? "";
@@ -752,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
@@ -786,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);
@@ -794,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;
@@ -807,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(
@@ -835,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);
@@ -856,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);
@@ -869,6 +1243,8 @@ export const V2Repeater: React.FC = ({
generateAutoFillValueSync,
generateNumberingCode,
parentFormData,
+ categoryLabelMap,
+ allCategoryColumns,
],
);
@@ -881,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;
@@ -1112,7 +1485,7 @@ export const V2Repeater: React.FC = ({
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={autoWidthTrigger}
- categoryColumns={sourceCategoryColumns}
+ categoryColumns={allCategoryColumns}
categoryLabelMap={categoryLabelMap}
/>