ERP-node/frontend/components/v2/V2Repeater.tsx

1529 lines
57 KiB
TypeScript
Raw Normal View History

2025-12-23 14:45:19 +09:00
"use client";
/**
* V2Repeater
2025-12-23 14:45:19 +09:00
*
* :
* - inline: 현재
* - modal: 엔티티 (FK ) +
*
2025-12-24 10:31:36 +09:00
* RepeaterTable ItemSelectionModal
2025-12-23 14:45:19 +09:00
*/
2026-01-15 13:38:01 +09:00
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
2025-12-23 14:45:19 +09:00
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
2025-12-23 14:45:19 +09:00
import { cn } from "@/lib/utils";
import {
V2RepeaterConfig,
V2RepeaterProps,
RepeaterColumnConfig as V2ColumnConfig,
2025-12-23 14:45:19 +09:00
DEFAULT_REPEATER_CONFIG,
} from "@/types/v2-repeater";
2025-12-23 14:45:19 +09:00
import { apiClient } from "@/lib/api/client";
2026-01-15 13:38:01 +09:00
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";
2025-12-23 14:45:19 +09:00
2025-12-24 10:31:36 +09:00
// modal-repeater-table 컴포넌트 재사용
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
2025-12-23 14:45:19 +09:00
// 전역 V2Repeater 등록 (buttonActions에서 사용)
2025-12-24 09:58:22 +09:00
declare global {
interface Window {
__v2RepeaterInstances?: Set<string>;
2025-12-24 09:58:22 +09:00
}
}
export const V2Repeater: React.FC<V2RepeaterProps> = ({
2025-12-23 14:45:19 +09:00
config: propConfig,
componentId,
2025-12-23 14:45:19 +09:00
parentId,
data: initialData,
onDataChange,
onRowClick,
className,
formData: parentFormData,
...restProps
2025-12-23 14:45:19 +09:00
}) => {
// 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();
2025-12-23 14:45:19 +09:00
// 설정 병합
const config: V2RepeaterConfig = useMemo(
2025-12-23 14:45:19 +09:00
() => ({
...DEFAULT_REPEATER_CONFIG,
...propConfig,
dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource },
features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features },
modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal },
}),
[propConfig],
);
2025-12-24 10:31:36 +09:00
// 상태
2025-12-23 14:45:19 +09:00
const [data, setData] = useState<any[]>(initialData || []);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
2025-12-24 10:31:36 +09:00
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);
// 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<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);
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]);
2025-12-24 10:31:36 +09:00
// 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
const categoryLabelMapRef = useRef<Record<string, string>>({});
useEffect(() => {
categoryLabelMapRef.current = categoryLabelMap;
}, [categoryLabelMap]);
2025-12-24 10:31:36 +09:00
// 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
2025-12-24 10:31:36 +09:00
// 동적 데이터 소스 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
2025-12-24 10:31:36 +09:00
const isModalMode = config.renderMode === "modal";
2025-12-24 09:58:22 +09:00
2025-12-24 10:31:36 +09:00
// 전역 리피터 등록
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
2025-12-24 09:58:22 +09:00
useEffect(() => {
const targetTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTableName) {
if (!window.__v2RepeaterInstances) {
window.__v2RepeaterInstances = new Set();
2025-12-24 10:31:36 +09:00
}
window.__v2RepeaterInstances.add(targetTableName);
2025-12-24 09:58:22 +09:00
}
return () => {
if (targetTableName && window.__v2RepeaterInstances) {
window.__v2RepeaterInstances.delete(targetTableName);
2025-12-24 10:31:36 +09:00
}
2025-12-24 09:58:22 +09:00
};
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
2025-12-23 14:45:19 +09:00
// 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조)
2025-12-24 09:58:22 +09:00
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
const currentData = dataRef.current;
const currentCategoryMap = categoryLabelMapRef.current;
const configTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
const tableName = configTableName || event.detail?.tableName;
2025-12-24 10:31:36 +09:00
const mainFormData = event.detail?.mainFormData;
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
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"));
2025-12-24 09:58:22 +09:00
return;
}
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,
foreignKeyColumn: config.foreignKeyColumn,
masterRecordId,
dataLength: currentData.length,
});
2025-12-24 09:58:22 +09:00
try {
let validColumns: Set<string> = new Set();
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns =
columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
2025-12-24 09:58:22 +09:00
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
} catch {
2025-12-24 10:31:36 +09:00
console.warn("테이블 컬럼 정보 조회 실패");
2025-12-24 09:58:22 +09:00
}
for (let i = 0; i < currentData.length; i++) {
const row = currentData[i];
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
2025-12-24 09:58:22 +09:00
let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) {
mergedData = { ...cleanRow };
if (config.foreignKeyColumn) {
const sourceColumn = config.foreignKeySourceColumn;
let fkValue: any;
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
fkValue = mainFormData[sourceColumn];
} else {
fkValue = masterRecordId;
}
if (fkValue !== undefined && fkValue !== null) {
mergedData[config.foreignKeyColumn] = fkValue;
}
}
} else {
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = {
...mainFormDataWithoutId,
...cleanRow,
};
}
2025-12-24 09:58:22 +09:00
const filteredData: Record<string, any> = {};
for (const [key, value] of Object.entries(mergedData)) {
if (validColumns.size === 0 || validColumns.has(key)) {
if (typeof value === "string" && currentCategoryMap[value]) {
filteredData[key] = currentCategoryMap[value];
} else {
filteredData[key] = value;
}
2025-12-24 09:58:22 +09:00
}
}
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("-")) {
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: rowId },
updatedData: updateFields,
});
} else {
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
}
2025-12-24 09:58:22 +09:00
}
// 삭제된 행 처리: 원본에는 있었지만 현재 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}건 저장 완료`);
2025-12-24 09:58:22 +09:00
} catch (error) {
console.error("❌ V2Repeater 저장 실패:", error);
toast.error(`V2Repeater 저장 실패: ${error}`);
} finally {
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
2025-12-24 09:58:22 +09:00
}
};
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.REPEATER_SAVE,
async (payload) => {
const configTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (!configTableName || payload.tableName === configTableName) {
await handleSaveEvent({ detail: payload } as CustomEvent);
}
},
{ componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` },
);
2025-12-24 09:58:22 +09:00
window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => {
unsubscribe();
2025-12-24 09:58:22 +09:00
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
}, [
config.dataSource?.tableName,
config.useCustomTable,
config.mainTableName,
config.foreignKeyColumn,
config.foreignKeySourceColumn,
parentId,
]);
2025-12-24 09:58:22 +09:00
// 수정 모드: useCustomTable + FK 기반으로 기존 디테일 데이터 자동 로드
const dataLoadedRef = useRef(false);
useEffect(() => {
if (dataLoadedRef.current) return;
if (!config.useCustomTable || !config.mainTableName || !config.foreignKeyColumn) return;
if (!parentFormData) return;
const fkSourceColumn = config.foreignKeySourceColumn || config.foreignKeyColumn;
const fkValue = parentFormData[fkSourceColumn];
if (!fkValue) return;
// 이미 데이터가 있으면 로드하지 않음
if (data.length > 0) return;
const loadExistingData = async () => {
try {
console.log("📥 [V2Repeater] 수정 모드 데이터 로드:", {
tableName: config.mainTableName,
fkColumn: config.foreignKeyColumn,
fkValue,
});
const response = await apiClient.post(
`/table-management/tables/${config.mainTableName}/data`,
{
page: 1,
size: 1000,
dataFilter: {
enabled: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
},
autoFilter: true,
}
);
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
if (Array.isArray(rows) && rows.length > 0) {
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`);
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey;
const refKey = config.dataSource?.referenceKey || "id";
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
try {
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
const uniqueValues = [...new Set(fkValues)];
if (uniqueValues.length > 0) {
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
const sourcePromises = uniqueValues.map((val) =>
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1, size: 1,
search: { [refKey]: val },
autoFilter: true,
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
.catch(() => [])
);
const sourceResults = await Promise.all(sourcePromises);
const sourceMap = new Map<string, any>();
sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
// 각 행에 소스 테이블의 표시 데이터 병합
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
sourceDisplayColumns.forEach((col) => {
const displayValue = sourceRecord[col.key] ?? null;
row[col.key] = displayValue;
row[`_display_${col.key}`] = displayValue;
});
}
});
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
}
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
}
}
// 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);
}
};
loadExistingData();
}, [
config.useCustomTable,
config.mainTableName,
config.foreignKeyColumn,
config.foreignKeySourceColumn,
parentFormData,
data.length,
onDataChange,
]);
2025-12-24 10:31:36 +09:00
// 현재 테이블 컬럼 정보 로드
2025-12-24 09:58:22 +09:00
useEffect(() => {
const loadCurrentTableColumnInfo = async () => {
const tableName = config.dataSource?.tableName;
if (!tableName) return;
try {
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;
});
2025-12-24 10:31:36 +09:00
const columnMap: Record<string, any> = {};
columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name;
const typeInfo = typeMap[name];
2025-12-24 10:31:36 +09:00
columnMap[name] = {
inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text",
2025-12-24 10:31:36 +09:00
displayName: col.displayName || col.display_name || col.label || name,
detailSettings: col.detailSettings || col.detail_settings,
categoryRef: typeInfo?.categoryRef || null,
2025-12-24 10:31:36 +09:00
};
});
setCurrentTableColumnInfo(columnMap);
2025-12-24 09:58:22 +09:00
} catch (error) {
2025-12-24 10:31:36 +09:00
console.error("컬럼 정보 로드 실패:", error);
2025-12-24 09:58:22 +09:00
}
};
loadCurrentTableColumnInfo();
}, [config.dataSource?.tableName]);
// 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서)
useEffect(() => {
const resolveEntityReference = async () => {
const tableName = config.dataSource?.tableName;
const foreignKey = config.dataSource?.foreignKey;
if (!isModalMode || !tableName || !foreignKey) {
// config에 저장된 값을 기본값으로 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
return;
}
try {
// 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey);
if (fkColumn) {
// column_labels의 reference_table 사용 (항상 최신값)
const refTable =
fkColumn.detailSettings?.referenceTable ||
fkColumn.reference_table ||
fkColumn.referenceTable ||
config.dataSource?.sourceTable ||
"";
const refKey =
fkColumn.detailSettings?.referenceColumn ||
fkColumn.reference_column ||
fkColumn.referenceColumn ||
config.dataSource?.referenceKey ||
"id";
setResolvedSourceTable(refTable);
setResolvedReferenceKey(refKey);
} else {
// FK 컬럼을 찾지 못한 경우 config 값 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
}
} catch (error) {
console.error("엔티티 참조 정보 조회 실패:", error);
// 오류 시 config 값 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
}
};
resolveEntityReference();
}, [
config.dataSource?.tableName,
config.dataSource?.foreignKey,
config.dataSource?.sourceTable,
config.dataSource?.referenceKey,
isModalMode,
]);
// 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용
// 🆕 카테고리 타입 컬럼도 함께 감지
useEffect(() => {
2025-12-24 10:31:36 +09:00
const loadSourceColumnLabels = async () => {
if (!isModalMode || !resolvedSourceTable) return;
2025-12-24 10:31:36 +09:00
try {
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
2025-12-24 10:31:36 +09:00
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
2025-12-24 10:31:36 +09:00
const labels: Record<string, string> = {};
const categoryCols: string[] = [];
columns.forEach((col: any) => {
2025-12-24 10:31:36 +09:00
const name = col.columnName || col.column_name || col.name;
labels[name] = col.displayName || col.display_name || col.label || name;
// 🆕 카테고리 타입 컬럼 감지
const inputType = col.inputType || col.input_type || "";
if (inputType === "category") {
categoryCols.push(name);
}
});
2025-12-24 10:31:36 +09:00
setSourceColumnLabels(labels);
setSourceCategoryColumns(categoryCols);
} catch (error) {
2025-12-24 10:31:36 +09:00
console.error("소스 컬럼 라벨 로드 실패:", error);
}
};
2025-12-24 10:31:36 +09:00
loadSourceColumnLabels();
}, [resolvedSourceTable, isModalMode]);
// V2ColumnConfig → RepeaterColumnConfig 변환
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
2025-12-24 10:31:36 +09:00
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
return config.columns
.filter((col: V2ColumnConfig) => col.visible !== false)
.map((col: V2ColumnConfig): RepeaterColumnConfig => {
const colInfo = currentTableColumnInfo[col.key];
const inputType = col.inputType || colInfo?.inputType || "text";
// 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용)
if (col.isSourceDisplay) {
const label = col.title || sourceColumnLabels[col.key] || col.key;
return {
field: `_display_${col.key}`,
2025-12-24 10:31:36 +09:00
label,
type: "text",
editable: false,
calculated: true,
width: col.width === "auto" ? undefined : col.width,
};
}
// 일반 입력 컬럼
let type: "text" | "number" | "date" | "select" | "category" = "text";
if (inputType === "number" || inputType === "decimal") type = "number";
else if (inputType === "date" || inputType === "datetime") type = "date";
else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
// 카테고리 참조 ID 결정
// DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용
let categoryRef: string | undefined;
if (inputType === "category") {
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}`;
}
}
2025-12-24 09:58:22 +09:00
}
return {
field: col.key,
label: col.title || colInfo?.displayName || col.key,
type,
editable: col.editable !== false,
width: col.width === "auto" ? undefined : col.width,
required: false,
categoryRef, // 🆕 카테고리 참조 ID 전달
2026-01-15 13:38:01 +09:00
hidden: col.hidden, // 🆕 히든 처리
autoFill: col.autoFill, // 🆕 자동 입력 설정
};
});
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
// 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(() => {
if (!parentFormData) return;
const codes: string[] = [];
// 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);
}
}
// 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);
}
}
}
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(
(row: any): any => {
const rules = config.calculationRules;
if (!rules || rules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of rules) {
if (!rule.targetColumn || !rule.formula) continue;
try {
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
for (const field of fieldMatches) {
if (field === rule.targetColumn) continue;
// 직접 필드 → _display_* 필드 순으로 값 탐색
const raw = updatedRow[field] ?? updatedRow[`_display_${field}`];
const value = parseFloat(raw) || 0;
formula = formula.replace(new RegExp(`\\b${field}\\b`, "g"), value.toString());
}
updatedRow[rule.targetColumn] = new Function(`return ${formula}`)();
} catch {
updatedRow[rule.targetColumn] = 0;
}
}
return updatedRow;
},
[config.calculationRules],
);
// _targetTable 메타데이터 포함하여 onDataChange 호출
const notifyDataChange = useCallback(
(newData: any[]) => {
if (!onDataChange) return;
const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) {
onDataChange(newData.map((row) => ({ ...row, _targetTable: targetTable })));
} else {
onDataChange(newData);
}
},
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
);
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
const calculated = newData.map(applyCalculationRules);
setData(calculated);
notifyDataChange(calculated);
setAutoWidthTrigger((prev) => prev + 1);
},
[applyCalculationRules, notifyDataChange],
);
2025-12-24 10:31:36 +09:00
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const calculated = applyCalculationRules(newRow);
const newData = [...data];
newData[index] = calculated;
setData(newData);
notifyDataChange(newData);
},
[data, applyCalculationRules, notifyDataChange],
);
2025-12-24 10:31:36 +09:00
// 행 삭제 핸들러
const handleRowDelete = useCallback(
(index: number) => {
const newData = data.filter((_, i) => i !== index);
handleDataChange(newData); // 🆕 handleDataChange 사용
// 선택 상태 업데이트
const newSelected = new Set<number>();
selectedRows.forEach((i) => {
if (i < index) newSelected.add(i);
else if (i > index) newSelected.add(i - 1);
});
setSelectedRows(newSelected);
},
[data, selectedRows, handleDataChange],
);
2025-12-24 10:31:36 +09:00
// 일괄 삭제 핸들러
const handleBulkDelete = useCallback(() => {
const newData = data.filter((_, index) => !selectedRows.has(index));
handleDataChange(newData); // 🆕 handleDataChange 사용
2025-12-24 10:31:36 +09:00
setSelectedRows(new Set());
}, [data, selectedRows, handleDataChange]);
2025-12-24 10:31:36 +09:00
// 행 추가 (inline 모드)
2026-01-15 13:38:01 +09:00
// 🆕 자동 입력 값 생성 함수 (동기 - 채번 제외)
const generateAutoFillValueSync = useCallback(
(col: any, rowIndex: number, mainFormData?: Record<string, unknown>) => {
if (!col.autoFill || col.autoFill.type === "none") return undefined;
const now = new Date();
2026-01-15 13:38:01 +09:00
switch (col.autoFill.type) {
case "currentDate":
return now.toISOString().split("T")[0]; // YYYY-MM-DD
2026-01-15 13:38:01 +09:00
case "currentDateTime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
2026-01-15 13:38:01 +09:00
case "sequence":
return rowIndex + 1; // 1부터 시작하는 순번
2026-01-15 13:38:01 +09:00
case "numbering":
// 채번은 별도 비동기 처리 필요
return null; // null 반환하여 비동기 처리 필요함을 표시
2026-01-15 13:38:01 +09:00
case "fromMainForm":
if (col.autoFill.sourceField && mainFormData) {
const rawValue = mainFormData[col.autoFill.sourceField];
// categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관)
if (typeof rawValue === "string" && categoryLabelMap[rawValue]) {
return categoryLabelMap[rawValue];
}
return rawValue;
2026-01-15 13:38:01 +09:00
}
return "";
2026-01-15 13:38:01 +09:00
case "fixed":
return col.autoFill.fixedValue ?? "";
case "parentSequence": {
const parentField = col.autoFill.parentField;
const separator = col.autoFill.separator ?? "-";
const seqLength = col.autoFill.sequenceLength ?? 2;
const parentValue = parentField && mainFormData ? String(mainFormData[parentField] ?? "") : "";
const seqNum = String(rowIndex + 1).padStart(seqLength, "0");
return parentValue ? `${parentValue}${separator}${seqNum}` : seqNum;
}
2026-01-15 13:38:01 +09:00
default:
return undefined;
}
},
[categoryLabelMap],
2026-01-15 13:38:01 +09:00
);
// 🆕 채번 API 호출 (비동기)
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(
async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
console.error("채번 실패:", result.error);
return "";
} catch (error) {
console.error("채번 API 호출 실패:", error);
return "";
2026-01-15 13:38:01 +09:00
}
},
[],
);
2026-01-15 13:38:01 +09:00
// 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함)
const groupedDataProcessedRef = useRef(false);
useEffect(() => {
if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return;
if (groupedDataProcessedRef.current) return;
groupedDataProcessedRef.current = true;
const newRows = groupedData.map((item: any, index: number) => {
const row: any = { _id: `grouped_${Date.now()}_${index}` };
for (const col of config.columns) {
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 ?? "";
row[`_display_${col.key}`] = sourceValue ?? "";
} else if (col.autoFill && col.autoFill.type !== "none") {
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
if (autoValue !== undefined) {
row[col.key] = autoValue;
} else {
row[col.key] = "";
}
} else if (sourceValue !== undefined) {
row[col.key] = sourceValue;
} else {
row[col.key] = "";
}
}
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
}, [groupedData, config.columns, generateAutoFillValueSync]);
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
useEffect(() => {
if (data.length === 0) return;
const parentSeqColumns = config.columns.filter(
(col) => col.autoFill?.type === "parentSequence" && col.autoFill.parentField,
);
if (parentSeqColumns.length === 0) return;
let needsUpdate = false;
const updatedData = data.map((row, index) => {
const updatedRow = { ...row };
for (const col of parentSeqColumns) {
const newValue = generateAutoFillValueSync(col, index, parentFormData);
if (newValue !== undefined && newValue !== row[col.key]) {
updatedRow[col.key] = newValue;
needsUpdate = true;
}
}
return updatedRow;
});
if (needsUpdate) {
setData(updatedData);
onDataChange?.(updatedData);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentFormData, config.columns, generateAutoFillValueSync]);
// 행 추가 (inline 모드 또는 모달 열기)
2026-01-15 13:38:01 +09:00
const handleAddRow = useCallback(async () => {
2025-12-24 10:31:36 +09:00
if (isModalMode) {
setModalOpen(true);
2025-12-23 14:45:19 +09:00
} else {
2025-12-24 10:31:36 +09:00
const newRow: any = { _id: `new_${Date.now()}` };
2026-01-15 13:38:01 +09:00
const currentRowCount = data.length;
// 동기적 자동 입력 값 적용
2026-01-15 13:38:01 +09:00
for (const col of config.columns) {
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
2026-01-15 13:38:01 +09:00
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) {
newRow[col.key] = autoValue;
} else {
newRow[col.key] = "";
2026-01-15 13:38:01 +09:00
}
}
// 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 {
// 변환 실패 시 코드 유지
}
}
2025-12-23 14:45:19 +09:00
const newData = [...data, newRow];
2026-01-15 13:38:01 +09:00
handleDataChange(newData);
2025-12-23 14:45:19 +09:00
}
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]);
2026-01-15 13:38:01 +09:00
// 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback(
2026-01-15 13:38:01 +09:00
async (items: Record<string, unknown>[]) => {
const fkColumn = config.dataSource?.foreignKey;
2026-01-15 13:38:01 +09:00
const currentRowCount = data.length;
2026-01-15 13:38:01 +09:00
// 채번이 필요한 컬럼 찾기
const numberingColumns = config.columns.filter(
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId,
2026-01-15 13:38:01 +09:00
);
2026-01-15 13:38:01 +09:00
const newRows = await Promise.all(
items.map(async (item, index) => {
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
2026-01-15 13:38:01 +09:00
// FK 값 저장 (resolvedReferenceKey 사용)
if (fkColumn && item[resolvedReferenceKey]) {
row[fkColumn] = item[resolvedReferenceKey];
}
2026-01-15 13:38:01 +09:00
// 모든 컬럼 처리 (순서대로)
for (const col of config.columns) {
if (col.isSourceDisplay) {
let displayVal = item[col.key] || "";
// 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관)
if (typeof displayVal === "string" && categoryLabelMap[displayVal]) {
displayVal = categoryLabelMap[displayVal];
}
row[`_display_${col.key}`] = displayVal;
2026-01-15 13:38:01 +09:00
} else {
// 자동 입력 값 적용
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
2026-01-15 13:38:01 +09:00
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
// 채번 규칙: 즉시 API 호출
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) {
row[col.key] = autoValue;
} else if (row[col.key] === undefined) {
// 입력 컬럼: 빈 값으로 초기화
row[col.key] = "";
}
}
2025-12-24 10:31:36 +09:00
}
return row;
}),
2026-01-15 13:38:01 +09:00
);
// 카테고리/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];
2026-01-15 13:38:01 +09:00
handleDataChange(newData);
setModalOpen(false);
},
[
config.dataSource?.foreignKey,
resolvedReferenceKey,
config.columns,
data,
handleDataChange,
generateAutoFillValueSync,
generateNumberingCode,
parentFormData,
categoryLabelMap,
allCategoryColumns,
],
);
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
2025-12-24 10:31:36 +09:00
const sourceColumns = useMemo(() => {
return config.columns
.filter((col) => col.isSourceDisplay && col.visible !== false)
.map((col) => col.key)
2025-12-24 10:31:36 +09:00
.filter((key) => key && key !== "none");
}, [config.columns]);
2025-12-23 14:45:19 +09:00
2026-01-15 13:38:01 +09:00
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
useEffect(() => {
const handleBeforeFormSave = async (event: Event) => {
const customEvent = event as CustomEvent;
const formData = customEvent.detail?.formData;
2026-01-15 13:38:01 +09:00
if (!formData || !dataRef.current.length) return;
2026-01-15 13:38:01 +09:00
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
const processedData = await Promise.all(
dataRef.current.map(async (row) => {
const newRow = { ...row };
2026-01-15 13:38:01 +09:00
for (const key of Object.keys(newRow)) {
const value = newRow[key];
if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) {
// __NUMBERING_RULE__ruleId__ 형식에서 ruleId 추출
const match = value.match(/__NUMBERING_RULE__(.+)__/);
if (match) {
const ruleId = match[1];
try {
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const result = await allocateNumberingCode(ruleId, undefined, newRow);
2026-01-15 13:38:01 +09:00
if (result.success && result.data?.generatedCode) {
newRow[key] = result.data.generatedCode;
} else {
console.error("채번 실패:", result.error);
newRow[key] = ""; // 채번 실패 시 빈 값
}
} catch (error) {
console.error("채번 API 호출 실패:", error);
newRow[key] = "";
}
}
}
}
2026-01-15 13:38:01 +09:00
return newRow;
}),
);
2026-01-15 13:38:01 +09:00
// 처리된 데이터를 formData에 추가
const fieldName = config.fieldName || "repeaterData";
formData[fieldName] = processedData;
};
// V2 EventBus 구독
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.FORM_SAVE_COLLECT,
async (payload) => {
// formData 객체가 있으면 데이터 수집
const fakeEvent = {
detail: { formData: payload.formData },
} as CustomEvent;
await handleBeforeFormSave(fakeEvent);
},
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
2026-01-15 13:38:01 +09:00
window.addEventListener("beforeFormSave", handleBeforeFormSave);
2026-01-15 13:38:01 +09:00
return () => {
unsubscribe();
2026-01-15 13:38:01 +09:00
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
};
}, [config.fieldName]);
// 🆕 데이터 전달 이벤트 리스너 (transferData 버튼 액션용)
useEffect(() => {
// componentDataTransfer: 특정 컴포넌트 ID로 데이터 전달
const handleComponentDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
// 이 컴포넌트가 대상인지 확인
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
return;
}
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
// 매핑 규칙이 있으면 적용
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else if (mode === "merge") {
// 중복 제거 후 병합 (id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
handleDataChange([...data, ...newItems]);
} else {
// 기본: append
handleDataChange([...data, ...mappedData]);
}
};
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
const handleSplitPanelDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else {
handleDataChange([...data, ...mappedData]);
}
};
// V2 EventBus 구독
const unsubscribeComponent = v2EventBus.subscribe(
V2_EVENTS.COMPONENT_DATA_TRANSFER,
(payload) => {
const fakeEvent = {
detail: {
targetComponentId: payload.targetComponentId,
transferData: [payload.data],
mappingRules: [],
mode: "append",
},
} as CustomEvent;
handleComponentDataTransfer(fakeEvent);
},
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
);
const unsubscribeSplitPanel = v2EventBus.subscribe(
V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER,
(payload) => {
const fakeEvent = {
detail: {
transferData: [payload.data],
mappingRules: [],
mode: "append",
},
} as CustomEvent;
handleSplitPanelDataTransfer(fakeEvent);
},
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
unsubscribeComponent();
unsubscribeSplitPanel();
window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [parentId, config.fieldName, data, handleDataChange]);
2025-12-24 10:31:36 +09:00
return (
<div className={cn("flex h-full flex-col overflow-hidden", className)}>
2025-12-24 10:31:36 +09:00
{/* 헤더 영역 */}
<div className="flex shrink-0 items-center justify-between pb-2">
2025-12-24 10:31:36 +09:00
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
2025-12-24 10:31:36 +09:00
{data.length > 0 && `${data.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
2025-12-23 14:45:19 +09:00
</div>
2025-12-24 10:31:36 +09:00
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
2025-12-24 10:31:36 +09:00
({selectedRows.size})
</Button>
)}
<Button onClick={handleAddRow} className="h-8 text-xs sm:h-10 sm:text-sm">
<Plus className="mr-2 h-4 w-4" />
{isModalMode ? config.modal?.buttonText || "검색" : "추가"}
2025-12-23 14:45:19 +09:00
</Button>
2025-12-24 10:31:36 +09:00
</div>
2025-12-23 14:45:19 +09:00
</div>
{/* Repeater 테이블 - 남은 공간에서 스크롤 */}
<div className="min-h-0 flex-1">
<RepeaterTable
columns={repeaterColumns}
data={data}
onDataChange={handleDataChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={(field, optionId) => {
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={autoWidthTrigger}
categoryColumns={allCategoryColumns}
categoryLabelMap={categoryLabelMap}
/>
</div>
2025-12-24 10:31:36 +09:00
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
2025-12-24 10:31:36 +09:00
{isModalMode && (
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={resolvedSourceTable}
2025-12-24 10:31:36 +09:00
sourceColumns={sourceColumns}
sourceSearchFields={sourceColumns}
2025-12-24 10:31:36 +09:00
multiSelect={config.features?.multiSelect ?? true}
modalTitle={config.modal?.title || "항목 검색"}
alreadySelected={data}
uniqueField={resolvedReferenceKey}
2025-12-24 10:31:36 +09:00
onSelect={handleSelectItems}
columnLabels={sourceColumnLabels}
categoryColumns={sourceCategoryColumns}
2025-12-24 10:31:36 +09:00
/>
2025-12-23 14:45:19 +09:00
)}
</div>
);
};
V2Repeater.displayName = "V2Repeater";
2025-12-23 14:45:19 +09:00
// V2ErrorBoundary로 래핑된 안전한 버전 export
export const SafeV2Repeater: React.FC<V2RepeaterProps> = (props) => {
return (
<V2ErrorBoundary componentId={props.parentId || "v2-repeater"} componentType="V2Repeater" fallbackStyle="compact">
<V2Repeater {...props} />
</V2ErrorBoundary>
);
};
export default V2Repeater;