"use client"; import React, { useState, useEffect, useMemo } from "react"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw, Pencil } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { RepeatScreenModalProps, CardData, CardColumnConfig, GroupedCardData, CardRowData, AggregationConfig, TableColumnConfig, CardContentRowConfig, AggregationDisplayConfig, FooterConfig, FooterButtonConfig, TableDataSourceConfig, TableCrudConfig, } from "./types"; import { ComponentRendererProps } from "@/types/component"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; export interface RepeatScreenModalComponentProps extends ComponentRendererProps { config?: RepeatScreenModalProps; groupedData?: Record[]; // EditModal에서 전달하는 그룹 데이터 } export function RepeatScreenModalComponent({ component, isDesignMode = false, formData, onFormDataChange, config, className, groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터 ...props }: RepeatScreenModalComponentProps) { // props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음) const groupedData = propsGroupedData || (props as any).groupedData; const componentConfig = { ...config, ...component?.config, }; // 설정 값 추출 const dataSource = componentConfig?.dataSource; const saveMode = componentConfig?.saveMode || "all"; const cardSpacing = componentConfig?.cardSpacing || "24px"; const showCardBorder = componentConfig?.showCardBorder ?? true; const showCardTitle = componentConfig?.showCardTitle ?? true; const cardTitle = componentConfig?.cardTitle || "카드 {index}"; const grouping = componentConfig?.grouping; // 🆕 v3: 자유 레이아웃 const contentRows = componentConfig?.contentRows || []; // 🆕 v3.1: Footer 설정 const footerConfig = componentConfig?.footerConfig; // (레거시 호환) const cardLayout = componentConfig?.cardLayout || []; const cardMode = componentConfig?.cardMode || "simple"; const tableLayout = componentConfig?.tableLayout; // 상태 const [rawData, setRawData] = useState([]); // 원본 데이터 const [cardsData, setCardsData] = useState([]); // simple 모드용 const [groupedCardsData, setGroupedCardsData] = useState([]); // withTable 모드용 const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [isSaving, setIsSaving] = useState(false); // 🆕 v3.1: 외부 테이블 데이터 (테이블 행별로 관리) const [externalTableData, setExternalTableData] = useState>({}); // 🆕 v3.1: 삭제 확인 다이얼로그 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [pendingDeleteInfo, setPendingDeleteInfo] = useState<{ cardId: string; rowId: string; contentRowId: string; } | null>(null); // 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합 useEffect(() => { const handleBeforeFormSave = (event: Event) => { if (!(event instanceof CustomEvent) || !event.detail?.formData) return; console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신"); // 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비 const saveDataByTable: Record = {}; for (const [key, rows] of Object.entries(externalTableData)) { // contentRow 찾기 const contentRow = contentRows.find((r) => key.includes(r.id)); if (!contentRow?.tableDataSource?.enabled) continue; const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; // dirty 행만 필터링 (삭제된 행 제외) const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted); if (dirtyRows.length === 0) continue; // 저장할 필드만 추출 const editableFields = (contentRow.tableColumns || []) .filter((col) => col.editable) .map((col) => col.field); const joinKeys = (contentRow.tableDataSource.joinConditions || []) .map((cond) => cond.sourceKey); const allowedFields = [...new Set([...editableFields, ...joinKeys])]; if (!saveDataByTable[targetTable]) { saveDataByTable[targetTable] = []; } for (const row of dirtyRows) { const saveData: Record = {}; // 허용된 필드만 포함 for (const field of allowedFields) { if (row[field] !== undefined) { saveData[field] = row[field]; } } // _isNew 플래그 유지 saveData._isNew = row._isNew; saveData._targetTable = targetTable; // 기존 레코드의 경우 id 포함 if (!row._isNew && row._originalData?.id) { saveData.id = row._originalData.id; } saveDataByTable[targetTable].push(saveData); } } // formData에 테이블별 저장 데이터 추가 for (const [tableName, rows] of Object.entries(saveDataByTable)) { const fieldKey = `_repeatScreenModal_${tableName}`; event.detail.formData[fieldKey] = rows; console.log(`[RepeatScreenModal] beforeFormSave - ${tableName} 저장 데이터:`, rows); } // 🆕 v3.9: 집계 저장 설정 정보도 formData에 추가 if (grouping?.aggregations && groupedCardsData.length > 0) { const aggregationSaveConfigs: Array<{ resultField: string; aggregatedValue: number; targetTable: string; targetColumn: string; joinKey: { sourceField: string; targetField: string }; sourceValue: any; // 조인 키 값 }> = []; for (const card of groupedCardsData) { for (const agg of grouping.aggregations) { if (agg.saveConfig?.enabled) { const { saveConfig, resultField } = agg; const { targetTable, targetColumn, joinKey } = saveConfig; if (!targetTable || !targetColumn || !joinKey?.sourceField || !joinKey?.targetField) { continue; } const aggregatedValue = card._aggregations?.[resultField] ?? 0; const sourceValue = card._representativeData?.[joinKey.sourceField]; if (sourceValue !== undefined) { aggregationSaveConfigs.push({ resultField, aggregatedValue, targetTable, targetColumn, joinKey, sourceValue, }); } } } } if (aggregationSaveConfigs.length > 0) { event.detail.formData._repeatScreenModal_aggregations = aggregationSaveConfigs; console.log("[RepeatScreenModal] beforeFormSave - 집계 저장 설정:", aggregationSaveConfigs); } } }; window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); return () => { window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); }; }, [externalTableData, contentRows, grouping, groupedCardsData]); // 초기 데이터 로드 useEffect(() => { const loadInitialData = async () => { console.log("[RepeatScreenModal] 데이터 로드 시작"); console.log("[RepeatScreenModal] groupedData (from EditModal):", groupedData); console.log("[RepeatScreenModal] formData:", formData); console.log("[RepeatScreenModal] dataSource:", dataSource); setIsLoading(true); setLoadError(null); try { let loadedData: any[] = []; // 🆕 우선순위 1: EditModal에서 전달받은 groupedData 사용 if (groupedData && groupedData.length > 0) { console.log("[RepeatScreenModal] groupedData 사용:", groupedData.length, "건"); loadedData = groupedData; } // 우선순위 2: API 호출 else if (dataSource && dataSource.sourceTable) { // 필터 조건 생성 const filters: Record = {}; // formData에서 선택된 행 ID 가져오기 let selectedIds: any[] = []; if (formData) { // 1. 명시적으로 설정된 filterField 확인 if (dataSource.filterField) { const filterValue = formData[dataSource.filterField]; if (filterValue) { selectedIds = Array.isArray(filterValue) ? filterValue : [filterValue]; } } // 2. 일반적인 선택 필드 확인 (fallback) if (selectedIds.length === 0) { const commonFields = ['selectedRows', 'selectedIds', 'checkedRows', 'checkedIds', 'ids']; for (const field of commonFields) { if (formData[field]) { const value = formData[field]; selectedIds = Array.isArray(value) ? value : [value]; console.log(`[RepeatScreenModal] ${field}에서 선택된 ID 발견:`, selectedIds); break; } } } // 3. formData에 id가 있으면 단일 행 if (selectedIds.length === 0 && formData.id) { selectedIds = [formData.id]; console.log("[RepeatScreenModal] formData.id 사용:", selectedIds); } } console.log("[RepeatScreenModal] 최종 선택된 ID:", selectedIds); // 선택된 ID가 있으면 필터 적용 if (selectedIds.length > 0) { filters.id = selectedIds; } else { console.warn("[RepeatScreenModal] 선택된 데이터가 없습니다."); setRawData([]); setCardsData([]); setGroupedCardsData([]); setIsLoading(false); return; } console.log("[RepeatScreenModal] API 필터:", filters); // API 호출 const response = await apiClient.post(`/table-management/tables/${dataSource.sourceTable}/data`, { search: filters, page: 1, size: 1000, }); if (response.data.success && response.data.data?.data) { loadedData = response.data.data.data; } } else { console.log("[RepeatScreenModal] 데이터 소스 없음"); setRawData([]); setCardsData([]); setGroupedCardsData([]); setIsLoading(false); return; } console.log("[RepeatScreenModal] 로드된 데이터:", loadedData.length, "건"); if (loadedData.length === 0) { setRawData([]); setCardsData([]); setGroupedCardsData([]); setIsLoading(false); return; } setRawData(loadedData); // 🆕 v3: contentRows가 있으면 새로운 방식 사용 const useNewLayout = contentRows && contentRows.length > 0; // 그룹핑 모드 확인 (groupByField가 없어도 enabled면 그룹핑 모드로 처리) const useGrouping = grouping?.enabled; if (useGrouping) { // 그룹핑 모드 const grouped = processGroupedData(loadedData, grouping); setGroupedCardsData(grouped); } else { // 단순 모드: 각 행이 하나의 카드 const initialCards: CardData[] = await Promise.all( loadedData.map(async (row: any, index: number) => ({ _cardId: `card-${index}-${Date.now()}`, _originalData: { ...row }, _isDirty: false, ...(await loadCardData(row)), })) ); setCardsData(initialCards); } } catch (error: any) { console.error("데이터 로드 실패:", error); setLoadError(error.message || "데이터 로드 중 오류가 발생했습니다."); } finally { setIsLoading(false); } }; loadInitialData(); }, [dataSource, formData, groupedData, contentRows, grouping?.enabled, grouping?.groupByField]); // 🆕 v3.1: 외부 테이블 데이터 로드 useEffect(() => { const loadExternalTableData = async () => { // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 const tableRowsWithExternalSource = contentRows.filter( (row) => row.type === "table" && row.tableDataSource?.enabled ); if (tableRowsWithExternalSource.length === 0) return; if (groupedCardsData.length === 0 && cardsData.length === 0) return; const newExternalData: Record = {}; for (const contentRow of tableRowsWithExternalSource) { const dataSourceConfig = contentRow.tableDataSource!; const cards = groupedCardsData.length > 0 ? groupedCardsData : cardsData; for (const card of cards) { const cardId = card._cardId; const representativeData = (card as GroupedCardData)._representativeData || card; try { // 조인 조건 생성 const filters: Record = {}; for (const condition of dataSourceConfig.joinConditions) { const refValue = representativeData[condition.referenceKey]; if (refValue !== undefined && refValue !== null) { filters[condition.sourceKey] = refValue; } } if (Object.keys(filters).length === 0) { console.warn(`[RepeatScreenModal] 조인 조건이 없습니다: ${contentRow.id}`); continue; } // API 호출 - 메인 테이블 데이터 const response = await apiClient.post( `/table-management/tables/${dataSourceConfig.sourceTable}/data`, { search: filters, page: 1, size: dataSourceConfig.limit || 100, sort: dataSourceConfig.orderBy ? { column: dataSourceConfig.orderBy.column, direction: dataSourceConfig.orderBy.direction, } : undefined, } ); if (response.data.success && response.data.data?.data) { let tableData = response.data.data.data; console.log(`[RepeatScreenModal] 소스 테이블 데이터 로드 완료:`, { sourceTable: dataSourceConfig.sourceTable, rowCount: tableData.length, sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], firstRowData: tableData[0], // 디버그: plan_date 필드 확인 plan_date_value: tableData[0]?.plan_date, }); // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 if (dataSourceConfig.additionalJoins && dataSourceConfig.additionalJoins.length > 0) { console.log(`[RepeatScreenModal] 조인 설정:`, dataSourceConfig.additionalJoins); tableData = await loadAndMergeJoinData(tableData, dataSourceConfig.additionalJoins); console.log(`[RepeatScreenModal] 조인 후 데이터:`, { rowCount: tableData.length, sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], firstRowData: tableData[0], }); } // 🆕 v3.4: 필터 조건 적용 if (dataSourceConfig.filterConfig?.enabled) { const { filterField, filterType, referenceField, referenceSource } = dataSourceConfig.filterConfig; // 비교 값 가져오기 let referenceValue: any; if (referenceSource === "formData") { referenceValue = formData?.[referenceField]; } else { // representativeData referenceValue = representativeData[referenceField]; } if (referenceValue !== undefined && referenceValue !== null) { tableData = tableData.filter((row: any) => { const rowValue = row[filterField]; if (filterType === "equals") { return rowValue === referenceValue; } else { // notEquals return rowValue !== referenceValue; } }); console.log(`[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`); } } const key = `${cardId}-${contentRow.id}`; newExternalData[key] = tableData.map((row: any, idx: number) => ({ _rowId: `ext-row-${cardId}-${contentRow.id}-${idx}-${Date.now()}`, _originalData: { ...row }, _isDirty: false, _isNew: false, _isEditing: false, // 🆕 v3.8: 로드된 데이터는 읽기 전용 _isDeleted: false, ...row, })); // 디버그: 저장된 외부 테이블 데이터 확인 console.log(`[RepeatScreenModal] 외부 테이블 데이터 저장:`, { key, rowCount: newExternalData[key].length, firstRow: newExternalData[key][0], plan_date_in_firstRow: newExternalData[key][0]?.plan_date, }); } } catch (error) { console.error(`[RepeatScreenModal] 외부 테이블 데이터 로드 실패:`, error); } } } setExternalTableData((prev) => { // 이전 데이터와 동일하면 업데이트하지 않음 (무한 루프 방지) const prevKeys = Object.keys(prev).sort().join(","); const newKeys = Object.keys(newExternalData).sort().join(","); if (prevKeys === newKeys) { // 키가 같으면 데이터 내용 비교 const isSame = Object.keys(newExternalData).every( (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]) ); if (isSame) return prev; } // 🆕 v3.2: 외부 테이블 데이터 로드 후 집계 재계산 // 비동기적으로 처리하여 무한 루프 방지 setTimeout(() => { recalculateAggregationsWithExternalData(newExternalData); }, 0); return newExternalData; }); }; loadExternalTableData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [contentRows, groupedCardsData.length, cardsData.length]); // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 const loadAndMergeJoinData = async ( mainData: any[], additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[] ): Promise => { if (mainData.length === 0) return mainData; // 각 조인 테이블별로 필요한 키 값들 수집 for (const joinConfig of additionalJoins) { if (!joinConfig.joinTable || !joinConfig.sourceKey || !joinConfig.targetKey) continue; // 메인 데이터에서 조인 키 값들 추출 const joinKeyValues = [...new Set(mainData.map((row) => row[joinConfig.sourceKey]).filter(Boolean))]; if (joinKeyValues.length === 0) continue; try { // 조인 테이블 데이터 조회 const joinResponse = await apiClient.post( `/table-management/tables/${joinConfig.joinTable}/data`, { search: { [joinConfig.targetKey]: joinKeyValues }, page: 1, size: 1000, // 충분히 큰 값 } ); if (joinResponse.data.success && joinResponse.data.data?.data) { const joinData = joinResponse.data.data.data; // 조인 데이터를 맵으로 변환 (빠른 조회를 위해) const joinDataMap = new Map(); for (const joinRow of joinData) { joinDataMap.set(joinRow[joinConfig.targetKey], joinRow); } // 메인 데이터에 조인 데이터 병합 mainData = mainData.map((row) => { const joinKey = row[joinConfig.sourceKey]; const joinRow = joinDataMap.get(joinKey); if (joinRow) { // 조인 테이블의 컬럼들을 메인 데이터에 추가 (접두사 없이) const mergedRow = { ...row }; for (const [key, value] of Object.entries(joinRow)) { // 이미 존재하는 키가 아닌 경우에만 추가 (메인 테이블 우선) if (!(key in mergedRow)) { mergedRow[key] = value; } else { // 충돌하는 경우 조인 테이블명을 접두사로 사용 mergedRow[`${joinConfig.joinTable}_${key}`] = value; } } return mergedRow; } return row; }); } } catch (error) { console.error(`[RepeatScreenModal] 조인 테이블 데이터 로드 실패 (${joinConfig.joinTable}):`, error); } } return mainData; }; // 🆕 v3.2: 외부 테이블 데이터가 로드된 후 집계 재계산 const recalculateAggregationsWithExternalData = (extData: Record) => { if (!grouping?.aggregations || grouping.aggregations.length === 0) return; if (groupedCardsData.length === 0) return; // 외부 테이블 집계 또는 formula가 있는지 확인 const hasExternalAggregation = grouping.aggregations.some((agg) => { const sourceType = agg.sourceType || "column"; if (sourceType === "formula") return true; // formula는 외부 테이블 참조 가능 if (sourceType === "column") { const sourceTable = agg.sourceTable || dataSource?.sourceTable; return sourceTable && sourceTable !== dataSource?.sourceTable; } return false; }); if (!hasExternalAggregation) return; // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 const tableRowWithExternalSource = contentRows.find( (row) => row.type === "table" && row.tableDataSource?.enabled ); if (!tableRowWithExternalSource) return; // 각 카드의 집계 재계산 const updatedCards = groupedCardsData.map((card) => { const key = `${card._cardId}-${tableRowWithExternalSource.id}`; // 🆕 v3.7: 삭제된 행은 집계에서 제외 const externalRows = (extData[key] || []).filter((row) => !row._isDeleted); // 집계 재계산 const newAggregations: Record = {}; grouping.aggregations!.forEach((agg) => { const sourceType = agg.sourceType || "column"; if (sourceType === "column") { const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; if (isExternalTable) { // 외부 테이블 집계 newAggregations[agg.resultField] = calculateColumnAggregation( externalRows, agg.sourceField || "", agg.type || "sum" ); } else { // 기본 테이블 집계 (기존 값 유지) newAggregations[agg.resultField] = card._aggregations[agg.resultField] || calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); } } else if (sourceType === "formula" && agg.formula) { // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 newAggregations[agg.resultField] = evaluateFormulaWithContext( agg.formula, card._representativeData, card._rows, externalRows, newAggregations // 이전 집계 결과 참조 ); } }); return { ...card, _aggregations: newAggregations, }; }); // 변경된 경우에만 업데이트 (무한 루프 방지) setGroupedCardsData((prev) => { const hasChanges = updatedCards.some((card, idx) => { const prevCard = prev[idx]; if (!prevCard) return true; return JSON.stringify(card._aggregations) !== JSON.stringify(prevCard._aggregations); }); return hasChanges ? updatedCards : prev; }); }; // 🆕 v3.1: 외부 테이블 행 추가 const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { const key = `${cardId}-${contentRowId}`; const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId); const representativeData = (card as GroupedCardData)?._representativeData || card || {}; // 기본값 생성 const newRowData: Record = { _rowId: `new-row-${Date.now()}`, _originalData: {}, _isDirty: true, _isNew: true, }; // 🆕 v3.5: 카드 대표 데이터에서 조인 테이블 컬럼 값 자동 채우기 // tableColumns에서 정의된 필드들 중 representativeData에 있는 값을 자동으로 채움 if (contentRow.tableColumns) { for (const col of contentRow.tableColumns) { // representativeData에 해당 필드가 있으면 자동으로 채움 if (representativeData[col.field] !== undefined && representativeData[col.field] !== null) { newRowData[col.field] = representativeData[col.field]; } } } // 🆕 v3.5: 조인 조건의 키 값도 자동으로 채움 (예: sales_order_id) if (contentRow.tableDataSource?.joinConditions) { for (const condition of contentRow.tableDataSource.joinConditions) { // sourceKey는 소스 테이블(예: shipment_plan)의 컬럼 // referenceKey는 카드 대표 데이터의 컬럼 (예: id) const refValue = representativeData[condition.referenceKey]; if (refValue !== undefined && refValue !== null) { newRowData[condition.sourceKey] = refValue; } } } // newRowDefaults 적용 (사용자 정의 기본값이 우선) if (contentRow.tableCrud?.newRowDefaults) { for (const [field, template] of Object.entries(contentRow.tableCrud.newRowDefaults)) { // {fieldName} 형식의 템플릿 치환 let value = template; const matches = template.match(/\{(\w+)\}/g); if (matches) { for (const match of matches) { const fieldName = match.slice(1, -1); value = value.replace(match, String(representativeData[fieldName] || "")); } } newRowData[field] = value; } } console.log("[RepeatScreenModal] 새 행 추가:", { cardId, contentRowId, representativeData, newRowData, }); setExternalTableData((prev) => { const newData = { ...prev, [key]: [...(prev[key] || []), newRowData], }; // 🆕 v3.5: 새 행 추가 시 집계 재계산 setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); return newData; }); }; // 🆕 v3.6: 테이블 영역 저장 기능 const saveTableAreaData = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { const key = `${cardId}-${contentRowId}`; const rows = externalTableData[key] || []; console.log("[RepeatScreenModal] saveTableAreaData 시작:", { key, rowsCount: rows.length, contentRowId, tableDataSource: contentRow?.tableDataSource, tableCrud: contentRow?.tableCrud, }); if (!contentRow?.tableDataSource?.enabled) { console.warn("[RepeatScreenModal] 외부 테이블 데이터 소스가 설정되지 않음"); return { success: false, message: "데이터 소스가 설정되지 않았습니다." }; } const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; const dirtyRows = rows.filter((row) => row._isDirty); console.log("[RepeatScreenModal] 저장 대상:", { targetTable, dirtyRowsCount: dirtyRows.length, dirtyRows: dirtyRows.map(r => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })), }); if (dirtyRows.length === 0) { return { success: true, message: "저장할 변경사항이 없습니다.", savedCount: 0 }; } const savePromises: Promise[] = []; const savedIds: number[] = []; // 🆕 v3.6: editable한 컬럼 + 조인 키만 추출 (읽기 전용 컬럼은 제외) const allowedFields = new Set(); // tableColumns에서 editable: true인 필드만 추가 (읽기 전용 컬럼 제외) if (contentRow.tableColumns) { contentRow.tableColumns.forEach((col) => { // editable이 명시적으로 true이거나, editable이 undefined가 아니고 false가 아닌 경우 // 또는 inputType이 있는 경우 (입력 가능한 컬럼) if (col.field && (col.editable === true || col.inputType)) { allowedFields.add(col.field); } }); } // 조인 조건의 sourceKey 추가 (예: sales_order_id) - 이건 항상 필요 if (contentRow.tableDataSource?.joinConditions) { contentRow.tableDataSource.joinConditions.forEach((cond) => { if (cond.sourceKey) allowedFields.add(cond.sourceKey); }); } console.log("[RepeatScreenModal] 저장 허용 필드 (editable + 조인키):", Array.from(allowedFields)); console.log("[RepeatScreenModal] tableColumns 정보:", contentRow.tableColumns?.map(c => ({ field: c.field, editable: c.editable, inputType: c.inputType }))); // 삭제할 행 (기존 데이터 중 _isDeleted가 true인 것) const deletedRows = dirtyRows.filter((row) => row._isDeleted && row._originalData?.id); // 저장할 행 (삭제되지 않은 것) const rowsToSave = dirtyRows.filter((row) => !row._isDeleted); console.log("[RepeatScreenModal] 삭제 대상:", deletedRows.length, "건"); console.log("[RepeatScreenModal] 저장 대상:", rowsToSave.length, "건"); // 🆕 v3.7: 삭제 처리 (배열 형태로 body에 전달) for (const row of deletedRows) { const deleteId = row._originalData.id; console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [{ id: deleteId }]); savePromises.push( apiClient.request({ method: "DELETE", url: `/table-management/tables/${targetTable}/delete`, data: [{ id: deleteId }], }).then((res) => { console.log("[RepeatScreenModal] DELETE 응답:", res.data); return { type: "delete", id: deleteId }; }).catch((err) => { console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message); throw err; }) ); } for (const row of rowsToSave) { const { _rowId, _originalData, _isDirty, _isNew, _isDeleted, ...allData } = row; // 허용된 필드만 필터링 const dataToSave: Record = {}; for (const field of allowedFields) { if (allData[field] !== undefined) { dataToSave[field] = allData[field]; } } console.log("[RepeatScreenModal] 저장할 데이터:", { _isNew, _originalData, allData, dataToSave, }); if (_isNew) { // INSERT - /add 엔드포인트 사용 console.log(`[RepeatScreenModal] INSERT 요청: /table-management/tables/${targetTable}/add`, dataToSave); savePromises.push( apiClient.post(`/table-management/tables/${targetTable}/add`, dataToSave).then((res) => { console.log("[RepeatScreenModal] INSERT 응답:", res.data); if (res.data?.data?.id) { savedIds.push(res.data.data.id); } return res; }).catch((err) => { console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message); throw err; }) ); } else if (_originalData?.id) { // UPDATE - /edit 엔드포인트 사용 (originalData와 updatedData 형식) const updatePayload = { originalData: _originalData, updatedData: { ...dataToSave, id: _originalData.id }, }; console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updatePayload); savePromises.push( apiClient.put(`/table-management/tables/${targetTable}/edit`, updatePayload).then((res) => { console.log("[RepeatScreenModal] UPDATE 응답:", res.data); savedIds.push(_originalData.id); return res; }).catch((err) => { console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message); throw err; }) ); } } try { await Promise.all(savePromises); // 저장 후: 삭제된 행은 제거, 나머지는 dirty/editing 플래그 초기화 setExternalTableData((prev) => { const updated = { ...prev }; if (updated[key]) { // 삭제된 행은 완전히 제거 updated[key] = updated[key] .filter((row) => !row._isDeleted) .map((row) => ({ ...row, _isDirty: false, _isNew: false, _isEditing: false, // 🆕 v3.8: 수정 모드 해제 _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined, _isDeleted: undefined, _isEditing: undefined }, })); } return updated; }); const savedCount = rowsToSave.length; const deletedCount = deletedRows.length; const message = deletedCount > 0 ? `${savedCount}건 저장, ${deletedCount}건 삭제 완료` : `${savedCount}건 저장 완료`; return { success: true, message, savedCount, deletedCount, savedIds }; } catch (error: any) { console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", error); return { success: false, message: error.message || "저장 중 오류가 발생했습니다." }; } }; // 🆕 v3.6: 테이블 영역 저장 핸들러 const handleTableAreaSave = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { setIsSaving(true); try { const result = await saveTableAreaData(cardId, contentRowId, contentRow); if (result.success) { console.log("[RepeatScreenModal] 테이블 영역 저장 성공:", result); // 🆕 v3.9: 집계 저장 설정이 있는 경우 연관 테이블 동기화 const card = groupedCardsData.find((c) => c._cardId === cardId); if (card && grouping?.aggregations) { await saveAggregationsToRelatedTables(card, contentRowId); } } else { console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", result.message); } } finally { setIsSaving(false); } }; // 🆕 v3.9: 집계 결과를 연관 테이블에 저장 const saveAggregationsToRelatedTables = async (card: GroupedCardData, contentRowId: string) => { if (!grouping?.aggregations) return; const savePromises: Promise[] = []; for (const agg of grouping.aggregations) { const saveConfig = agg.saveConfig; // 저장 설정이 없거나 비활성화된 경우 스킵 if (!saveConfig?.enabled) continue; // 자동 저장이 아닌 경우, 레이아웃에 연결되어 있는지 확인 필요 // (현재는 자동 저장과 동일하게 처리 - 추후 레이아웃 연결 체크 추가 가능) // 집계 결과 값 가져오기 const aggregatedValue = card._aggregations[agg.resultField]; if (aggregatedValue === undefined) { console.warn(`[RepeatScreenModal] 집계 결과 없음: ${agg.resultField}`); continue; } // 조인 키로 대상 레코드 식별 const sourceKeyValue = card._representativeData[saveConfig.joinKey.sourceField]; if (!sourceKeyValue) { console.warn(`[RepeatScreenModal] 조인 키 값 없음: ${saveConfig.joinKey.sourceField}`); continue; } console.log(`[RepeatScreenModal] 집계 저장 시작:`, { aggregation: agg.resultField, value: aggregatedValue, targetTable: saveConfig.targetTable, targetColumn: saveConfig.targetColumn, joinKey: `${saveConfig.joinKey.sourceField}=${sourceKeyValue} -> ${saveConfig.joinKey.targetField}`, }); // UPDATE API 호출 const updatePayload = { originalData: { [saveConfig.joinKey.targetField]: sourceKeyValue }, updatedData: { [saveConfig.targetColumn]: aggregatedValue, [saveConfig.joinKey.targetField]: sourceKeyValue, }, }; savePromises.push( apiClient.put(`/table-management/tables/${saveConfig.targetTable}/edit`, updatePayload) .then((res) => { console.log(`[RepeatScreenModal] 집계 저장 성공: ${agg.resultField} -> ${saveConfig.targetTable}.${saveConfig.targetColumn}`); return res; }) .catch((err) => { console.error(`[RepeatScreenModal] 집계 저장 실패: ${agg.resultField}`, err.response?.data || err.message); throw err; }) ); } if (savePromises.length > 0) { try { await Promise.all(savePromises); console.log(`[RepeatScreenModal] 모든 집계 저장 완료: ${savePromises.length}건`); } catch (error) { console.error("[RepeatScreenModal] 일부 집계 저장 실패:", error); } } }; // 🆕 v3.1: 외부 테이블 행 삭제 요청 const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { // 삭제 확인 팝업 표시 setPendingDeleteInfo({ cardId, rowId, contentRowId }); setDeleteConfirmOpen(true); } else { // 바로 삭제 handleDeleteExternalRow(cardId, rowId, contentRowId); } }; // 🆕 v3.1: 외부 테이블 행 삭제 실행 (소프트 삭제 - _isDeleted 플래그 설정) const handleDeleteExternalRow = (cardId: string, rowId: string, contentRowId: string) => { const key = `${cardId}-${contentRowId}`; setExternalTableData((prev) => { const newData = { ...prev, [key]: (prev[key] || []).map((row) => row._rowId === rowId ? { ...row, _isDeleted: true, _isDirty: true } : row ), }; // 🆕 v3.5: 행 삭제 시 집계 재계산 (삭제된 행 제외) setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); return newData; }); setDeleteConfirmOpen(false); setPendingDeleteInfo(null); }; // 🆕 v3.7: 삭제 취소 (소프트 삭제 복원) const handleRestoreExternalRow = (cardId: string, rowId: string, contentRowId: string) => { const key = `${cardId}-${contentRowId}`; setExternalTableData((prev) => { const newData = { ...prev, [key]: (prev[key] || []).map((row) => row._rowId === rowId ? { ...row, _isDeleted: false, _isDirty: true } : row ), }; setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); return newData; }); }; // 🆕 v3.8: 수정 모드 전환 const handleEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => { const key = `${cardId}-${contentRowId}`; setExternalTableData((prev) => ({ ...prev, [key]: (prev[key] || []).map((row) => row._rowId === rowId ? { ...row, _isEditing: true } : row ), })); }; // 🆕 v3.8: 수정 취소 const handleCancelEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => { const key = `${cardId}-${contentRowId}`; setExternalTableData((prev) => ({ ...prev, [key]: (prev[key] || []).map((row) => row._rowId === rowId ? { ...row._originalData, _rowId: row._rowId, _originalData: row._originalData, _isEditing: false, _isDirty: false, _isNew: false, _isDeleted: false, } : row ), })); }; // 🆕 v3.1: 외부 테이블 행 데이터 변경 const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => { const key = `${cardId}-${contentRowId}`; // 데이터 업데이트 setExternalTableData((prev) => { const newData = { ...prev, [key]: (prev[key] || []).map((row) => row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row ), }; // 🆕 v3.5: 데이터 변경 시 집계 실시간 재계산 // setTimeout으로 비동기 처리하여 상태 업데이트 후 재계산 setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); return newData; }); }; // 그룹화된 데이터 처리 const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => { if (!groupingConfig?.enabled) { return []; } const groupByField = groupingConfig.groupByField; const groupMap = new Map(); // groupByField가 없으면 각 행을 개별 그룹으로 처리 if (!groupByField) { // 각 행이 하나의 카드 (그룹) data.forEach((row, index) => { const groupKey = `row-${index}`; groupMap.set(groupKey, [row]); }); } else { // 그룹별로 데이터 분류 data.forEach((row) => { const groupKey = String(row[groupByField] || ""); if (!groupMap.has(groupKey)) { groupMap.set(groupKey, []); } groupMap.get(groupKey)!.push(row); }); } // GroupedCardData 생성 const result: GroupedCardData[] = []; let cardIndex = 0; groupMap.forEach((rows, groupKey) => { // 행 데이터 생성 const cardRows: CardRowData[] = rows.map((row, idx) => ({ _rowId: `row-${cardIndex}-${idx}-${Date.now()}`, _originalData: { ...row }, _isDirty: false, ...row, })); const representativeData = rows[0] || {}; // 🆕 v3.2: 집계 계산 (순서대로 - 이전 집계 결과 참조 가능) // 1단계: 기본 테이블 컬럼 집계만 (외부 테이블 데이터는 아직 없음) const aggregations: Record = {}; if (groupingConfig.aggregations) { groupingConfig.aggregations.forEach((agg) => { const sourceType = agg.sourceType || "column"; if (sourceType === "column") { // 컬럼 집계 (기본 테이블만 - 외부 테이블은 나중에 처리) const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; if (!isExternalTable) { // 기본 테이블 집계 aggregations[agg.resultField] = calculateColumnAggregation( rows, agg.sourceField || "", agg.type || "sum" ); } else { // 외부 테이블 집계는 나중에 계산 (placeholder) aggregations[agg.resultField] = 0; } } else if (sourceType === "formula") { // 가상 집계 (연산식) - 외부 테이블 없이 먼저 계산 시도 // 외부 테이블 데이터가 필요한 경우 나중에 재계산됨 if (agg.formula) { aggregations[agg.resultField] = evaluateFormulaWithContext( agg.formula, representativeData, rows, [], // 외부 테이블 데이터 없음 aggregations // 이전 집계 결과 참조 ); } else { aggregations[agg.resultField] = 0; } } }); } // 안정적인 _cardId 생성 (Date.now() 대신 groupKey 사용) // groupKey가 없으면 대표 데이터의 id 사용 const stableId = groupKey || representativeData.id || cardIndex; result.push({ _cardId: `grouped-card-${cardIndex}-${stableId}`, _groupKey: groupKey, _groupField: groupByField || "", _aggregations: aggregations, _rows: cardRows, _representativeData: representativeData, }); cardIndex++; }); return result; }; // 집계 계산 (컬럼 집계용) const calculateColumnAggregation = ( rows: any[], sourceField: string, type: "sum" | "count" | "avg" | "min" | "max" ): number => { const values = rows.map((row) => Number(row[sourceField]) || 0); switch (type) { case "sum": return values.reduce((a, b) => a + b, 0); case "count": return values.length; case "avg": return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; case "min": return values.length > 0 ? Math.min(...values) : 0; case "max": return values.length > 0 ? Math.max(...values) : 0; default: return 0; } }; // 🆕 v3.2: 집계 계산 (다중 테이블 및 formula 지원) const calculateAggregation = ( agg: AggregationConfig, cardRows: any[], // 기본 테이블 행들 externalRows: any[], // 외부 테이블 행들 previousAggregations: Record, // 이전 집계 결과들 representativeData: Record // 카드 대표 데이터 ): number => { const sourceType = agg.sourceType || "column"; if (sourceType === "column") { // 컬럼 집계 const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; // 외부 테이블인 경우 externalRows 사용, 아니면 cardRows 사용 const targetRows = isExternalTable ? externalRows : cardRows; return calculateColumnAggregation( targetRows, agg.sourceField || "", agg.type || "sum" ); } else if (sourceType === "formula") { // 가상 집계 (연산식) if (!agg.formula) return 0; return evaluateFormulaWithContext( agg.formula, representativeData, cardRows, externalRows, previousAggregations ); } return 0; }; // 🆕 v3.1: 집계 표시값 계산 (formula, external 등 지원) const calculateAggregationDisplayValue = ( aggField: AggregationDisplayConfig, card: GroupedCardData ): number | string => { const sourceType = aggField.sourceType || "aggregation"; switch (sourceType) { case "aggregation": // 기존 집계 결과 참조 return card._aggregations?.[aggField.aggregationResultField || ""] || 0; case "formula": // 컬럼 간 연산 if (!aggField.formula) return 0; return evaluateFormula(aggField.formula, card._representativeData, card._rows); case "external": // 외부 테이블 값 (별도 로드 필요 - 현재는 placeholder) // TODO: 외부 테이블 값 로드 구현 return 0; case "externalFormula": // 외부 테이블 + 연산 (별도 로드 필요 - 현재는 placeholder) // TODO: 외부 테이블 값 로드 후 연산 구현 return 0; default: return 0; } }; // 🆕 v3.2: 연산식 평가 (다중 테이블, 이전 집계 결과 참조 지원) const evaluateFormulaWithContext = ( formula: string, representativeData: Record, cardRows: any[], // 기본 테이블 행들 externalRows: any[], // 외부 테이블 행들 previousAggregations: Record // 이전 집계 결과들 ): number => { try { let expression = formula; // 1. 외부 테이블 집계 함수 처리: SUM_EXT({field}), COUNT_EXT({field}) 등 const extAggFunctions = ["SUM_EXT", "COUNT_EXT", "AVG_EXT", "MIN_EXT", "MAX_EXT"]; for (const fn of extAggFunctions) { const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g"); expression = expression.replace(regex, (match, fieldName) => { if (!externalRows || externalRows.length === 0) return "0"; const values = externalRows.map((row) => Number(row[fieldName]) || 0); const baseFn = fn.replace("_EXT", ""); switch (baseFn) { case "SUM": return String(values.reduce((a, b) => a + b, 0)); case "COUNT": return String(values.length); case "AVG": return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); case "MIN": return String(values.length > 0 ? Math.min(...values) : 0); case "MAX": return String(values.length > 0 ? Math.max(...values) : 0); default: return "0"; } }); } // 2. 기본 테이블 집계 함수 처리: SUM({field}), COUNT({field}) 등 const aggFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; for (const fn of aggFunctions) { // SUM_EXT는 이미 처리했으므로 제외 const regex = new RegExp(`(? { if (!cardRows || cardRows.length === 0) return "0"; const values = cardRows.map((row) => Number(row[fieldName]) || 0); switch (fn) { case "SUM": return String(values.reduce((a, b) => a + b, 0)); case "COUNT": return String(values.length); case "AVG": return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); case "MIN": return String(values.length > 0 ? Math.min(...values) : 0); case "MAX": return String(values.length > 0 ? Math.max(...values) : 0); default: return "0"; } }); } // 3. 단순 필드 참조 치환 (이전 집계 결과 또는 대표 데이터) const fieldRegex = /\{(\w+)\}/g; expression = expression.replace(fieldRegex, (match, fieldName) => { // 먼저 이전 집계 결과에서 찾기 if (previousAggregations && fieldName in previousAggregations) { return String(previousAggregations[fieldName]); } // 대표 데이터에서 값 가져오기 const value = representativeData[fieldName]; return String(Number(value) || 0); }); // 4. 안전한 수식 평가 (사칙연산만 허용) // 허용 문자: 숫자, 소수점, 사칙연산, 괄호, 공백 if (!/^[\d\s+\-*/().]+$/.test(expression)) { console.warn("[RepeatScreenModal] 허용되지 않는 연산식:", expression); return 0; } // eval 대신 Function 사용 (더 안전) const result = new Function(`return ${expression}`)(); return Number(result) || 0; } catch (error) { console.error("[RepeatScreenModal] 연산식 평가 실패:", formula, error); return 0; } }; // 레거시 호환: 기존 evaluateFormula 유지 const evaluateFormula = ( formula: string, representativeData: Record, rows?: any[] ): number => { return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {}); }; // 카드 데이터 로드 (소스 설정에 따라) const loadCardData = async (originalData: any): Promise> => { const cardData: Record = {}; // 🆕 v3: contentRows 사용 if (contentRows && contentRows.length > 0) { for (const contentRow of contentRows) { // 헤더/필드 타입의 컬럼 처리 if ((contentRow.type === "header" || contentRow.type === "fields") && contentRow.columns) { for (const col of contentRow.columns) { if (col.sourceConfig) { if (col.sourceConfig.type === "direct") { cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { cardData[col.field] = null; // 조인은 나중에 일괄 처리 } else if (col.sourceConfig.type === "manual") { cardData[col.field] = null; } } else { // sourceConfig가 없으면 원본 데이터에서 직접 가져옴 cardData[col.field] = originalData[col.field]; } } } // 테이블 타입의 컬럼 처리 if (contentRow.type === "table" && contentRow.tableColumns) { for (const col of contentRow.tableColumns) { cardData[col.field] = originalData[col.field]; } } } } else { // 레거시: cardLayout 사용 for (const row of cardLayout) { for (const col of row.columns) { if (col.sourceConfig) { if (col.sourceConfig.type === "direct") { cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { cardData[col.field] = null; // 조인은 나중에 일괄 처리 } else if (col.sourceConfig.type === "manual") { cardData[col.field] = null; } } else { cardData[col.field] = originalData[col.field]; } } } } return cardData; }; // Simple 모드: 카드 데이터 변경 const handleCardDataChange = (cardId: string, field: string, value: any) => { setCardsData((prev) => prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)) ); }; // WithTable 모드: 행 데이터 변경 const handleRowDataChange = (cardId: string, rowId: string, field: string, value: any) => { setGroupedCardsData((prev) => prev.map((card) => { if (card._cardId !== cardId) return card; const updatedRows = card._rows.map((row) => row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row ); // 집계값 재계산 const newAggregations: Record = {}; if (grouping?.aggregations) { grouping.aggregations.forEach((agg) => { newAggregations[agg.resultField] = calculateAggregation(updatedRows, agg); }); } return { ...card, _rows: updatedRows, _aggregations: newAggregations, }; }) ); }; // 카드 제목 생성 const getCardTitle = (data: Record, index: number): string => { let title = cardTitle; title = title.replace("{index}", String(index + 1)); const matches = title.match(/\{(\w+)\}/g); if (matches) { matches.forEach((match) => { const field = match.slice(1, -1); const value = data[field] || ""; title = title.replace(match, String(value)); }); } return title; }; // 전체 저장 const handleSaveAll = async () => { setIsSaving(true); try { // 기존 데이터 저장 if (cardMode === "withTable") { await saveGroupedData(); } else { await saveSimpleData(); } // 🆕 v3.1: 외부 테이블 데이터 저장 await saveExternalTableData(); alert("저장되었습니다."); } catch (error: any) { console.error("저장 실패:", error); alert(`저장 중 오류가 발생했습니다: ${error.message}`); } finally { setIsSaving(false); } }; // 🆕 v3.1: 외부 테이블 데이터 저장 const saveExternalTableData = async () => { const savePromises: Promise[] = []; for (const [key, rows] of Object.entries(externalTableData)) { // key 형식: cardId-contentRowId const [cardId, contentRowId] = key.split("-").slice(0, 2); const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id)); if (!contentRow?.tableDataSource?.enabled) continue; const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; const dirtyRows = rows.filter((row) => row._isDirty); for (const row of dirtyRows) { const { _rowId, _originalData, _isDirty, _isNew, ...dataToSave } = row; if (_isNew) { // INSERT savePromises.push( apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {}) ); } else if (_originalData?.id) { // UPDATE savePromises.push( apiClient.put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave).then(() => {}) ); } } } await Promise.all(savePromises); // 저장 후 dirty 플래그 초기화 setExternalTableData((prev) => { const updated: Record = {}; for (const [key, rows] of Object.entries(prev)) { updated[key] = rows.map((row) => ({ ...row, _isDirty: false, _isNew: false, _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, })); } return updated; }); }; // 🆕 v3.1: Footer 버튼 클릭 핸들러 const handleFooterButtonClick = async (btn: FooterButtonConfig) => { switch (btn.action) { case "save": await handleSaveAll(); break; case "cancel": case "close": // 모달 닫기 이벤트 발생 window.dispatchEvent(new CustomEvent("closeScreenModal")); break; case "reset": // 데이터 초기화 if (confirm("변경 사항을 모두 취소하시겠습니까?")) { // 외부 테이블 데이터 초기화 setExternalTableData({}); // 기존 데이터 재로드 setCardsData([]); setGroupedCardsData([]); } break; case "custom": // 커스텀 액션 이벤트 발생 if (btn.customAction) { window.dispatchEvent( new CustomEvent("repeatScreenModalCustomAction", { detail: { actionType: btn.customAction.type, config: btn.customAction.config, componentId: component?.id, }, }) ); } break; } }; // Simple 모드 저장 const saveSimpleData = async () => { const dirtyCards = cardsData.filter((card) => card._isDirty); if (dirtyCards.length === 0) { alert("변경된 데이터가 없습니다."); return; } const groupedData: Record = {}; for (const card of dirtyCards) { for (const row of cardLayout) { for (const col of row.columns) { if (col.targetConfig && col.targetConfig.saveEnabled !== false) { const targetTable = col.targetConfig.targetTable; const targetColumn = col.targetConfig.targetColumn; const value = card[col.field]; if (!groupedData[targetTable]) { groupedData[targetTable] = []; } let existingRow = groupedData[targetTable].find((r) => r._cardId === card._cardId); if (!existingRow) { existingRow = { _cardId: card._cardId, _originalData: card._originalData, }; groupedData[targetTable].push(existingRow); } existingRow[targetColumn] = value; } } } } await saveToTables(groupedData); setCardsData((prev) => prev.map((card) => ({ ...card, _isDirty: false }))); }; // WithTable 모드 저장 const saveGroupedData = async () => { const dirtyCards = groupedCardsData.filter((card) => card._rows.some((row) => row._isDirty)); if (dirtyCards.length === 0) { alert("변경된 데이터가 없습니다."); return; } const groupedData: Record = {}; for (const card of dirtyCards) { const dirtyRows = card._rows.filter((row) => row._isDirty); for (const row of dirtyRows) { // 테이블 컬럼에서 저장 대상 추출 if (tableLayout?.tableColumns) { for (const col of tableLayout.tableColumns) { if (col.editable && col.targetConfig && col.targetConfig.saveEnabled !== false) { const targetTable = col.targetConfig.targetTable; const targetColumn = col.targetConfig.targetColumn; const value = row[col.field]; if (!groupedData[targetTable]) { groupedData[targetTable] = []; } let existingRow = groupedData[targetTable].find((r) => r._rowId === row._rowId); if (!existingRow) { existingRow = { _rowId: row._rowId, _originalData: row._originalData, }; groupedData[targetTable].push(existingRow); } existingRow[targetColumn] = value; } } } } } await saveToTables(groupedData); setGroupedCardsData((prev) => prev.map((card) => ({ ...card, _rows: card._rows.map((row) => ({ ...row, _isDirty: false })), })) ); }; // 테이블별 저장 const saveToTables = async (groupedData: Record) => { const savePromises = Object.entries(groupedData).map(async ([tableName, rows]) => { return Promise.all( rows.map(async (row) => { const { _cardId, _rowId, _originalData, ...dataToSave } = row; const id = _originalData?.id; if (id) { await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, dataToSave); } else { await apiClient.post(`/table-management/tables/${tableName}/data`, dataToSave); } }) ); }); await Promise.all(savePromises); }; // 수정 여부 확인 const hasDirtyData = useMemo(() => { // 기존 데이터 수정 여부 let hasBaseDirty = false; if (cardMode === "withTable") { hasBaseDirty = groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); } else { hasBaseDirty = cardsData.some((c) => c._isDirty); } // 🆕 v3.1: 외부 테이블 데이터 수정 여부 const hasExternalDirty = Object.values(externalTableData).some((rows) => rows.some((row) => row._isDirty) ); return hasBaseDirty || hasExternalDirty; }, [cardMode, cardsData, groupedCardsData, externalTableData]); // 디자인 모드 렌더링 if (isDesignMode) { // 행 타입별 개수 계산 const rowTypeCounts = { header: contentRows.filter((r) => r.type === "header").length, aggregation: contentRows.filter((r) => r.type === "aggregation").length, table: contentRows.filter((r) => r.type === "table").length, fields: contentRows.filter((r) => r.type === "fields").length, }; return (
{/* 아이콘 */}
{/* 제목 */}
Repeat Screen Modal
반복 화면 모달
v3 자유 레이아웃
{/* 행 구성 정보 */}
{contentRows.length > 0 ? ( <> {rowTypeCounts.header > 0 && ( 헤더 {rowTypeCounts.header}개 )} {rowTypeCounts.aggregation > 0 && ( 집계 {rowTypeCounts.aggregation}개 )} {rowTypeCounts.table > 0 && ( 테이블 {rowTypeCounts.table}개 )} {rowTypeCounts.fields > 0 && ( 필드 {rowTypeCounts.fields}개 )} ) : ( 행 없음 )}
{/* 통계 정보 */}
{contentRows.length}
행 (Rows)
{grouping?.aggregations?.length || 0}
집계 설정
{dataSource?.sourceTable ? 1 : 0}
데이터 소스
{/* 데이터 소스 정보 */} {dataSource?.sourceTable && (
소스 테이블: {dataSource.sourceTable} {dataSource.filterField && (필터: {dataSource.filterField})}
)} {/* 그룹핑 정보 */} {grouping?.enabled && (
그룹핑: {grouping.groupByField}
)} {/* 카드 제목 정보 */} {showCardTitle && cardTitle && (
카드 제목: {cardTitle}
)} {/* 설정 안내 */}
오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요
); } // 로딩 상태 if (isLoading) { return (
데이터를 불러오는 중...
); } // 오류 상태 if (loadError) { return (
데이터 로드 실패

{loadError}

); } // 🆕 v3: 자유 레이아웃 렌더링 (contentRows 사용) const useNewLayout = contentRows && contentRows.length > 0; const useGrouping = grouping?.enabled; // 그룹핑 모드 렌더링 if (useGrouping) { return (
{groupedCardsData.map((card, cardIndex) => ( r._isDirty) && "border-primary shadow-lg" )} > {/* 카드 제목 (선택사항) */} {showCardTitle && ( {getCardTitle(card._representativeData, cardIndex)} {card._rows.some((r) => r._isDirty) && ( 수정됨 )} )} {/* 🆕 v3: contentRows 기반 렌더링 */} {useNewLayout ? ( contentRows.map((contentRow, rowIndex) => (
{contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( // 🆕 v3.1: 외부 테이블 데이터 소스 사용
{/* 테이블 헤더 영역: 제목 + 버튼들 */} {(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
{contentRow.tableTitle || ""}
{/* 저장 버튼 - allowSave가 true일 때만 표시 */} {contentRow.tableCrud?.allowSave && ( )} {/* 추가 버튼 */} {contentRow.tableCrud?.allowCreate && ( )}
)} {contentRow.showTableHeader !== false && ( {(contentRow.tableColumns || []).map((col) => ( {col.label} ))} {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && ( 작업 )} )} {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( 데이터가 없습니다. ) : ( (externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => ( {(contentRow.tableColumns || []).map((col) => ( {renderTableCell( col, row, (value) => handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value), row._isNew || row._isEditing // 신규 행이거나 수정 모드일 때만 편집 가능 )} ))} {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && (
{/* 수정 버튼: 저장된 행(isNew가 아닌)이고 편집 모드가 아닐 때만 표시 */} {contentRow.tableCrud?.allowUpdate && !row._isNew && !row._isEditing && !row._isDeleted && ( )} {/* 수정 취소 버튼: 편집 모드일 때만 표시 */} {row._isEditing && !row._isNew && ( )} {/* 삭제/복원 버튼 */} {contentRow.tableCrud?.allowDelete && ( row._isDeleted ? ( ) : ( ) )}
)}
)) )}
) : ( // 기존 renderContentRow 사용 renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange) )}
)) ) : ( // 레거시: tableLayout 사용 <> {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && (
{tableLayout.headerRows.map((row, rowIndex) => (
{row.columns.map((col, colIndex) => (
{renderHeaderColumn(col, card, grouping?.aggregations || [])}
))}
))}
)} {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && (
{tableLayout.tableColumns.map((col) => ( {col.label} ))} {card._rows.map((row) => ( {tableLayout.tableColumns.map((col) => ( {renderTableCell( col, row, (value) => handleRowDataChange(card._cardId, row._rowId, col.field, value), row._isNew || row._isEditing )} ))} ))}
)} )}
))}
{/* 🆕 v3.1: Footer 버튼 영역 */} {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? (
{footerConfig.buttons.map((btn) => ( ))}
) : null} {/* 데이터 없음 */} {groupedCardsData.length === 0 && !isLoading && (
표시할 데이터가 없습니다.
)} {/* 🆕 v3.1: 삭제 확인 다이얼로그 */} 삭제 확인 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. 취소 { if (pendingDeleteInfo) { handleDeleteExternalRow( pendingDeleteInfo.cardId, pendingDeleteInfo.rowId, pendingDeleteInfo.contentRowId ); } }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > 삭제
); } // 단순 모드 렌더링 (그룹핑 없음) return (
{cardsData.map((card, cardIndex) => ( {/* 카드 제목 (선택사항) */} {showCardTitle && ( {getCardTitle(card, cardIndex)} {card._isDirty && (수정됨)} )} {/* 🆕 v3: contentRows 기반 렌더링 */} {useNewLayout ? ( contentRows.map((contentRow, rowIndex) => (
{renderSimpleContentRow(contentRow, card, (value, field) => handleCardDataChange(card._cardId, field, value) )}
)) ) : ( // 레거시: cardLayout 사용 cardLayout.map((row, rowIndex) => (
{row.columns.map((col, colIndex) => (
{renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))}
))}
)) )}
))}
{/* 🆕 v3.1: Footer 버튼 영역 */} {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? (
{footerConfig.buttons.map((btn) => ( ))}
) : null} {/* 데이터 없음 */} {cardsData.length === 0 && !isLoading && (
표시할 데이터가 없습니다.
)}
); } // 🆕 v3: contentRow 렌더링 (그룹핑 모드) function renderContentRow( contentRow: CardContentRowConfig, card: GroupedCardData, aggregations: AggregationConfig[], onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void ) { switch (contentRow.type) { case "header": case "fields": // contentRow에서 직접 columns 가져오기 (v3 구조) const headerColumns = contentRow.columns || []; if (headerColumns.length === 0) { return (
헤더 컬럼이 설정되지 않았습니다.
); } return (
{headerColumns.map((col, colIndex) => (
{renderHeaderColumn(col, card, aggregations)}
))}
); case "aggregation": // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) const aggFields = contentRow.aggregationFields || []; if (aggFields.length === 0) { return (
집계 필드가 설정되지 않았습니다. (레이아웃 탭에서 집계 필드를 추가하세요)
); } return (
{aggFields.map((aggField, fieldIndex) => { // 집계 결과에서 값 가져오기 (aggregationResultField 사용) const value = card._aggregations?.[aggField.aggregationResultField] || 0; return (
{aggField.label || aggField.aggregationResultField}
{typeof value === "number" ? value.toLocaleString() : value || "-"}
); })}
); case "table": // contentRow에서 직접 tableColumns 가져오기 (v3 구조) const tableColumns = contentRow.tableColumns || []; if (tableColumns.length === 0) { return (
테이블 컬럼이 설정되지 않았습니다. (레이아웃 탭에서 테이블 컬럼을 추가하세요)
); } return (
{contentRow.tableTitle && (
{contentRow.tableTitle}
)} {contentRow.showTableHeader !== false && ( {tableColumns.map((col) => ( {col.label} ))} )} {card._rows.map((row) => ( {tableColumns.map((col) => ( {renderTableCell( col, row, (value) => onRowDataChange(card._cardId, row._rowId, col.field, value), row._isNew || row._isEditing )} ))} ))}
); default: return null; } } // 🆕 v3: contentRow 렌더링 (단순 모드) function renderSimpleContentRow( contentRow: CardContentRowConfig, card: CardData, onChange: (value: any, field: string) => void ) { switch (contentRow.type) { case "header": case "fields": return (
{(contentRow.columns || []).map((col, colIndex) => (
{renderColumn(col, card, (value) => onChange(value, col.field))}
))}
); case "aggregation": // 단순 모드에서도 집계 표시 (단일 카드 기준) // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) const aggFields = contentRow.aggregationFields || []; if (aggFields.length === 0) { return (
집계 필드가 설정되지 않았습니다.
); } return (
{aggFields.map((aggField, fieldIndex) => { // 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용) const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; return (
{aggField.label || aggField.aggregationResultField}
{typeof value === "number" ? value.toLocaleString() : value || "-"}
); })}
); case "table": // 단순 모드에서도 테이블 표시 (단일 행) // contentRow에서 직접 tableColumns 가져오기 (v3 구조) const tableColumns = contentRow.tableColumns || []; if (tableColumns.length === 0) { return (
테이블 컬럼이 설정되지 않았습니다.
); } return (
{contentRow.tableTitle && (
{contentRow.tableTitle}
)} {contentRow.showTableHeader !== false && ( {tableColumns.map((col) => ( {col.label} ))} )} {/* 단순 모드: 카드 자체가 하나의 행 */} {tableColumns.map((col) => ( {renderSimpleTableCell(col, card, (value) => onChange(value, col.field))} ))}
); default: return null; } } // 단순 모드 테이블 셀 렌더링 function renderSimpleTableCell( col: TableColumnConfig, card: CardData, onChange: (value: any) => void ) { const value = card[col.field] || card._originalData?.[col.field]; if (!col.editable) { // 읽기 전용 if (col.type === "number") { return typeof value === "number" ? value.toLocaleString() : value || "-"; } return value || "-"; } // 편집 가능 switch (col.type) { case "number": return ( onChange(parseFloat(e.target.value) || 0)} className="h-8 text-sm" /> ); case "date": return ( onChange(e.target.value)} className="h-8 text-sm" /> ); case "select": return ( ); default: return ( onChange(e.target.value)} className="h-8 text-sm" /> ); } } // 배경색 클래스 변환 function getBackgroundClass(color: string): string { const colorMap: Record = { blue: "bg-blue-50 dark:bg-blue-950", green: "bg-green-50 dark:bg-green-950", purple: "bg-purple-50 dark:bg-purple-950", orange: "bg-orange-50 dark:bg-orange-950", }; return colorMap[color] || ""; } // 헤더 컬럼 렌더링 (집계값 포함) function renderHeaderColumn( col: CardColumnConfig, card: GroupedCardData, aggregations: AggregationConfig[] ) { let value: any; // 집계값 타입이면 집계 결과에서 가져옴 if (col.type === "aggregation" && col.aggregationField) { value = card._aggregations[col.aggregationField]; const aggConfig = aggregations.find((a) => a.resultField === col.aggregationField); return (
{typeof value === "number" ? value.toLocaleString() : value || "-"} {aggConfig && ({aggConfig.type})}
); } // 일반 필드는 대표 데이터에서 가져옴 value = card._representativeData[col.field]; return (
{value || "-"}
); } // 테이블 셀 렌더링 // 🆕 v3.8: isRowEditable 파라미터 추가 - 행이 편집 가능한 상태인지 (신규 행이거나 수정 모드) function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void, isRowEditable?: boolean) { const value = row[col.field]; // Badge 타입 if (col.type === "badge") { const badgeColor = col.badgeColorMap?.[value] || col.badgeVariant || "default"; return {value || "-"}; } // 🆕 v3.8: 행 수준 편집 가능 여부 체크 // isRowEditable이 false이면 컬럼 설정과 관계없이 읽기 전용 const canEdit = col.editable && (isRowEditable !== false); // 읽기 전용 if (!canEdit) { if (col.type === "number") { return {typeof value === "number" ? value.toLocaleString() : value || "-"}; } if (col.type === "date") { // ISO 8601 형식을 표시용으로 변환 const displayDate = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "-"; return {displayDate}; } return {value || "-"}; } // 편집 가능 switch (col.type) { case "text": return ( onChange(e.target.value)} className="h-8 text-sm" /> ); case "number": return ( onChange(Number(e.target.value) || 0)} className="h-8 text-sm text-right" /> ); case "date": // ISO 8601 형식('2025-12-02T00:00:00.000Z')을 'YYYY-MM-DD' 형식으로 변환 const dateValue = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : ""; return ( onChange(e.target.value)} className="h-8 text-sm" /> ); default: return {value || "-"}; } } // 컬럼 렌더링 함수 (Simple 모드) function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) { const value = card[col.field]; const isReadOnly = !col.editable; return (
{isReadOnly && (
{value || "-"}
)} {!isReadOnly && ( <> {col.type === "text" && ( onChange(e.target.value)} placeholder={col.placeholder} className="h-10 text-sm" /> )} {col.type === "number" && ( onChange(e.target.value)} placeholder={col.placeholder} className="h-10 text-sm" /> )} {col.type === "date" && ( onChange(e.target.value)} className="h-10 text-sm" /> )} {col.type === "select" && ( )} {col.type === "textarea" && (