"use client"; /** * UnifiedRepeater 컴포넌트 * * 렌더링 모드: * - inline: 현재 테이블 컬럼 직접 입력 * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 * * RepeaterTable 및 ItemSelectionModal 재사용 */ import React, { useState, useEffect, useCallback, useMemo } 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"; // 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; } } export const UnifiedRepeater: React.FC = ({ 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(initialData || []); const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); // 🆕 소스 테이블의 카테고리 타입 컬럼 목록 const [sourceCategoryColumns, setSourceCategoryColumns] = useState([]); // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); // 동적 데이터 소스 상태 const [activeDataSources, setActiveDataSources] = useState>({}); // 🆕 최신 엔티티 참조 정보 (column_labels에서 조회) const [resolvedSourceTable, setResolvedSourceTable] = useState(""); const [resolvedReferenceKey, setResolvedReferenceKey] = useState("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) => { const tableName = config.dataSource?.tableName; const eventParentId = event.detail?.parentId; const mainFormData = event.detail?.mainFormData; if (!tableName || data.length === 0) { return; } try { // 테이블 유효 컬럼 조회 let validColumns: Set = 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("_"))); // 메인 폼 데이터 병합 const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; const mergedData = { ...mainFormDataWithoutId, ...cleanRow, }; // 유효하지 않은 컬럼 제거 const filteredData: Record = {}; 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, "건"); } 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, 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 = {}; 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 = {}; 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 전달 }; }); }, [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(); 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(); 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 handleAddRow = useCallback(() => { if (isModalMode) { setModalOpen(true); } else { const newRow: any = { _id: `new_${Date.now()}` }; config.columns.forEach((col) => { newRow[col.key] = ""; }); const newData = [...data, newRow]; handleDataChange(newData); // 🆕 handleDataChange 사용 } }, [isModalMode, config.columns, data, handleDataChange]); // 모달에서 항목 선택 - 🆕 columns 배열에서 isSourceDisplay 플래그로 구분 const handleSelectItems = useCallback( (items: Record[]) => { const fkColumn = config.dataSource?.foreignKey; const newRows = items.map((item) => { const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; // FK 값 저장 (resolvedReferenceKey 사용) if (fkColumn && item[resolvedReferenceKey]) { row[fkColumn] = item[resolvedReferenceKey]; } // 모든 컬럼 처리 (순서대로) config.columns.forEach((col) => { if (col.isSourceDisplay) { // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) row[`_display_${col.key}`] = item[col.key] || ""; } else { // 입력 컬럼: 빈 값으로 초기화 if (row[col.key] === undefined) { row[col.key] = ""; } } }); return row; }); const newData = [...data, ...newRows]; handleDataChange(newData); // 🆕 handleDataChange 사용하여 autoWidthTrigger도 증가 setModalOpen(false); }, [config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange], ); // 소스 컬럼 목록 (모달용) - 🆕 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]); return (
{/* 헤더 영역 */}
{data.length > 0 && `${data.length}개 항목`} {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
{selectedRows.size > 0 && ( )}
{/* Repeater 테이블 */} { setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); }} selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={autoWidthTrigger} categoryColumns={sourceCategoryColumns} categoryLabelMap={categoryLabelMap} /> {/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */} {isModalMode && ( )}
); }; UnifiedRepeater.displayName = "UnifiedRepeater"; export default UnifiedRepeater;