3180 lines
121 KiB
TypeScript
3180 lines
121 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에서 전달받는 그룹 데이터
|
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지)
|
|
_initialData,
|
|
_originalData: _propsOriginalData,
|
|
_groupedData,
|
|
...props
|
|
}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
|
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
|
|
// DynamicComponentRenderer에서는 _groupedData로 전달됨
|
|
const groupedData = propsGroupedData || (props as any).groupedData || _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.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가
|
|
useEffect(() => {
|
|
const handleTriggerSave = async (event: Event) => {
|
|
if (!(event instanceof CustomEvent)) return;
|
|
|
|
console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신");
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
|
|
// 기존 데이터 저장
|
|
if (cardMode === "withTable") {
|
|
await saveGroupedData();
|
|
} else {
|
|
await saveSimpleData();
|
|
}
|
|
|
|
// 외부 테이블 데이터 저장
|
|
await saveExternalTableData();
|
|
|
|
// 연동 저장 처리 (syncSaves)
|
|
await processSyncSaves();
|
|
|
|
console.log("[RepeatScreenModal] 외부 트리거 저장 완료");
|
|
|
|
// 저장 완료 이벤트 발생
|
|
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
|
|
detail: { success: true }
|
|
}));
|
|
|
|
// 성공 콜백 실행
|
|
if (event.detail?.onSuccess) {
|
|
event.detail.onSuccess();
|
|
}
|
|
} catch (error: any) {
|
|
console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error);
|
|
|
|
// 저장 실패 이벤트 발생
|
|
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
|
|
detail: { success: false, error: error.message }
|
|
}));
|
|
|
|
// 실패 콜백 실행
|
|
if (event.detail?.onError) {
|
|
event.detail.onError(error);
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
|
|
return () => {
|
|
window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
|
|
};
|
|
}, [cardMode, groupedCardsData, externalTableData, contentRows]);
|
|
|
|
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
|
|
useEffect(() => {
|
|
const handleBeforeFormSave = (event: Event) => {
|
|
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
|
|
|
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
|
|
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
|
|
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
|
|
|
|
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
|
|
const saveDataByTable: Record<string, any[]> = {};
|
|
|
|
for (const [key, rows] of Object.entries(externalTableData)) {
|
|
// key 형식: cardId-contentRowId
|
|
const keyParts = key.split("-");
|
|
const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId
|
|
|
|
// contentRow 찾기
|
|
const contentRow = contentRows.find((r) => key.includes(r.id));
|
|
if (!contentRow?.tableDataSource?.enabled) continue;
|
|
|
|
// 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해)
|
|
const card = groupedCardsData.find((c) => c._cardId === cardId);
|
|
const representativeData = card?._representativeData || {};
|
|
|
|
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
|
|
|
|
// dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
|
|
// 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음)
|
|
const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted);
|
|
|
|
console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, {
|
|
totalRows: rows.length,
|
|
dirtyRows: dirtyRows.length,
|
|
rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted }))
|
|
});
|
|
|
|
if (dirtyRows.length === 0) continue;
|
|
|
|
// 저장할 필드만 추출
|
|
const editableFields = (contentRow.tableColumns || [])
|
|
.filter((col) => col.editable)
|
|
.map((col) => col.field);
|
|
|
|
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
|
|
const joinConditions = contentRow.tableDataSource.joinConditions || [];
|
|
const joinKeys = 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];
|
|
}
|
|
}
|
|
|
|
// 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기
|
|
// 예: sales_order_id (sourceKey) = card의 id (targetKey)
|
|
for (const joinCond of joinConditions) {
|
|
const { sourceKey, targetKey } = joinCond;
|
|
// sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴
|
|
if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) {
|
|
saveData[sourceKey] = representativeData[targetKey];
|
|
console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`);
|
|
}
|
|
}
|
|
|
|
// _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) {
|
|
let refValue = representativeData[condition.referenceKey];
|
|
if (refValue !== undefined && refValue !== null) {
|
|
// 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189)
|
|
// 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로
|
|
// 정확한 ID 매칭을 위해 숫자로 변환해야 함
|
|
if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') {
|
|
const numValue = Number(refValue);
|
|
if (!isNaN(numValue)) {
|
|
refValue = numValue;
|
|
}
|
|
}
|
|
filters[condition.sourceKey] = refValue;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(filters).length === 0) {
|
|
console.warn(`[RepeatScreenModal] 조인 조건이 없습니다: ${contentRow.id}`);
|
|
continue;
|
|
}
|
|
|
|
console.log(`[RepeatScreenModal] 외부 테이블 API 호출:`, {
|
|
sourceTable: dataSourceConfig.sourceTable,
|
|
filters,
|
|
joinConditions: dataSourceConfig.joinConditions,
|
|
representativeDataId: representativeData.id,
|
|
representativeDataIdType: typeof representativeData.id,
|
|
});
|
|
|
|
// 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 tableRowsWithExternalSource = contentRows.filter(
|
|
(row) => row.type === "table" && row.tableDataSource?.enabled
|
|
);
|
|
|
|
if (tableRowsWithExternalSource.length === 0) return;
|
|
|
|
// 각 카드의 집계 재계산
|
|
const updatedCards = groupedCardsData.map((card) => {
|
|
// 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
|
|
const externalRowsByTableId: Record<string, any[]> = {};
|
|
const allExternalRows: any[] = [];
|
|
|
|
for (const tableRow of tableRowsWithExternalSource) {
|
|
const key = `${card._cardId}-${tableRow.id}`;
|
|
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
|
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
|
|
externalRowsByTableId[tableRow.id] = rows;
|
|
allExternalRows.push(...rows);
|
|
}
|
|
|
|
// 집계 재계산
|
|
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(
|
|
allExternalRows,
|
|
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) {
|
|
// 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용
|
|
let filteredExternalRows: any[];
|
|
|
|
if (agg.externalTableRefs && agg.externalTableRefs.length > 0) {
|
|
// 특정 테이블만 참조
|
|
filteredExternalRows = [];
|
|
for (const tableId of agg.externalTableRefs) {
|
|
if (externalRowsByTableId[tableId]) {
|
|
filteredExternalRows.push(...externalRowsByTableId[tableId]);
|
|
}
|
|
}
|
|
} else {
|
|
// 모든 외부 테이블 데이터 사용 (기존 동작)
|
|
filteredExternalRows = allExternalRows;
|
|
}
|
|
|
|
// 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산
|
|
newAggregations[agg.resultField] = evaluateFormulaWithContext(
|
|
agg.formula,
|
|
card._representativeData,
|
|
card._rows,
|
|
filteredExternalRows,
|
|
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: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
|
|
const handleAddExternalRow = async (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;
|
|
}
|
|
}
|
|
|
|
// 🆕 v3.13: 자동 채번 처리
|
|
const rowNumbering = contentRow.tableCrud?.rowNumbering;
|
|
console.log("[RepeatScreenModal] 채번 설정 확인:", {
|
|
tableCrud: contentRow.tableCrud,
|
|
rowNumbering,
|
|
enabled: rowNumbering?.enabled,
|
|
targetColumn: rowNumbering?.targetColumn,
|
|
numberingRuleId: rowNumbering?.numberingRuleId,
|
|
});
|
|
if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) {
|
|
try {
|
|
console.log("[RepeatScreenModal] 자동 채번 시작:", {
|
|
targetColumn: rowNumbering.targetColumn,
|
|
numberingRuleId: rowNumbering.numberingRuleId,
|
|
});
|
|
|
|
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
|
|
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
|
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
|
|
|
|
if (response.success && response.data) {
|
|
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;
|
|
|
|
console.log("[RepeatScreenModal] 자동 채번 완료:", {
|
|
column: rowNumbering.targetColumn,
|
|
generatedCode: response.data.generatedCode,
|
|
});
|
|
} else {
|
|
console.warn("[RepeatScreenModal] 채번 실패:", response);
|
|
}
|
|
} catch (error) {
|
|
console.error("[RepeatScreenModal] 채번 API 호출 실패:", error);
|
|
}
|
|
}
|
|
|
|
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.14: 외부 테이블 행 삭제 실행 (즉시 DELETE API 호출)
|
|
const handleDeleteExternalRow = async (cardId: string, rowId: string, contentRowId: string) => {
|
|
const key = `${cardId}-${contentRowId}`;
|
|
const rows = externalTableData[key] || [];
|
|
const targetRow = rows.find((row) => row._rowId === rowId);
|
|
|
|
// 기존 DB 데이터인 경우 (id가 있는 경우) 즉시 삭제
|
|
if (targetRow?._originalData?.id) {
|
|
try {
|
|
const contentRow = contentRows.find((r) => r.id === contentRowId);
|
|
const targetTable = contentRow?.tableCrud?.targetTable || contentRow?.tableDataSource?.sourceTable;
|
|
|
|
if (!targetTable) {
|
|
console.error("[RepeatScreenModal] 삭제 대상 테이블을 찾을 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`);
|
|
|
|
// 백엔드는 배열 형태의 데이터를 기대함
|
|
await apiClient.request({
|
|
method: "DELETE",
|
|
url: `/table-management/tables/${targetTable}/delete`,
|
|
data: [{ id: targetRow._originalData.id }],
|
|
});
|
|
|
|
console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`);
|
|
|
|
// 성공 시 UI에서 완전히 제거
|
|
setExternalTableData((prev) => {
|
|
const newData = {
|
|
...prev,
|
|
[key]: prev[key].filter((row) => row._rowId !== rowId),
|
|
};
|
|
|
|
// 행 삭제 시 집계 재계산
|
|
setTimeout(() => {
|
|
recalculateAggregationsWithExternalData(newData);
|
|
}, 0);
|
|
|
|
return newData;
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message);
|
|
// 에러 시에도 다이얼로그 닫기
|
|
}
|
|
} else {
|
|
// 새로 추가된 행 (아직 DB에 없음) - UI에서만 제거
|
|
console.log(`[RepeatScreenModal] 새 행 삭제 (DB 없음): rowId=${rowId}`);
|
|
setExternalTableData((prev) => {
|
|
const newData = {
|
|
...prev,
|
|
[key]: prev[key].filter((row) => row._rowId !== rowId),
|
|
};
|
|
|
|
// 행 삭제 시 집계 재계산
|
|
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) {
|
|
console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`);
|
|
return "0";
|
|
}
|
|
const values = externalRows.map((row) => Number(row[fieldName]) || 0);
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`);
|
|
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();
|
|
|
|
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
|
|
await processSyncSaves();
|
|
|
|
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.12: 연동 저장 처리 (syncSaves)
|
|
const processSyncSaves = async () => {
|
|
const syncPromises: Promise<void>[] = [];
|
|
|
|
// contentRows에서 syncSaves가 설정된 테이블 행 찾기
|
|
for (const contentRow of contentRows) {
|
|
if (contentRow.type !== "table") continue;
|
|
if (!contentRow.tableCrud?.syncSaves?.length) continue;
|
|
|
|
const sourceTable = contentRow.tableDataSource?.sourceTable;
|
|
if (!sourceTable) continue;
|
|
|
|
// 이 테이블 행의 모든 카드 데이터 수집
|
|
for (const card of groupedCardsData) {
|
|
const key = `${card._cardId}-${contentRow.id}`;
|
|
const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted);
|
|
|
|
// 각 syncSave 설정 처리
|
|
for (const syncSave of contentRow.tableCrud.syncSaves) {
|
|
if (!syncSave.enabled) continue;
|
|
if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue;
|
|
|
|
// 조인 키 값 수집 (중복 제거)
|
|
const joinKeyValues = new Set<string | number>();
|
|
for (const row of rows) {
|
|
const keyValue = row[syncSave.joinKey.sourceField];
|
|
if (keyValue !== undefined && keyValue !== null) {
|
|
joinKeyValues.add(keyValue);
|
|
}
|
|
}
|
|
|
|
// 각 조인 키별로 집계 계산 및 업데이트
|
|
for (const keyValue of joinKeyValues) {
|
|
// 해당 조인 키에 해당하는 행들만 필터링
|
|
const filteredRows = rows.filter(
|
|
(row) => row[syncSave.joinKey.sourceField] === keyValue
|
|
);
|
|
|
|
// 집계 계산
|
|
let aggregatedValue: number = 0;
|
|
const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0);
|
|
|
|
switch (syncSave.aggregationType) {
|
|
case "sum":
|
|
aggregatedValue = values.reduce((a, b) => a + b, 0);
|
|
break;
|
|
case "count":
|
|
aggregatedValue = values.length;
|
|
break;
|
|
case "avg":
|
|
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
|
break;
|
|
case "min":
|
|
aggregatedValue = values.length > 0 ? Math.min(...values) : 0;
|
|
break;
|
|
case "max":
|
|
aggregatedValue = values.length > 0 ? Math.max(...values) : 0;
|
|
break;
|
|
case "latest":
|
|
aggregatedValue = values.length > 0 ? values[values.length - 1] : 0;
|
|
break;
|
|
}
|
|
|
|
console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, {
|
|
joinKey: keyValue,
|
|
aggregationType: syncSave.aggregationType,
|
|
values,
|
|
aggregatedValue,
|
|
});
|
|
|
|
// 대상 테이블 업데이트
|
|
syncPromises.push(
|
|
apiClient
|
|
.put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, {
|
|
[syncSave.targetColumn]: aggregatedValue,
|
|
})
|
|
.then(() => {
|
|
console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`);
|
|
})
|
|
.catch((err) => {
|
|
console.error(`[SyncSave] 업데이트 실패:`, err);
|
|
throw err;
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (syncPromises.length > 0) {
|
|
console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`);
|
|
await Promise.all(syncPromises);
|
|
console.log(`[SyncSave] 연동 저장 완료`);
|
|
}
|
|
};
|
|
|
|
// 🆕 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?.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">
|
|
{/* 추가 버튼 */}
|
|
{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">
|
|
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
|
{(contentRow.tableColumns || []).filter(col => !col.hidden).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?.filter(col => !col.hidden)?.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"
|
|
)}
|
|
>
|
|
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
|
{(contentRow.tableColumns || []).filter(col => !col.hidden).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>
|
|
);
|
|
}
|