feat: Enhance category column handling and data mapping
- Updated the `getCategoryColumnsByCompany` and `getCategoryColumnsByMenu` functions to exclude reference columns from category column queries, improving data integrity. - Modified the `TableManagementService` to include `category_ref` in the column management logic, ensuring proper handling of category references during data operations. - Enhanced the frontend components to support category reference mapping, allowing for better data representation and user interaction. - Implemented category label conversion in various components to improve the display of category data, ensuring a seamless user experience.
This commit is contained in:
@@ -75,6 +75,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref
|
||||
const dataRef = useRef<any[]>(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
// 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용)
|
||||
const loadedIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||
|
||||
@@ -91,17 +100,67 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
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<string, any> = {};
|
||||
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<string>();
|
||||
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<string, string>;
|
||||
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<V2RepeaterProps> = ({
|
||||
|
||||
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||
const categoryLabelMapRef = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
categoryLabelMapRef.current = categoryLabelMap;
|
||||
}, [categoryLabelMap]);
|
||||
|
||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||
@@ -170,35 +233,54 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
};
|
||||
}, [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<string> = new Set();
|
||||
try {
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
@@ -209,13 +291,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
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<string, any>;
|
||||
if (config.useCustomTable && config.mainTableName) {
|
||||
mergedData = { ...cleanRow };
|
||||
@@ -242,59 +321,83 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
// 유효하지 않은 컬럼 제거
|
||||
const filteredData: Record<string, any> = {};
|
||||
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<V2RepeaterProps> = ({
|
||||
});
|
||||
|
||||
// 각 행에 소스 테이블의 표시 데이터 병합
|
||||
// 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<V2RepeaterProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
|
||||
const codesToResolve = new Set<string>();
|
||||
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<string, string>;
|
||||
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<V2RepeaterProps> = ({
|
||||
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<string, any> = {};
|
||||
inputTypes.forEach((t: any) => {
|
||||
typeMap[t.columnName] = t;
|
||||
});
|
||||
|
||||
const columnMap: Record<string, any> = {};
|
||||
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<V2RepeaterProps> = ({
|
||||
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<V2RepeaterProps> = ({
|
||||
}, [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<string>();
|
||||
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<string>();
|
||||
|
||||
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<V2RepeaterProps> = ({
|
||||
|
||||
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<V2RepeaterProps> = ({
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[categoryLabelMap],
|
||||
);
|
||||
|
||||
// 🆕 채번 API 호출 (비동기)
|
||||
@@ -801,7 +977,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
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<V2RepeaterProps> = ({
|
||||
return row;
|
||||
});
|
||||
|
||||
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
|
||||
const categoryColSet = new Set(allCategoryColumns);
|
||||
const codesToResolve = new Set<string>();
|
||||
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<string, string>;
|
||||
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<V2RepeaterProps> = ({
|
||||
// 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<V2RepeaterProps> = ({
|
||||
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<V2RepeaterProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string, string>;
|
||||
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<V2RepeaterProps> = ({
|
||||
// 모든 컬럼 처리 (순서대로)
|
||||
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<V2RepeaterProps> = ({
|
||||
}),
|
||||
);
|
||||
|
||||
// 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환
|
||||
const categoryColSet = new Set(allCategoryColumns);
|
||||
const unresolvedCodes = new Set<string>();
|
||||
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<string, string>;
|
||||
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<V2RepeaterProps> = ({
|
||||
generateAutoFillValueSync,
|
||||
generateNumberingCode,
|
||||
parentFormData,
|
||||
categoryLabelMap,
|
||||
allCategoryColumns,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -951,9 +1257,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
}, [config.columns]);
|
||||
|
||||
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeFormSave = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
|
||||
Reference in New Issue
Block a user