- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
1100 lines
40 KiB
TypeScript
1100 lines
40 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* UnifiedRepeater 컴포넌트
|
|
*
|
|
* 렌더링 모드:
|
|
* - inline: 현재 테이블 컬럼 직접 입력
|
|
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
|
*
|
|
* RepeaterTable 및 ItemSelectionModal 재사용
|
|
*
|
|
* 데이터 전달 인터페이스:
|
|
* - DataProvidable: 선택된 데이터 제공
|
|
* - DataReceivable: 외부에서 데이터 수신
|
|
* - repeaterDataChange 이벤트 발행
|
|
*/
|
|
|
|
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 {
|
|
UnifiedRepeaterConfig,
|
|
UnifiedRepeaterProps,
|
|
RepeaterColumnConfig as UnifiedColumnConfig,
|
|
DEFAULT_REPEATER_CONFIG,
|
|
} from "@/types/unified-repeater";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
|
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
|
|
|
// 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";
|
|
|
|
// 데이터 전달 인터페이스
|
|
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
|
|
|
// V2 이벤트 시스템
|
|
import { V2_EVENTS, dispatchV2Event } from "@/types/component-events";
|
|
|
|
// 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
|
|
declare global {
|
|
interface Window {
|
|
__unifiedRepeaterInstances?: Set<string>;
|
|
}
|
|
}
|
|
|
|
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|
config: propConfig,
|
|
parentId,
|
|
data: initialData,
|
|
onDataChange,
|
|
onRowClick,
|
|
className,
|
|
}) => {
|
|
// 설정 병합
|
|
const config: UnifiedRepeaterConfig = 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],
|
|
);
|
|
|
|
// ScreenContext (데이터 전달 인터페이스 등록용)
|
|
const screenContext = useScreenContextOptional();
|
|
|
|
// 상태
|
|
const [data, setData] = useState<any[]>(initialData || []);
|
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
|
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
|
|
|
// 소스 테이블 컬럼 라벨 매핑
|
|
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
|
|
|
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
|
|
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
|
|
|
|
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
|
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
|
|
|
// 현재 테이블 컬럼 정보 (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";
|
|
|
|
// 전역 리피터 등록
|
|
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
|
|
useEffect(() => {
|
|
const targetTableName =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
|
|
if (targetTableName) {
|
|
if (!window.__unifiedRepeaterInstances) {
|
|
window.__unifiedRepeaterInstances = new Set();
|
|
}
|
|
window.__unifiedRepeaterInstances.add(targetTableName);
|
|
}
|
|
|
|
return () => {
|
|
if (targetTableName && window.__unifiedRepeaterInstances) {
|
|
window.__unifiedRepeaterInstances.delete(targetTableName);
|
|
}
|
|
};
|
|
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
|
|
|
// ============================================================
|
|
// DataProvidable 인터페이스 구현
|
|
// 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함
|
|
// ============================================================
|
|
const dataProvider: DataProvidable = useMemo(
|
|
() => ({
|
|
componentId: parentId || config.fieldName || "unified-repeater",
|
|
componentType: "unified-repeater",
|
|
|
|
// 선택된 행 데이터 반환
|
|
getSelectedData: () => {
|
|
return Array.from(selectedRows)
|
|
.map((idx) => data[idx])
|
|
.filter(Boolean);
|
|
},
|
|
|
|
// 전체 데이터 반환
|
|
getAllData: () => {
|
|
return [...data];
|
|
},
|
|
|
|
// 선택 초기화
|
|
clearSelection: () => {
|
|
setSelectedRows(new Set());
|
|
},
|
|
}),
|
|
[parentId, config.fieldName, data, selectedRows],
|
|
);
|
|
|
|
// ============================================================
|
|
// DataReceivable 인터페이스 구현
|
|
// 외부에서 이 리피터로 데이터를 전달받을 수 있게 함
|
|
// ============================================================
|
|
const dataReceiver: DataReceivable = useMemo(
|
|
() => ({
|
|
componentId: parentId || config.fieldName || "unified-repeater",
|
|
componentType: "repeater",
|
|
|
|
// 데이터 수신 (append, replace, merge 모드 지원)
|
|
receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => {
|
|
if (!incomingData || incomingData.length === 0) return;
|
|
|
|
// 매핑 규칙 적용
|
|
const mappedData = incomingData.map((item, index) => {
|
|
const newRow: any = { _id: `received_${Date.now()}_${index}` };
|
|
|
|
if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) {
|
|
receiverConfig.mappingRules.forEach((rule) => {
|
|
const sourceValue = item[rule.sourceField];
|
|
newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue;
|
|
});
|
|
} else {
|
|
// 매핑 규칙 없으면 그대로 복사
|
|
Object.assign(newRow, item);
|
|
}
|
|
|
|
return newRow;
|
|
});
|
|
|
|
// 모드에 따라 데이터 처리
|
|
switch (receiverConfig.mode) {
|
|
case "replace":
|
|
setData(mappedData);
|
|
onDataChange?.(mappedData);
|
|
break;
|
|
case "merge":
|
|
// 중복 제거 후 병합 (id 또는 _id 기준)
|
|
const existingIds = new Set(data.map((row) => row.id || row._id));
|
|
const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id));
|
|
const mergedData = [...data, ...newItems];
|
|
setData(mergedData);
|
|
onDataChange?.(mergedData);
|
|
break;
|
|
case "append":
|
|
default:
|
|
const appendedData = [...data, ...mappedData];
|
|
setData(appendedData);
|
|
onDataChange?.(appendedData);
|
|
break;
|
|
}
|
|
},
|
|
|
|
// 현재 데이터 반환
|
|
getData: () => {
|
|
return [...data];
|
|
},
|
|
}),
|
|
[parentId, config.fieldName, data, onDataChange],
|
|
);
|
|
|
|
// ============================================================
|
|
// ScreenContext에 DataProvider/DataReceiver 등록
|
|
// ============================================================
|
|
useEffect(() => {
|
|
if (screenContext && (parentId || config.fieldName)) {
|
|
const componentId = parentId || config.fieldName || "unified-repeater";
|
|
|
|
screenContext.registerDataProvider(componentId, dataProvider);
|
|
screenContext.registerDataReceiver(componentId, dataReceiver);
|
|
|
|
return () => {
|
|
screenContext.unregisterDataProvider(componentId);
|
|
screenContext.unregisterDataReceiver(componentId);
|
|
};
|
|
}
|
|
}, [screenContext, parentId, config.fieldName, dataProvider, dataReceiver]);
|
|
|
|
// ============================================================
|
|
// repeaterDataChange 이벤트 발행
|
|
// 데이터 변경 시 다른 컴포넌트(aggregation-widget 등)에 알림
|
|
// ============================================================
|
|
const prevDataLengthRef = useRef(data.length);
|
|
useEffect(() => {
|
|
// 데이터가 변경되었을 때만 이벤트 발행
|
|
if (typeof window !== "undefined" && data.length !== prevDataLengthRef.current) {
|
|
dispatchV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, {
|
|
componentId: parentId || config.fieldName || "unified-repeater",
|
|
tableName: config.dataSource?.tableName || "",
|
|
data: data,
|
|
selectedData: Array.from(selectedRows)
|
|
.map((idx) => data[idx])
|
|
.filter(Boolean),
|
|
});
|
|
prevDataLengthRef.current = data.length;
|
|
}
|
|
}, [data, selectedRows, parentId, config.fieldName, config.dataSource?.tableName]);
|
|
|
|
// 저장 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleSaveEvent = async (event: CustomEvent) => {
|
|
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
|
|
const tableName =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
const eventParentId = event.detail?.parentId;
|
|
const mainFormData = event.detail?.mainFormData;
|
|
|
|
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
|
|
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
|
|
|
if (!tableName || data.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// UnifiedRepeater 저장 시작
|
|
const saveInfo = {
|
|
tableName,
|
|
useCustomTable: config.useCustomTable,
|
|
mainTableName: config.mainTableName,
|
|
foreignKeyColumn: config.foreignKeyColumn,
|
|
masterRecordId,
|
|
dataLength: data.length,
|
|
};
|
|
console.log("UnifiedRepeater 저장 시작", saveInfo);
|
|
|
|
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 < data.length; i++) {
|
|
const row = data[i];
|
|
|
|
// 내부 필드 제거
|
|
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
|
|
|
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
|
let mergedData: Record<string, any>;
|
|
if (config.useCustomTable && config.mainTableName) {
|
|
// 커스텀 테이블: 리피터 데이터만 저장
|
|
mergedData = { ...cleanRow };
|
|
|
|
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
|
|
if (config.foreignKeyColumn) {
|
|
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
|
|
// 없으면 마스터 레코드 ID 사용 (기존 동작)
|
|
const sourceColumn = config.foreignKeySourceColumn;
|
|
let fkValue: any;
|
|
|
|
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
|
|
// mainFormData에서 참조 컬럼 값 가져오기
|
|
fkValue = mainFormData[sourceColumn];
|
|
} else {
|
|
// 기본: 마스터 레코드 ID 사용
|
|
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)) {
|
|
filteredData[key] = value;
|
|
}
|
|
}
|
|
|
|
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ UnifiedRepeater 저장 실패:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// V2 EventBus 구독
|
|
const unsubscribe = v2EventBus.subscribe(
|
|
V2_EVENTS.REPEATER_SAVE,
|
|
async (payload) => {
|
|
const tableName =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
if (payload.tableName === tableName) {
|
|
await handleSaveEvent({ detail: payload } as CustomEvent);
|
|
}
|
|
},
|
|
{ componentId: `unified-repeater-${config.dataSource?.tableName}` },
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
|
return () => {
|
|
unsubscribe();
|
|
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
|
};
|
|
}, [
|
|
data,
|
|
config.dataSource?.tableName,
|
|
config.useCustomTable,
|
|
config.mainTableName,
|
|
config.foreignKeyColumn,
|
|
parentId,
|
|
]);
|
|
|
|
// 현재 테이블 컬럼 정보 로드
|
|
useEffect(() => {
|
|
const loadCurrentTableColumnInfo = async () => {
|
|
const tableName = config.dataSource?.tableName;
|
|
if (!tableName) return;
|
|
|
|
try {
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
|
|
|
const columnMap: Record<string, any> = {};
|
|
columns.forEach((col: any) => {
|
|
const name = col.columnName || col.column_name || col.name;
|
|
columnMap[name] = {
|
|
inputType: col.inputType || col.input_type || col.webType || "text",
|
|
displayName: col.displayName || col.display_name || col.label || name,
|
|
detailSettings: col.detailSettings || col.detail_settings,
|
|
};
|
|
});
|
|
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]);
|
|
|
|
// UnifiedColumnConfig → RepeaterColumnConfig 변환
|
|
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
|
|
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
|
|
return config.columns
|
|
.filter((col: UnifiedColumnConfig) => col.visible !== false)
|
|
.map((col: UnifiedColumnConfig): 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 가져오기 (tableName.columnName 형식)
|
|
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
|
|
let categoryRef: string | undefined;
|
|
if (inputType === "category") {
|
|
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
|
|
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]);
|
|
|
|
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
|
useEffect(() => {
|
|
const loadCategoryLabels = async () => {
|
|
if (sourceCategoryColumns.length === 0 || data.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
|
const allCodes = new Set<string>();
|
|
for (const row of data) {
|
|
for (const col of sourceCategoryColumns) {
|
|
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
|
const val = row[`_display_${col}`] || row[col];
|
|
if (val && typeof val === "string") {
|
|
const codes = val
|
|
.split(",")
|
|
.map((c: string) => c.trim())
|
|
.filter(Boolean);
|
|
for (const code of codes) {
|
|
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
|
|
allCodes.add(code);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allCodes.size === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
|
valueCodes: Array.from(allCodes),
|
|
});
|
|
|
|
if (response.data?.success && response.data.data) {
|
|
setCategoryLabelMap((prev) => ({
|
|
...prev,
|
|
...response.data.data,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 라벨 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadCategoryLabels();
|
|
}, [data, sourceCategoryColumns]);
|
|
|
|
// 데이터 변경 핸들러
|
|
const handleDataChange = useCallback(
|
|
(newData: any[]) => {
|
|
setData(newData);
|
|
|
|
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
|
if (onDataChange) {
|
|
const targetTable =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
|
|
if (targetTable) {
|
|
// 각 행에 _targetTable 추가
|
|
const dataWithTarget = newData.map((row) => ({
|
|
...row,
|
|
_targetTable: targetTable,
|
|
}));
|
|
onDataChange(dataWithTarget);
|
|
} else {
|
|
onDataChange(newData);
|
|
}
|
|
}
|
|
|
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
|
setAutoWidthTrigger((prev) => prev + 1);
|
|
},
|
|
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
|
);
|
|
|
|
// 행 변경 핸들러
|
|
const handleRowChange = useCallback(
|
|
(index: number, newRow: any) => {
|
|
const newData = [...data];
|
|
newData[index] = newRow;
|
|
setData(newData);
|
|
|
|
// 🆕 _targetTable 메타데이터 포함
|
|
if (onDataChange) {
|
|
const targetTable =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
|
|
if (targetTable) {
|
|
const dataWithTarget = newData.map((row) => ({
|
|
...row,
|
|
_targetTable: targetTable,
|
|
}));
|
|
onDataChange(dataWithTarget);
|
|
} else {
|
|
onDataChange(newData);
|
|
}
|
|
}
|
|
},
|
|
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
|
);
|
|
|
|
// 행 삭제 핸들러
|
|
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": {
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
}
|
|
|
|
case "currentDateTime": {
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
}
|
|
|
|
case "sequence":
|
|
return rowIndex + 1; // 1부터 시작하는 순번
|
|
|
|
case "numbering":
|
|
// 채번은 별도 비동기 처리 필요
|
|
return null; // null 반환하여 비동기 처리 필요함을 표시
|
|
|
|
case "fromMainForm":
|
|
if (col.autoFill.sourceField && mainFormData) {
|
|
return mainFormData[col.autoFill.sourceField];
|
|
}
|
|
return "";
|
|
|
|
case "fixed":
|
|
return col.autoFill.fixedValue ?? "";
|
|
|
|
default:
|
|
return undefined;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 🆕 채번 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 "";
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 🆕 행 추가 (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);
|
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
|
// 채번 규칙: 즉시 API 호출
|
|
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
|
} else if (autoValue !== undefined) {
|
|
newRow[col.key] = autoValue;
|
|
} else {
|
|
newRow[col.key] = "";
|
|
}
|
|
}
|
|
|
|
const newData = [...data, newRow];
|
|
handleDataChange(newData);
|
|
}
|
|
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]);
|
|
|
|
// 모달에서 항목 선택 - 비동기로 변경
|
|
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) {
|
|
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
|
|
row[`_display_${col.key}`] = item[col.key] || "";
|
|
} else {
|
|
// 자동 입력 값 적용
|
|
const autoValue = generateAutoFillValueSync(col, currentRowCount + index);
|
|
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;
|
|
}),
|
|
);
|
|
|
|
const newData = [...data, ...newRows];
|
|
handleDataChange(newData);
|
|
setModalOpen(false);
|
|
},
|
|
[
|
|
config.dataSource?.foreignKey,
|
|
resolvedReferenceKey,
|
|
config.columns,
|
|
data,
|
|
handleDataChange,
|
|
generateAutoFillValueSync,
|
|
generateNumberingCode,
|
|
],
|
|
);
|
|
|
|
// 소스 컬럼 목록 (모달용) - 🆕 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를 실제 값으로 변환
|
|
const dataRef = useRef(data);
|
|
dataRef.current = data;
|
|
|
|
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: `unified-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: `unified-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: `unified-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("space-y-4", className)}>
|
|
{/* 헤더 영역 */}
|
|
<div className="flex items-center justify-between">
|
|
<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 테이블 */}
|
|
<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={sourceCategoryColumns}
|
|
categoryLabelMap={categoryLabelMap}
|
|
/>
|
|
|
|
{/* 항목 선택 모달 (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>
|
|
);
|
|
};
|
|
|
|
UnifiedRepeater.displayName = "UnifiedRepeater";
|
|
|
|
// V2ErrorBoundary로 래핑된 안전한 버전 export
|
|
export const SafeUnifiedRepeater: React.FC<UnifiedRepeaterProps> = (props) => {
|
|
return (
|
|
<V2ErrorBoundary
|
|
componentId={props.parentId || "unified-repeater"}
|
|
componentType="UnifiedRepeater"
|
|
fallbackStyle="compact"
|
|
>
|
|
<UnifiedRepeater {...props} />
|
|
</V2ErrorBoundary>
|
|
);
|
|
};
|
|
|
|
export default UnifiedRepeater;
|