"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; } } export const V2Repeater: React.FC = ({ config: propConfig, componentId, parentId, data: initialData, onDataChange, onRowClick, className, formData: parentFormData, ...restProps }) => { // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; // componentId 결정: 직접 전달 또는 component 객체에서 추출 const effectiveComponentId = componentId || (restProps as any).component?.id; // ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null) const screenContext = useScreenContextOptional(); // 설정 병합 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(initialData || []); const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); // 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref const dataRef = useRef(data); useEffect(() => { dataRef.current = data; }, [data]); // 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용) const loadedIdsRef = useRef>(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 = {}; 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(); 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; 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>({}); // 🆕 소스 테이블의 카테고리 타입 컬럼 목록 const [sourceCategoryColumns, setSourceCategoryColumns] = useState([]); // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); const categoryLabelMapRef = useRef>({}); useEffect(() => { categoryLabelMapRef.current = categoryLabelMap; }, [categoryLabelMap]); // 현재 테이블 컬럼 정보 (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"; // 전역 리피터 등록 // 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) useEffect(() => { const targetTableName = config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; if (targetTableName) { if (!window.__v2RepeaterInstances) { window.__v2RepeaterInstances = new Set(); } window.__v2RepeaterInstances.add(targetTableName); } return () => { if (targetTableName && window.__v2RepeaterInstances) { window.__v2RepeaterInstances.delete(targetTableName); } }; }, [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 = 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; 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 = {}; 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, }); const response = await apiClient.post( `/table-management/tables/${config.mainTableName}/data`, { page: 1, size: 1000, search: { [config.foreignKeyColumn]: fkValue }, autoFilter: true, } ); const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; if (Array.isArray(rows) && rows.length > 0) { console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); const sourceTable = config.dataSource?.sourceTable; const fkColumn = config.dataSource?.foreignKey; const refKey = config.dataSource?.referenceKey || "id"; if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { try { const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); const uniqueValues = [...new Set(fkValues)]; if (uniqueValues.length > 0) { // FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 const sourcePromises = uniqueValues.map((val) => apiClient.post(`/table-management/tables/${sourceTable}/data`, { page: 1, size: 1, search: { [refKey]: val }, autoFilter: true, }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) .catch(() => []) ); const sourceResults = await Promise.all(sourcePromises); const sourceMap = new Map(); 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(); 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; 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 = {}; inputTypes.forEach((t: any) => { typeMap[t.columnName] = t; }); const columnMap: Record = {}; 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 = {}; 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(); 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(); 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) => { 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): Promise => { 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 ""; } }, [], ); // 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함) const groupedDataProcessedRef = useRef(false); useEffect(() => { if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return; if (groupedDataProcessedRef.current) return; groupedDataProcessedRef.current = true; const newRows = groupedData.map((item: any, index: number) => { const row: any = { _id: `grouped_${Date.now()}_${index}` }; for (const col of config.columns) { let sourceValue = item[(col as any).sourceKey || col.key]; // 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반) if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) { sourceValue = categoryLabelMap[sourceValue]; } if (col.isSourceDisplay) { row[col.key] = sourceValue ?? ""; row[`_display_${col.key}`] = sourceValue ?? ""; } else if (col.autoFill && col.autoFill.type !== "none") { const autoValue = generateAutoFillValueSync(col, index, parentFormData); if (autoValue !== undefined) { row[col.key] = autoValue; } else { row[col.key] = ""; } } else if (sourceValue !== undefined) { row[col.key] = sourceValue; } else { row[col.key] = ""; } } return row; }); // 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관) const categoryColSet = new Set(allCategoryColumns); const codesToResolve = new Set(); for (const row of newRows) { for (const col of config.columns) { const val = row[col.key] || row[`_display_${col.key}`]; if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) { if (!categoryLabelMap[val]) { codesToResolve.add(val); } } } } if (codesToResolve.size > 0) { apiClient.post("/table-categories/labels-by-codes", { valueCodes: Array.from(codesToResolve), }).then((resp) => { if (resp.data?.success && resp.data.data) { const labelData = resp.data.data as Record; setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); const convertedRows = newRows.map((row) => { const updated = { ...row }; for (const col of config.columns) { const val = updated[col.key]; if (typeof val === "string" && labelData[val]) { updated[col.key] = labelData[val]; } const dispKey = `_display_${col.key}`; const dispVal = updated[dispKey]; if (typeof dispVal === "string" && labelData[dispVal]) { updated[dispKey] = labelData[dispVal]; } } return updated; }); setData(convertedRows); onDataChange?.(convertedRows); } }).catch(() => {}); } setData(newRows); onDataChange?.(newRows); // eslint-disable-next-line react-hooks/exhaustive-deps }, [groupedData, config.columns, generateAutoFillValueSync]); // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 useEffect(() => { if (data.length === 0) return; const parentSeqColumns = config.columns.filter( (col) => col.autoFill?.type === "parentSequence" && col.autoFill.parentField, ); if (parentSeqColumns.length === 0) return; let needsUpdate = false; const updatedData = data.map((row, index) => { const updatedRow = { ...row }; for (const col of parentSeqColumns) { const newValue = generateAutoFillValueSync(col, index, parentFormData); if (newValue !== undefined && newValue !== row[col.key]) { updatedRow[col.key] = newValue; needsUpdate = true; } } return updatedRow; }); if (needsUpdate) { setData(updatedData); onDataChange?.(updatedData); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentFormData, config.columns, generateAutoFillValueSync]); // 행 추가 (inline 모드 또는 모달 열기) 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; 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[]) => { 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(); 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; 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 (
{/* 헤더 영역 */}
{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={allCategoryColumns} categoryLabelMap={categoryLabelMap} />
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */} {isModalMode && ( )}
); }; V2Repeater.displayName = "V2Repeater"; // V2ErrorBoundary로 래핑된 안전한 버전 export export const SafeV2Repeater: React.FC = (props) => { return ( ); }; export default V2Repeater;