1597 lines
61 KiB
TypeScript
1597 lines
61 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2Repeater 컴포넌트
|
|
*
|
|
* 렌더링 모드:
|
|
* - inline: 현재 테이블 컬럼 직접 입력
|
|
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
|
*
|
|
* RepeaterTable 및 ItemSelectionModal 재사용
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
V2RepeaterConfig,
|
|
V2RepeaterProps,
|
|
RepeaterColumnConfig as V2ColumnConfig,
|
|
DEFAULT_REPEATER_CONFIG,
|
|
} from "@/types/v2-repeater";
|
|
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";
|
|
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
|
|
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
|
|
|
|
// 전역 V2Repeater 등록 (buttonActions에서 사용)
|
|
declare global {
|
|
interface Window {
|
|
__v2RepeaterInstances?: Set<string>;
|
|
}
|
|
}
|
|
|
|
export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|
config: propConfig,
|
|
componentId,
|
|
parentId,
|
|
data: initialData,
|
|
onDataChange,
|
|
onRowClick,
|
|
className,
|
|
formData: parentFormData,
|
|
groupedData,
|
|
...restProps
|
|
}) => {
|
|
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
|
const effectiveComponentId = componentId || (restProps as any).component?.id;
|
|
|
|
// ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null)
|
|
const screenContext = useScreenContextOptional();
|
|
// 설정 병합
|
|
const config: V2RepeaterConfig = useMemo(
|
|
() => ({
|
|
...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],
|
|
);
|
|
|
|
// 상태
|
|
const [data, setData] = useState<any[]>(initialData || []);
|
|
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);
|
|
|
|
// 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]);
|
|
|
|
// 소스 테이블 컬럼 라벨 매핑
|
|
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]);
|
|
|
|
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
|
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
|
|
|
// 동적 데이터 소스 상태
|
|
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
|
|
|
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
|
|
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
|
|
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
|
|
|
|
const isModalMode = config.renderMode === "modal";
|
|
|
|
// 전역 리피터 등록
|
|
// tableName이 비어있어도 반드시 등록 (repeaterSave 이벤트 발행 가드에 필요)
|
|
useEffect(() => {
|
|
const targetTableName =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
const registrationKey = targetTableName || "__v2_repeater_same_table__";
|
|
|
|
if (!window.__v2RepeaterInstances) {
|
|
window.__v2RepeaterInstances = new Set();
|
|
}
|
|
window.__v2RepeaterInstances.add(registrationKey);
|
|
|
|
return () => {
|
|
if (window.__v2RepeaterInstances) {
|
|
window.__v2RepeaterInstances.delete(registrationKey);
|
|
}
|
|
};
|
|
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
|
|
|
// 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조)
|
|
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;
|
|
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"));
|
|
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,
|
|
});
|
|
|
|
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 || [];
|
|
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
|
|
} catch {
|
|
console.warn("테이블 컬럼 정보 조회 실패");
|
|
}
|
|
|
|
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 };
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
// 삭제된 행 처리: 원본에는 있었지만 현재 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);
|
|
toast.error(`V2Repeater 저장 실패: ${error}`);
|
|
} finally {
|
|
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
|
}
|
|
};
|
|
|
|
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"}` },
|
|
);
|
|
|
|
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
|
return () => {
|
|
unsubscribe();
|
|
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
|
};
|
|
}, [
|
|
config.dataSource?.tableName,
|
|
config.useCustomTable,
|
|
config.mainTableName,
|
|
config.foreignKeyColumn,
|
|
config.foreignKeySourceColumn,
|
|
parentId,
|
|
]);
|
|
|
|
// 수정 모드: 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,
|
|
});
|
|
|
|
let rows: any[] = [];
|
|
const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin;
|
|
|
|
if (useEntityJoinForLoad) {
|
|
// 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인)
|
|
const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue });
|
|
const params: Record<string, any> = {
|
|
page: 1,
|
|
size: 1000,
|
|
search: searchParam,
|
|
enableEntityJoin: true,
|
|
autoFilter: JSON.stringify({ enabled: true }),
|
|
};
|
|
const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns;
|
|
if (addJoinCols && addJoinCols.length > 0) {
|
|
params.additionalJoinColumns = JSON.stringify(addJoinCols);
|
|
}
|
|
const response = await apiClient.get(
|
|
`/table-management/tables/${config.mainTableName}/data-with-joins`,
|
|
{ params }
|
|
);
|
|
const resultData = response.data?.data;
|
|
const rawRows = Array.isArray(resultData)
|
|
? resultData
|
|
: resultData?.data || resultData?.rows || [];
|
|
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거
|
|
const seenIds = new Set<string>();
|
|
rows = rawRows.filter((row: any) => {
|
|
if (!row.id || seenIds.has(row.id)) return false;
|
|
seenIds.add(row.id);
|
|
return true;
|
|
});
|
|
} else {
|
|
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,
|
|
}
|
|
);
|
|
rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
|
}
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : "");
|
|
|
|
// 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강
|
|
const columnMapping = config.sourceDetailConfig?.columnMapping;
|
|
if (useEntityJoinForLoad && columnMapping) {
|
|
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
|
|
rows.forEach((row: any) => {
|
|
sourceDisplayColumns.forEach((col) => {
|
|
const mappedKey = columnMapping[col.key];
|
|
const value = mappedKey ? row[mappedKey] : row[col.key];
|
|
row[`_display_${col.key}`] = value ?? "";
|
|
});
|
|
});
|
|
console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료");
|
|
}
|
|
|
|
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시)
|
|
if (!useEntityJoinForLoad) {
|
|
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) {
|
|
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,
|
|
]);
|
|
|
|
// 현재 테이블 컬럼 정보 로드
|
|
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;
|
|
});
|
|
|
|
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: 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);
|
|
} catch (error) {
|
|
console.error("컬럼 정보 로드 실패:", error);
|
|
}
|
|
};
|
|
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(() => {
|
|
const loadSourceColumnLabels = async () => {
|
|
if (!isModalMode || !resolvedSourceTable) return;
|
|
|
|
try {
|
|
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
|
|
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
|
|
|
const labels: Record<string, string> = {};
|
|
const categoryCols: string[] = [];
|
|
|
|
columns.forEach((col: any) => {
|
|
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);
|
|
}
|
|
});
|
|
|
|
setSourceColumnLabels(labels);
|
|
setSourceCategoryColumns(categoryCols);
|
|
} catch (error) {
|
|
console.error("소스 컬럼 라벨 로드 실패:", error);
|
|
}
|
|
};
|
|
loadSourceColumnLabels();
|
|
}, [resolvedSourceTable, isModalMode]);
|
|
|
|
// V2ColumnConfig → RepeaterColumnConfig 변환
|
|
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
|
|
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}`,
|
|
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}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 전달
|
|
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],
|
|
);
|
|
|
|
// 행 변경 핸들러
|
|
const handleRowChange = useCallback(
|
|
(index: number, newRow: any) => {
|
|
const calculated = applyCalculationRules(newRow);
|
|
const newData = [...data];
|
|
newData[index] = calculated;
|
|
setData(newData);
|
|
notifyDataChange(newData);
|
|
},
|
|
[data, applyCalculationRules, notifyDataChange],
|
|
);
|
|
|
|
// 행 삭제 핸들러
|
|
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],
|
|
);
|
|
|
|
// 일괄 삭제 핸들러
|
|
const handleBulkDelete = useCallback(() => {
|
|
const newData = data.filter((_, index) => !selectedRows.has(index));
|
|
handleDataChange(newData); // 🆕 handleDataChange 사용
|
|
setSelectedRows(new Set());
|
|
}, [data, selectedRows, handleDataChange]);
|
|
|
|
// 행 추가 (inline 모드)
|
|
// 🆕 자동 입력 값 생성 함수 (동기 - 채번 제외)
|
|
const generateAutoFillValueSync = useCallback(
|
|
(col: any, rowIndex: number, mainFormData?: Record<string, unknown>) => {
|
|
if (!col.autoFill || col.autoFill.type === "none") return undefined;
|
|
|
|
const now = new Date();
|
|
|
|
switch (col.autoFill.type) {
|
|
case "currentDate":
|
|
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
|
|
|
case "currentDateTime":
|
|
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
|
|
|
case "sequence":
|
|
return rowIndex + 1; // 1부터 시작하는 순번
|
|
|
|
case "numbering":
|
|
// 채번은 별도 비동기 처리 필요
|
|
return null; // null 반환하여 비동기 처리 필요함을 표시
|
|
|
|
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;
|
|
}
|
|
return "";
|
|
|
|
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;
|
|
}
|
|
|
|
default:
|
|
return undefined;
|
|
}
|
|
},
|
|
[categoryLabelMap],
|
|
);
|
|
|
|
// 🆕 채번 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 "";
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면
|
|
// 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅
|
|
const sourceDetailLoadedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (sourceDetailLoadedRef.current) return;
|
|
if (!groupedData || groupedData.length === 0) return;
|
|
if (!config.sourceDetailConfig) return;
|
|
|
|
const { tableName, foreignKey, parentKey } = config.sourceDetailConfig;
|
|
if (!tableName || !foreignKey || !parentKey) return;
|
|
|
|
const parentKeys = groupedData
|
|
.map((row) => row[parentKey])
|
|
.filter((v) => v !== undefined && v !== null && v !== "");
|
|
|
|
if (parentKeys.length === 0) return;
|
|
|
|
sourceDetailLoadedRef.current = true;
|
|
|
|
const loadSourceDetails = async () => {
|
|
try {
|
|
const uniqueKeys = [...new Set(parentKeys)] as string[];
|
|
const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!;
|
|
|
|
let detailRows: any[] = [];
|
|
|
|
if (useEntityJoin) {
|
|
// data-with-joins GET API 사용 (엔티티 조인 자동 적용)
|
|
const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") });
|
|
const params: Record<string, any> = {
|
|
page: 1,
|
|
size: 9999,
|
|
search: searchParam,
|
|
enableEntityJoin: true,
|
|
autoFilter: JSON.stringify({ enabled: true }),
|
|
};
|
|
if (additionalJoinColumns && additionalJoinColumns.length > 0) {
|
|
params.additionalJoinColumns = JSON.stringify(additionalJoinColumns);
|
|
}
|
|
const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params });
|
|
const resultData = resp.data?.data;
|
|
const rawRows = Array.isArray(resultData)
|
|
? resultData
|
|
: resultData?.data || resultData?.rows || [];
|
|
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거
|
|
const seenIds = new Set<string>();
|
|
detailRows = rawRows.filter((row: any) => {
|
|
if (!row.id || seenIds.has(row.id)) return false;
|
|
seenIds.add(row.id);
|
|
return true;
|
|
});
|
|
} else {
|
|
// 기존 POST API 사용
|
|
const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
|
page: 1,
|
|
size: 9999,
|
|
search: { [foreignKey]: uniqueKeys },
|
|
});
|
|
const resultData = resp.data?.data;
|
|
detailRows = Array.isArray(resultData)
|
|
? resultData
|
|
: resultData?.data || resultData?.rows || [];
|
|
}
|
|
|
|
if (detailRows.length === 0) {
|
|
console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys });
|
|
return;
|
|
}
|
|
|
|
console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : "");
|
|
|
|
// 디테일 행을 리피터 컬럼에 매핑
|
|
const newRows = detailRows.map((detail, index) => {
|
|
const row: any = { _id: `src_detail_${Date.now()}_${index}` };
|
|
for (const col of config.columns) {
|
|
if (col.isSourceDisplay) {
|
|
// columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용)
|
|
const mappedKey = columnMapping?.[col.key];
|
|
const value = mappedKey ? detail[mappedKey] : detail[col.key];
|
|
row[`_display_${col.key}`] = value ?? "";
|
|
// 원본 값도 저장 (DB persist용 - _display_ 접두사 없이)
|
|
if (detail[col.key] !== undefined) {
|
|
row[col.key] = detail[col.key];
|
|
}
|
|
} else if (col.autoFill) {
|
|
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
|
|
row[col.key] = autoValue ?? "";
|
|
} else if (col.sourceKey && detail[col.sourceKey] !== undefined) {
|
|
row[col.key] = detail[col.sourceKey];
|
|
} else if (detail[col.key] !== undefined) {
|
|
row[col.key] = detail[col.key];
|
|
} else {
|
|
row[col.key] = "";
|
|
}
|
|
}
|
|
return row;
|
|
});
|
|
|
|
setData(newRows);
|
|
onDataChange?.(newRows);
|
|
} catch (error) {
|
|
console.error("[V2Repeater] sourceDetail 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadSourceDetails();
|
|
}, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]);
|
|
|
|
// 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 모드 또는 모달 열기)
|
|
const handleAddRow = useCallback(async () => {
|
|
if (isModalMode) {
|
|
setModalOpen(true);
|
|
} else {
|
|
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) {
|
|
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
|
} else if (autoValue !== undefined) {
|
|
newRow[col.key] = autoValue;
|
|
} else {
|
|
newRow[col.key] = "";
|
|
}
|
|
}
|
|
|
|
// 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, categoryLabelMap, allCategoryColumns]);
|
|
|
|
// 모달에서 항목 선택 - 비동기로 변경
|
|
const handleSelectItems = useCallback(
|
|
async (items: Record<string, unknown>[]) => {
|
|
const fkColumn = config.dataSource?.foreignKey;
|
|
const currentRowCount = data.length;
|
|
|
|
// 채번이 필요한 컬럼 찾기
|
|
const numberingColumns = config.columns.filter(
|
|
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId,
|
|
);
|
|
|
|
const newRows = await Promise.all(
|
|
items.map(async (item, index) => {
|
|
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
|
|
|
|
// FK 값 저장 (resolvedReferenceKey 사용)
|
|
if (fkColumn && item[resolvedReferenceKey]) {
|
|
row[fkColumn] = item[resolvedReferenceKey];
|
|
}
|
|
|
|
// 모든 컬럼 처리 (순서대로)
|
|
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;
|
|
} else {
|
|
// 자동 입력 값 적용
|
|
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
|
|
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] = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
return row;
|
|
}),
|
|
);
|
|
|
|
// 카테고리/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);
|
|
},
|
|
[
|
|
config.dataSource?.foreignKey,
|
|
resolvedReferenceKey,
|
|
config.columns,
|
|
data,
|
|
handleDataChange,
|
|
generateAutoFillValueSync,
|
|
generateNumberingCode,
|
|
parentFormData,
|
|
categoryLabelMap,
|
|
allCategoryColumns,
|
|
],
|
|
);
|
|
|
|
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
|
|
const sourceColumns = useMemo(() => {
|
|
return config.columns
|
|
.filter((col) => col.isSourceDisplay && col.visible !== false)
|
|
.map((col) => col.key)
|
|
.filter((key) => key && key !== "none");
|
|
}, [config.columns]);
|
|
|
|
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
|
useEffect(() => {
|
|
const handleBeforeFormSave = async (event: Event) => {
|
|
const customEvent = event as CustomEvent;
|
|
const formData = customEvent.detail?.formData;
|
|
|
|
if (!formData || !dataRef.current.length) return;
|
|
|
|
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
|
|
const processedData = await Promise.all(
|
|
dataRef.current.map(async (row) => {
|
|
const newRow = { ...row };
|
|
|
|
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);
|
|
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] = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return newRow;
|
|
}),
|
|
);
|
|
|
|
// 처리된 데이터를 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}` },
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
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]);
|
|
|
|
return (
|
|
<div className={cn("flex h-full flex-col overflow-hidden", className)}>
|
|
{/* 헤더 영역 */}
|
|
<div className="flex shrink-0 items-center justify-between pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{data.length > 0 && `${data.length}개 항목`}
|
|
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selectedRows.size > 0 && (
|
|
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
선택 삭제 ({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 || "검색" : "추가"}
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
|
|
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
|
|
{isModalMode && (
|
|
<ItemSelectionModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
sourceTable={resolvedSourceTable}
|
|
sourceColumns={sourceColumns}
|
|
sourceSearchFields={sourceColumns}
|
|
multiSelect={config.features?.multiSelect ?? true}
|
|
modalTitle={config.modal?.title || "항목 검색"}
|
|
alreadySelected={data}
|
|
uniqueField={resolvedReferenceKey}
|
|
onSelect={handleSelectItems}
|
|
columnLabels={sourceColumnLabels}
|
|
categoryColumns={sourceCategoryColumns}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2Repeater.displayName = "V2Repeater";
|
|
|
|
// 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;
|