829 lines
30 KiB
TypeScript
829 lines
30 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* UnifiedRepeater 컴포넌트
|
|
*
|
|
* 렌더링 모드:
|
|
* - 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 {
|
|
UnifiedRepeaterConfig,
|
|
UnifiedRepeaterProps,
|
|
RepeaterColumnConfig as UnifiedColumnConfig,
|
|
DEFAULT_REPEATER_CONFIG,
|
|
} from "@/types/unified-repeater";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
|
|
|
// 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";
|
|
|
|
// 전역 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],
|
|
);
|
|
|
|
// 상태
|
|
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";
|
|
|
|
// 전역 리피터 등록
|
|
useEffect(() => {
|
|
const tableName = config.dataSource?.tableName;
|
|
if (tableName) {
|
|
if (!window.__unifiedRepeaterInstances) {
|
|
window.__unifiedRepeaterInstances = new Set();
|
|
}
|
|
window.__unifiedRepeaterInstances.add(tableName);
|
|
}
|
|
|
|
return () => {
|
|
if (tableName && window.__unifiedRepeaterInstances) {
|
|
window.__unifiedRepeaterInstances.delete(tableName);
|
|
}
|
|
};
|
|
}, [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) {
|
|
console.log("📋 UnifiedRepeater 저장 스킵:", { tableName, dataLength: data.length });
|
|
return;
|
|
}
|
|
|
|
console.log("📋 UnifiedRepeater 저장 시작:", {
|
|
tableName,
|
|
useCustomTable: config.useCustomTable,
|
|
mainTableName: config.mainTableName,
|
|
foreignKeyColumn: config.foreignKeyColumn,
|
|
masterRecordId,
|
|
dataLength: data.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 < 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 자동 연결
|
|
if (config.foreignKeyColumn && masterRecordId) {
|
|
mergedData[config.foreignKeyColumn] = masterRecordId;
|
|
console.log(`📎 FK 자동 연결: ${config.foreignKeyColumn} = ${masterRecordId}`);
|
|
}
|
|
} 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);
|
|
}
|
|
|
|
console.log("✅ UnifiedRepeater 저장 완료:", data.length, "건 →", tableName);
|
|
} catch (error) {
|
|
console.error("❌ UnifiedRepeater 저장 실패:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
|
return () => {
|
|
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";
|
|
|
|
console.log("🔄 [UnifiedRepeater] 엔티티 참조 정보 조회:", {
|
|
foreignKey,
|
|
resolvedSourceTable: refTable,
|
|
resolvedReferenceKey: refKey,
|
|
configSourceTable: config.dataSource?.sourceTable,
|
|
});
|
|
|
|
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);
|
|
onDataChange?.(newData);
|
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
|
setAutoWidthTrigger((prev) => prev + 1);
|
|
},
|
|
[onDataChange],
|
|
);
|
|
|
|
// 행 변경 핸들러
|
|
const handleRowChange = useCallback(
|
|
(index: number, newRow: any) => {
|
|
const newData = [...data];
|
|
newData[index] = newRow;
|
|
// 🆕 handleDataChange 대신 직접 호출 (행 변경마다 너비 조정은 불필요)
|
|
setData(newData);
|
|
onDataChange?.(newData);
|
|
},
|
|
[data, onDataChange],
|
|
);
|
|
|
|
// 행 삭제 핸들러
|
|
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) {
|
|
return mainFormData[col.autoFill.sourceField];
|
|
}
|
|
return "";
|
|
|
|
case "fixed":
|
|
return col.autoFill.fixedValue ?? "";
|
|
|
|
default:
|
|
return undefined;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 🆕 채번 API 호출 (비동기)
|
|
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
|
|
try {
|
|
const result = await allocateNumberingCode(ruleId);
|
|
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);
|
|
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;
|
|
};
|
|
|
|
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
|
|
|
return () => {
|
|
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;
|
|
}
|
|
|
|
console.log("📥 [UnifiedRepeater] componentDataTransfer 수신:", {
|
|
targetComponentId,
|
|
dataCount: transferData?.length,
|
|
mode,
|
|
myId: parentId,
|
|
});
|
|
|
|
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 || {};
|
|
|
|
console.log("📥 [UnifiedRepeater] splitPanelDataTransfer 수신:", {
|
|
dataCount: transferData?.length,
|
|
mode,
|
|
sourcePosition,
|
|
});
|
|
|
|
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]);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
|
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
|
|
|
return () => {
|
|
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";
|
|
|
|
export default UnifiedRepeater;
|