ERP-node/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx

2880 lines
109 KiB
TypeScript

"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<string, any>[]; // 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<any[]>([]); // 원본 데이터
const [cardsData, setCardsData] = useState<CardData[]>([]); // simple 모드용
const [groupedCardsData, setGroupedCardsData] = useState<GroupedCardData[]>([]); // withTable 모드용
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// 🆕 v3.1: 외부 테이블 데이터 (테이블 행별로 관리)
const [externalTableData, setExternalTableData] = useState<Record<string, any[]>>({});
// 🆕 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<string, any[]> = {};
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<string, any> = {};
// 허용된 필드만 포함
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<string, any> = {};
// 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<string, any[]> = {};
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<string, any> = {};
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<any[]> => {
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<any, any>();
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<string, any[]>) => {
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<string, number> = {};
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<string, any> = {
_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<any>[] = [];
const savedIds: number[] = [];
// 🆕 v3.6: editable한 컬럼 + 조인 키만 추출 (읽기 전용 컬럼은 제외)
const allowedFields = new Set<string>();
// 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<string, any> = {};
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<any>[] = [];
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<string, any[]>();
// 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<string, number> = {};
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<string, number>, // 이전 집계 결과들
representativeData: Record<string, any> // 카드 대표 데이터
): 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<string, any>,
cardRows: any[], // 기본 테이블 행들
externalRows: any[], // 외부 테이블 행들
previousAggregations: Record<string, number> // 이전 집계 결과들
): 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(`(?<!_)${fn}\\(\\{(\\w+)\\}\\)`, "g");
expression = expression.replace(regex, (match, fieldName) => {
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<string, any>,
rows?: any[]
): number => {
return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {});
};
// 카드 데이터 로드 (소스 설정에 따라)
const loadCardData = async (originalData: any): Promise<Record<string, any>> => {
const cardData: Record<string, any> = {};
// 🆕 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<string, number> = {};
if (grouping?.aggregations) {
grouping.aggregations.forEach((agg) => {
newAggregations[agg.resultField] = calculateAggregation(updatedRows, agg);
});
}
return {
...card,
_rows: updatedRows,
_aggregations: newAggregations,
};
})
);
};
// 카드 제목 생성
const getCardTitle = (data: Record<string, any>, 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<void>[] = [];
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<string, any[]> = {};
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<string, any[]> = {};
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<string, any[]> = {};
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<string, any[]>) => {
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 (
<div
className={cn(
"w-full h-full min-h-[400px] border-2 border-dashed border-primary/50 rounded-lg p-8 bg-gradient-to-br from-primary/5 to-primary/10",
className
)}
>
<div className="flex flex-col items-center justify-center h-full space-y-6">
{/* 아이콘 */}
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center">
<Layers className="w-10 h-10 text-primary" />
</div>
{/* 제목 */}
<div className="text-center space-y-2">
<div className="text-xl font-bold text-primary">Repeat Screen Modal</div>
<div className="text-base font-semibold text-foreground"> </div>
<Badge variant="secondary">v3 </Badge>
</div>
{/* 행 구성 정보 */}
<div className="flex flex-wrap gap-2 justify-center">
{contentRows.length > 0 ? (
<>
{rowTypeCounts.header > 0 && (
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
{rowTypeCounts.header}
</Badge>
)}
{rowTypeCounts.aggregation > 0 && (
<Badge className="bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300">
{rowTypeCounts.aggregation}
</Badge>
)}
{rowTypeCounts.table > 0 && (
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{rowTypeCounts.table}
</Badge>
)}
{rowTypeCounts.fields > 0 && (
<Badge className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">
{rowTypeCounts.fields}
</Badge>
)}
</>
) : (
<Badge variant="outline"> </Badge>
)}
</div>
{/* 통계 정보 */}
<div className="flex gap-6 text-center">
<div className="space-y-1">
<div className="text-2xl font-bold text-primary">{contentRows.length}</div>
<div className="text-xs text-muted-foreground"> (Rows)</div>
</div>
<div className="w-px bg-border" />
<div className="space-y-1">
<div className="text-2xl font-bold text-primary">{grouping?.aggregations?.length || 0}</div>
<div className="text-xs text-muted-foreground"> </div>
</div>
<div className="w-px bg-border" />
<div className="space-y-1">
<div className="text-2xl font-bold text-primary">{dataSource?.sourceTable ? 1 : 0}</div>
<div className="text-xs text-muted-foreground"> </div>
</div>
</div>
{/* 데이터 소스 정보 */}
{dataSource?.sourceTable && (
<div className="text-xs bg-green-100 dark:bg-green-900 px-3 py-2 rounded-md">
: <strong>{dataSource.sourceTable}</strong>
{dataSource.filterField && <span className="ml-2">(: {dataSource.filterField})</span>}
</div>
)}
{/* 그룹핑 정보 */}
{grouping?.enabled && (
<div className="text-xs bg-purple-100 dark:bg-purple-900 px-3 py-2 rounded-md">
: <strong>{grouping.groupByField}</strong>
</div>
)}
{/* 카드 제목 정보 */}
{showCardTitle && cardTitle && (
<div className="text-xs bg-muted px-3 py-2 rounded-md">
: <strong>{cardTitle}</strong>
</div>
)}
{/* 설정 안내 */}
<div className="text-xs text-muted-foreground bg-muted/50 px-4 py-2 rounded-md border">
(///)
</div>
</div>
</div>
);
}
// 로딩 상태
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-sm text-muted-foreground"> ...</span>
</div>
);
}
// 오류 상태
if (loadError) {
return (
<div className="p-6 border border-destructive/50 rounded-lg bg-destructive/5">
<div className="flex items-center gap-2 text-destructive mb-2">
<X className="h-5 w-5" />
<span className="font-semibold"> </span>
</div>
<p className="text-sm text-muted-foreground">{loadError}</p>
</div>
);
}
// 🆕 v3: 자유 레이아웃 렌더링 (contentRows 사용)
const useNewLayout = contentRows && contentRows.length > 0;
const useGrouping = grouping?.enabled;
// 그룹핑 모드 렌더링
if (useGrouping) {
return (
<div className={cn("space-y-6 overflow-x-auto", className)}>
<div className="space-y-4 min-w-[800px]" style={{ gap: cardSpacing }}>
{groupedCardsData.map((card, cardIndex) => (
<Card
key={card._cardId}
className={cn(
"transition-shadow",
showCardBorder && "border-2",
card._rows.some((r) => r._isDirty) && "border-primary shadow-lg"
)}
>
{/* 카드 제목 (선택사항) */}
{showCardTitle && (
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<span>{getCardTitle(card._representativeData, cardIndex)}</span>
{card._rows.some((r) => r._isDirty) && (
<Badge variant="outline" className="text-xs text-primary">
</Badge>
)}
</CardTitle>
</CardHeader>
)}
<CardContent className="space-y-4">
{/* 🆕 v3: contentRows 기반 렌더링 */}
{useNewLayout ? (
contentRows.map((contentRow, rowIndex) => (
<div key={contentRow.id || `crow-${rowIndex}`}>
{contentRow.type === "table" && contentRow.tableDataSource?.enabled ? (
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
<div className="border rounded-lg overflow-hidden">
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
{(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
<span>{contentRow.tableTitle || ""}</span>
<div className="flex items-center gap-2">
{/* 저장 버튼 - allowSave가 true일 때만 표시 */}
{contentRow.tableCrud?.allowSave && (
<Button
variant="default"
size="sm"
onClick={() => handleTableAreaSave(card._cardId, contentRow.id, contentRow)}
disabled={isSaving || !(externalTableData[`${card._cardId}-${contentRow.id}`] || []).some((r: any) => r._isDirty)}
className="h-7 text-xs gap-1"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
{contentRow.tableCrud?.saveButtonLabel || "저장"}
</Button>
)}
{/* 추가 버튼 */}
{contentRow.tableCrud?.allowCreate && (
<Button
variant="outline"
size="sm"
onClick={() => handleAddExternalRow(card._cardId, contentRow.id, contentRow)}
className="h-7 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
</div>
)}
<Table>
{contentRow.showTableHeader !== false && (
<TableHeader>
<TableRow className="bg-muted/50">
{(contentRow.tableColumns || []).map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
className={cn("text-xs", col.align && `text-${col.align}`)}
>
{col.label}
</TableHead>
))}
{(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && (
<TableHead className="w-[80px] text-center text-xs"></TableHead>
)}
</TableRow>
</TableHeader>
)}
<TableBody>
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
<TableRow>
<TableCell
colSpan={(contentRow.tableColumns?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
) : (
(externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => (
<TableRow
key={row._rowId}
className={cn(
row._isDirty && "bg-primary/5",
row._isNew && "bg-green-50 dark:bg-green-950",
row._isDeleted && "bg-destructive/10 opacity-60"
)}
>
{(contentRow.tableColumns || []).map((col) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn(
"text-sm",
col.align && `text-${col.align}`,
row._isDeleted && "line-through text-muted-foreground"
)}
>
{renderTableCell(
col,
row,
(value) => handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value),
row._isNew || row._isEditing // 신규 행이거나 수정 모드일 때만 편집 가능
)}
</TableCell>
))}
{(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && (
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
{/* 수정 버튼: 저장된 행(isNew가 아닌)이고 편집 모드가 아닐 때만 표시 */}
{contentRow.tableCrud?.allowUpdate && !row._isNew && !row._isEditing && !row._isDeleted && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditExternalRow(card._cardId, row._rowId, contentRow.id)}
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title="수정"
>
<Pencil className="h-4 w-4" />
</Button>
)}
{/* 수정 취소 버튼: 편집 모드일 때만 표시 */}
{row._isEditing && !row._isNew && (
<Button
variant="ghost"
size="sm"
onClick={() => handleCancelEditExternalRow(card._cardId, row._rowId, contentRow.id)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground hover:bg-muted"
title="수정 취소"
>
<X className="h-4 w-4" />
</Button>
)}
{/* 삭제/복원 버튼 */}
{contentRow.tableCrud?.allowDelete && (
row._isDeleted ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleRestoreExternalRow(card._cardId, row._rowId, contentRow.id)}
className="h-7 w-7 p-0 text-primary hover:text-primary hover:bg-primary/10"
title="삭제 취소"
>
<RotateCcw className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteExternalRowRequest(card._cardId, row._rowId, contentRow.id, contentRow)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
)
)}
</div>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
// 기존 renderContentRow 사용
renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange)
)}
</div>
))
) : (
// 레거시: tableLayout 사용
<>
{tableLayout?.headerRows && tableLayout.headerRows.length > 0 && (
<div className="space-y-3 p-4 bg-muted/30 rounded-lg">
{tableLayout.headerRows.map((row, rowIndex) => (
<div
key={row.id || `hrow-${rowIndex}`}
className={cn(
"grid gap-4",
row.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr",
row.backgroundColor && getBackgroundClass(row.backgroundColor),
row.rounded && "rounded-lg",
row.padding && `p-${row.padding}`
)}
style={{ gap: row.gap || "16px" }}
>
{row.columns.map((col, colIndex) => (
<div key={col.id || `hcol-${colIndex}`} style={{ width: col.width }}>
{renderHeaderColumn(col, card, grouping?.aggregations || [])}
</div>
))}
</div>
))}
</div>
)}
{tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
{tableLayout.tableColumns.map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
className={cn("text-xs", col.align && `text-${col.align}`)}
>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{card._rows.map((row) => (
<TableRow key={row._rowId} className={cn(row._isDirty && "bg-primary/5")}>
{tableLayout.tableColumns.map((col) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn("text-sm", col.align && `text-${col.align}`)}
>
{renderTableCell(
col,
row,
(value) => handleRowDataChange(card._cardId, row._rowId, col.field, value),
row._isNew || row._isEditing
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</>
)}
</CardContent>
</Card>
))}
</div>
{/* 🆕 v3.1: Footer 버튼 영역 */}
{footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? (
<div
className={cn(
"flex gap-2 pt-4",
footerConfig.position === "sticky" && "sticky bottom-0 bg-background py-4 border-t",
footerConfig.alignment === "left" && "justify-start",
footerConfig.alignment === "center" && "justify-center",
footerConfig.alignment === "right" && "justify-end",
!footerConfig.alignment && "justify-end"
)}
>
{footerConfig.buttons.map((btn) => (
<Button
key={btn.id}
variant={btn.variant || "default"}
disabled={btn.disabled || (btn.action === "save" && (isSaving || !hasDirtyData))}
onClick={() => handleFooterButtonClick(btn)}
className="gap-2"
>
{btn.action === "save" && isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : btn.icon === "save" ? (
<Save className="h-4 w-4" />
) : btn.icon === "x" ? (
<X className="h-4 w-4" />
) : btn.icon === "reset" ? (
<RotateCcw className="h-4 w-4" />
) : null}
{btn.label}
</Button>
))}
</div>
) : null}
{/* 데이터 없음 */}
{groupedCardsData.length === 0 && !isLoading && (
<div className="text-center py-12 text-muted-foreground"> .</div>
)}
{/* 🆕 v3.1: 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingDeleteInfo) {
handleDeleteExternalRow(
pendingDeleteInfo.cardId,
pendingDeleteInfo.rowId,
pendingDeleteInfo.contentRowId
);
}
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// 단순 모드 렌더링 (그룹핑 없음)
return (
<div className={cn("space-y-6 overflow-x-auto", className)}>
<div className="space-y-4 min-w-[600px]" style={{ gap: cardSpacing }}>
{cardsData.map((card, cardIndex) => (
<Card
key={card._cardId}
className={cn("transition-shadow", showCardBorder && "border-2", card._isDirty && "border-primary shadow-lg")}
>
{/* 카드 제목 (선택사항) */}
{showCardTitle && (
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<span>{getCardTitle(card, cardIndex)}</span>
{card._isDirty && <span className="text-xs text-primary font-normal">()</span>}
</CardTitle>
</CardHeader>
)}
<CardContent className="space-y-4">
{/* 🆕 v3: contentRows 기반 렌더링 */}
{useNewLayout ? (
contentRows.map((contentRow, rowIndex) => (
<div key={contentRow.id || `crow-${rowIndex}`}>
{renderSimpleContentRow(contentRow, card, (value, field) =>
handleCardDataChange(card._cardId, field, value)
)}
</div>
))
) : (
// 레거시: cardLayout 사용
cardLayout.map((row, rowIndex) => (
<div
key={row.id || `row-${rowIndex}`}
className={cn(
"grid gap-4",
row.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr"
)}
style={{ gap: row.gap || "16px" }}
>
{row.columns.map((col, colIndex) => (
<div key={col.id || `col-${colIndex}`} style={{ width: col.width }}>
{renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))}
</div>
))}
</div>
))
)}
</CardContent>
</Card>
))}
</div>
{/* 🆕 v3.1: Footer 버튼 영역 */}
{footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? (
<div
className={cn(
"flex gap-2 pt-4",
footerConfig.position === "sticky" && "sticky bottom-0 bg-background py-4 border-t",
footerConfig.alignment === "left" && "justify-start",
footerConfig.alignment === "center" && "justify-center",
footerConfig.alignment === "right" && "justify-end",
!footerConfig.alignment && "justify-end"
)}
>
{footerConfig.buttons.map((btn) => (
<Button
key={btn.id}
variant={btn.variant || "default"}
disabled={btn.disabled || (btn.action === "save" && (isSaving || !hasDirtyData))}
onClick={() => handleFooterButtonClick(btn)}
className="gap-2"
>
{btn.action === "save" && isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : btn.icon === "save" ? (
<Save className="h-4 w-4" />
) : btn.icon === "x" ? (
<X className="h-4 w-4" />
) : btn.icon === "reset" ? (
<RotateCcw className="h-4 w-4" />
) : null}
{btn.label}
</Button>
))}
</div>
) : null}
{/* 데이터 없음 */}
{cardsData.length === 0 && !isLoading && (
<div className="text-center py-12 text-muted-foreground"> .</div>
)}
</div>
);
}
// 🆕 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 (
<div className="p-4 bg-muted/30 rounded-lg text-sm text-muted-foreground">
.
</div>
);
}
return (
<div
className={cn(
"grid gap-4",
contentRow.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr",
contentRow.backgroundColor && getBackgroundClass(contentRow.backgroundColor),
contentRow.padding && `p-${contentRow.padding}`,
"rounded-lg"
)}
style={{ gap: contentRow.gap || "16px" }}
>
{headerColumns.map((col, colIndex) => (
<div key={col.id || `col-${colIndex}`} style={{ width: col.width }}>
{renderHeaderColumn(col, card, aggregations)}
</div>
))}
</div>
);
case "aggregation":
// contentRow에서 직접 aggregationFields 가져오기 (v3 구조)
const aggFields = contentRow.aggregationFields || [];
if (aggFields.length === 0) {
return (
<div className="p-4 bg-orange-50 dark:bg-orange-950 rounded-lg text-sm text-orange-600 dark:text-orange-400">
. ( )
</div>
);
}
return (
<div
className={cn(
contentRow.aggregationLayout === "vertical"
? "flex flex-col gap-3"
: "flex flex-wrap gap-4"
)}
>
{aggFields.map((aggField, fieldIndex) => {
// 집계 결과에서 값 가져오기 (aggregationResultField 사용)
const value = card._aggregations?.[aggField.aggregationResultField] || 0;
return (
<div
key={fieldIndex}
className={cn(
"p-3 rounded-lg flex-1 min-w-[120px]",
aggField.backgroundColor ? getBackgroundClass(aggField.backgroundColor) : "bg-muted/50"
)}
>
<div className="text-xs text-muted-foreground">{aggField.label || aggField.aggregationResultField}</div>
<div
className={cn(
"font-bold",
aggField.fontSize ? `text-${aggField.fontSize}` : "text-lg",
aggField.textColor && `text-${aggField.textColor}`
)}
>
{typeof value === "number" ? value.toLocaleString() : value || "-"}
</div>
</div>
);
})}
</div>
);
case "table":
// contentRow에서 직접 tableColumns 가져오기 (v3 구조)
const tableColumns = contentRow.tableColumns || [];
if (tableColumns.length === 0) {
return (
<div className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg text-sm text-blue-600 dark:text-blue-400">
. ( )
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
{contentRow.tableTitle && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium">
{contentRow.tableTitle}
</div>
)}
<Table>
{contentRow.showTableHeader !== false && (
<TableHeader>
<TableRow className="bg-muted/50">
{tableColumns.map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
className={cn("text-xs", col.align && `text-${col.align}`)}
>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{card._rows.map((row) => (
<TableRow key={row._rowId} className={cn(row._isDirty && "bg-primary/5")}>
{tableColumns.map((col) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn("text-sm", col.align && `text-${col.align}`)}
>
{renderTableCell(
col,
row,
(value) => onRowDataChange(card._cardId, row._rowId, col.field, value),
row._isNew || row._isEditing
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
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 (
<div
className={cn(
"grid gap-4",
contentRow.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr",
contentRow.backgroundColor && getBackgroundClass(contentRow.backgroundColor),
contentRow.padding && `p-${contentRow.padding}`,
"rounded-lg"
)}
style={{ gap: contentRow.gap || "16px" }}
>
{(contentRow.columns || []).map((col, colIndex) => (
<div key={col.id || `col-${colIndex}`} style={{ width: col.width }}>
{renderColumn(col, card, (value) => onChange(value, col.field))}
</div>
))}
</div>
);
case "aggregation":
// 단순 모드에서도 집계 표시 (단일 카드 기준)
// contentRow에서 직접 aggregationFields 가져오기 (v3 구조)
const aggFields = contentRow.aggregationFields || [];
if (aggFields.length === 0) {
return (
<div className="p-4 bg-orange-50 dark:bg-orange-950 rounded-lg text-sm text-orange-600 dark:text-orange-400">
.
</div>
);
}
return (
<div
className={cn(
contentRow.aggregationLayout === "vertical"
? "flex flex-col gap-3"
: "flex flex-wrap gap-4"
)}
>
{aggFields.map((aggField, fieldIndex) => {
// 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용)
const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField];
return (
<div
key={fieldIndex}
className={cn(
"p-3 rounded-lg flex-1 min-w-[120px]",
aggField.backgroundColor ? getBackgroundClass(aggField.backgroundColor) : "bg-muted/50"
)}
>
<div className="text-xs text-muted-foreground">{aggField.label || aggField.aggregationResultField}</div>
<div
className={cn(
"font-bold",
aggField.fontSize ? `text-${aggField.fontSize}` : "text-lg",
aggField.textColor && `text-${aggField.textColor}`
)}
>
{typeof value === "number" ? value.toLocaleString() : value || "-"}
</div>
</div>
);
})}
</div>
);
case "table":
// 단순 모드에서도 테이블 표시 (단일 행)
// contentRow에서 직접 tableColumns 가져오기 (v3 구조)
const tableColumns = contentRow.tableColumns || [];
if (tableColumns.length === 0) {
return (
<div className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg text-sm text-blue-600 dark:text-blue-400">
.
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
{contentRow.tableTitle && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium">
{contentRow.tableTitle}
</div>
)}
<Table>
{contentRow.showTableHeader !== false && (
<TableHeader>
<TableRow className="bg-muted/50">
{tableColumns.map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
className={cn("text-xs", col.align && `text-${col.align}`)}
>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{/* 단순 모드: 카드 자체가 하나의 행 */}
<TableRow className={cn(card._isDirty && "bg-primary/5")}>
{tableColumns.map((col) => (
<TableCell
key={`${card._cardId}-${col.id}`}
className={cn("text-sm", col.align && `text-${col.align}`)}
>
{renderSimpleTableCell(col, card, (value) => onChange(value, col.field))}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
);
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 (
<Input
type="number"
value={value || ""}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
className="h-8 text-sm"
/>
);
case "date":
return (
<Input
type="date"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
case "select":
return (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{(col.selectOptions || []).map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default:
return (
<Input
type="text"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
}
}
// 배경색 클래스 변환
function getBackgroundClass(color: string): string {
const colorMap: Record<string, string> = {
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 (
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">{col.label}</Label>
<div
className={cn(
"text-lg font-bold",
col.textColor && `text-${col.textColor}`,
col.fontSize && `text-${col.fontSize}`
)}
>
{typeof value === "number" ? value.toLocaleString() : value || "-"}
{aggConfig && <span className="text-xs font-normal text-muted-foreground ml-1">({aggConfig.type})</span>}
</div>
</div>
);
}
// 일반 필드는 대표 데이터에서 가져옴
value = card._representativeData[col.field];
return (
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">{col.label}</Label>
<div
className={cn(
"text-sm",
col.fontWeight && `font-${col.fontWeight}`,
col.fontSize && `text-${col.fontSize}`
)}
>
{value || "-"}
</div>
</div>
);
}
// 테이블 셀 렌더링
// 🆕 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 <Badge variant={badgeColor as any}>{value || "-"}</Badge>;
}
// 🆕 v3.8: 행 수준 편집 가능 여부 체크
// isRowEditable이 false이면 컬럼 설정과 관계없이 읽기 전용
const canEdit = col.editable && (isRowEditable !== false);
// 읽기 전용
if (!canEdit) {
if (col.type === "number") {
return <span>{typeof value === "number" ? value.toLocaleString() : value || "-"}</span>;
}
if (col.type === "date") {
// ISO 8601 형식을 표시용으로 변환
const displayDate = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "-";
return <span>{displayDate}</span>;
}
return <span>{value || "-"}</span>;
}
// 편집 가능
switch (col.type) {
case "text":
return (
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
case "number":
return (
<Input
type="number"
value={value || ""}
onChange={(e) => 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 (
<Input
type="date"
value={dateValue}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
default:
return <span>{value || "-"}</span>;
}
}
// 컬럼 렌더링 함수 (Simple 모드)
function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) {
const value = card[col.field];
const isReadOnly = !col.editable;
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium">
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</Label>
{isReadOnly && (
<div className="text-sm text-muted-foreground bg-muted px-3 py-2 rounded-md min-h-[40px] flex items-center">
{value || "-"}
</div>
)}
{!isReadOnly && (
<>
{col.type === "text" && (
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={col.placeholder}
className="h-10 text-sm"
/>
)}
{col.type === "number" && (
<Input
type="number"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={col.placeholder}
className="h-10 text-sm"
/>
)}
{col.type === "date" && (
<Input
type="date"
value={value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : ""}
onChange={(e) => onChange(e.target.value)}
className="h-10 text-sm"
/>
)}
{col.type === "select" && (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-10 text-sm">
<SelectValue placeholder={col.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{col.selectOptions?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{col.type === "textarea" && (
<Textarea
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={col.placeholder}
className="text-sm min-h-[80px]"
/>
)}
{col.type === "component" && col.componentType && (
<div className="text-xs text-muted-foreground p-2 border rounded">
: {col.componentType} ( )
</div>
)}
</>
)}
</div>
);
}