- Integrated `formatPgError` utility to provide user-friendly error messages based on PostgreSQL error codes during form data operations. - Updated error responses in `saveFormData`, `saveFormDataEnhanced`, `updateFormData`, and `updateFormDataPartial` to include specific messages based on the company context. - Improved error handling in the frontend components to display relevant error messages from the server response, ensuring users receive clear feedback on save operations. - Enhanced the required field validation by incorporating NOT NULL metadata checks across various components, improving the accuracy of form submissions. These changes improve the overall user experience by providing clearer error messages and ensuring that required fields are properly validated based on both manual settings and database constraints.
3198 lines
123 KiB
TypeScript
3198 lines
123 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 { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
|
|
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 userInputCode = newRowData[rowNumbering.targetColumn] as string;
|
|
const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData);
|
|
|
|
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(
|
|
"border-primary/50 from-primary/5 to-primary/10 h-full min-h-[400px] w-full rounded-lg border-2 border-dashed bg-gradient-to-br p-8",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex h-full flex-col items-center justify-center space-y-6">
|
|
{/* 아이콘 */}
|
|
<div className="bg-primary/10 flex h-20 w-20 items-center justify-center rounded-full">
|
|
<Layers className="text-primary h-10 w-10" />
|
|
</div>
|
|
|
|
{/* 제목 */}
|
|
<div className="space-y-2 text-center">
|
|
<div className="text-primary text-xl font-bold">Repeat Screen Modal</div>
|
|
<div className="text-foreground text-base font-semibold">반복 화면 모달</div>
|
|
<Badge variant="secondary">v3 자유 레이아웃</Badge>
|
|
</div>
|
|
|
|
{/* 행 구성 정보 */}
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
{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-primary text-2xl font-bold">{contentRows.length}</div>
|
|
<div className="text-muted-foreground text-xs">행 (Rows)</div>
|
|
</div>
|
|
<div className="bg-border w-px" />
|
|
<div className="space-y-1">
|
|
<div className="text-primary text-2xl font-bold">{grouping?.aggregations?.length || 0}</div>
|
|
<div className="text-muted-foreground text-xs">집계 설정</div>
|
|
</div>
|
|
<div className="bg-border w-px" />
|
|
<div className="space-y-1">
|
|
<div className="text-primary text-2xl font-bold">{dataSource?.sourceTable ? 1 : 0}</div>
|
|
<div className="text-muted-foreground text-xs">데이터 소스</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 데이터 소스 정보 */}
|
|
{dataSource?.sourceTable && (
|
|
<div className="rounded-md bg-green-100 px-3 py-2 text-xs dark:bg-green-900">
|
|
소스 테이블: <strong>{dataSource.sourceTable}</strong>
|
|
{dataSource.filterField && <span className="ml-2">(필터: {dataSource.filterField})</span>}
|
|
</div>
|
|
)}
|
|
|
|
{/* 그룹핑 정보 */}
|
|
{grouping?.enabled && (
|
|
<div className="rounded-md bg-purple-100 px-3 py-2 text-xs dark:bg-purple-900">
|
|
그룹핑: <strong>{grouping.groupByField}</strong>
|
|
</div>
|
|
)}
|
|
|
|
{/* 카드 제목 정보 */}
|
|
{showCardTitle && cardTitle && (
|
|
<div className="bg-muted rounded-md px-3 py-2 text-xs">
|
|
카드 제목: <strong>{cardTitle}</strong>
|
|
</div>
|
|
)}
|
|
|
|
{/* 설정 안내 */}
|
|
<div className="text-muted-foreground bg-muted/50 rounded-md border px-4 py-2 text-xs">
|
|
오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 로딩 상태
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center p-12">
|
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
|
<span className="text-muted-foreground ml-3 text-sm">데이터를 불러오는 중...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 오류 상태
|
|
if (loadError) {
|
|
return (
|
|
<div className="border-destructive/50 bg-destructive/5 rounded-lg border p-6">
|
|
<div className="text-destructive mb-2 flex items-center gap-2">
|
|
<X className="h-5 w-5" />
|
|
<span className="font-semibold">데이터 로드 실패</span>
|
|
</div>
|
|
<p className="text-muted-foreground text-sm">{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="min-w-[800px] space-y-4" 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="flex items-center justify-between text-lg">
|
|
<span>{getCardTitle(card._representativeData, cardIndex)}</span>
|
|
{card._rows.some((r) => r._isDirty) && (
|
|
<Badge variant="outline" className="text-primary text-xs">
|
|
수정됨
|
|
</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="overflow-hidden rounded-lg border">
|
|
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
|
|
{(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && (
|
|
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-2 text-sm font-medium">
|
|
<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 gap-1 text-xs"
|
|
>
|
|
<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-muted-foreground py-8 text-center"
|
|
>
|
|
데이터가 없습니다.
|
|
</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 && "text-muted-foreground line-through",
|
|
)}
|
|
>
|
|
{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:bg-blue-50 hover:text-blue-700"
|
|
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="text-muted-foreground hover:text-foreground hover:bg-muted h-7 w-7 p-0"
|
|
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="text-primary hover:text-primary hover:bg-primary/10 h-7 w-7 p-0"
|
|
title="삭제 취소"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleDeleteExternalRowRequest(
|
|
card._cardId,
|
|
row._rowId,
|
|
contentRow.id,
|
|
contentRow,
|
|
)
|
|
}
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10 h-7 w-7 p-0"
|
|
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="bg-muted/30 space-y-3 rounded-lg p-4">
|
|
{tableLayout.headerRows.map((row, rowIndex) => (
|
|
<div
|
|
key={row.id || `hrow-${rowIndex}`}
|
|
className={cn(
|
|
"grid gap-4",
|
|
row.layout === "vertical" ? "grid-cols-1" : "auto-cols-fr grid-flow-col",
|
|
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="overflow-hidden rounded-lg border">
|
|
<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" && "bg-background sticky bottom-0 border-t py-4",
|
|
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-muted-foreground py-12 text-center">표시할 데이터가 없습니다.</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="min-w-[600px] space-y-4" 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="flex items-center justify-between text-lg">
|
|
<span>{getCardTitle(card, cardIndex)}</span>
|
|
{card._isDirty && <span className="text-primary text-xs 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" : "auto-cols-fr grid-flow-col",
|
|
)}
|
|
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" && "bg-background sticky bottom-0 border-t py-4",
|
|
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-muted-foreground py-12 text-center">표시할 데이터가 없습니다.</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="bg-muted/30 text-muted-foreground rounded-lg p-4 text-sm">
|
|
헤더 컬럼이 설정되지 않았습니다.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"grid gap-4",
|
|
contentRow.layout === "vertical" ? "grid-cols-1" : "auto-cols-fr grid-flow-col",
|
|
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="rounded-lg bg-orange-50 p-4 text-sm text-orange-600 dark:bg-orange-950 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(
|
|
"min-w-[120px] flex-1 rounded-lg p-3",
|
|
aggField.backgroundColor ? getBackgroundClass(aggField.backgroundColor) : "bg-muted/50",
|
|
)}
|
|
>
|
|
<div className="text-muted-foreground text-xs">{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="rounded-lg bg-blue-50 p-4 text-sm text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
|
테이블 컬럼이 설정되지 않았습니다. (레이아웃 탭에서 테이블 컬럼을 추가하세요)
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border">
|
|
{contentRow.tableTitle && (
|
|
<div className="bg-muted/30 border-b px-4 py-2 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" : "auto-cols-fr grid-flow-col",
|
|
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="rounded-lg bg-orange-50 p-4 text-sm text-orange-600 dark:bg-orange-950 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(
|
|
"min-w-[120px] flex-1 rounded-lg p-3",
|
|
aggField.backgroundColor ? getBackgroundClass(aggField.backgroundColor) : "bg-muted/50",
|
|
)}
|
|
>
|
|
<div className="text-muted-foreground text-xs">{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="rounded-lg bg-blue-50 p-4 text-sm text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
|
테이블 컬럼이 설정되지 않았습니다.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border">
|
|
{contentRow.tableTitle && (
|
|
<div className="bg-muted/30 border-b px-4 py-2 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-muted-foreground text-xs font-medium">{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-muted-foreground ml-1 text-xs font-normal">({aggConfig.type})</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 일반 필드는 대표 데이터에서 가져옴
|
|
value = card._representativeData[col.field];
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label className="text-muted-foreground text-xs font-medium">{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-right text-sm"
|
|
/>
|
|
);
|
|
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, tableName?: string) {
|
|
const value = card[col.field];
|
|
const isReadOnly = !col.editable;
|
|
const effectiveRequired = col.required || isColumnRequiredByMeta(tableName, col.field);
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">
|
|
{col.label}{effectiveRequired && <span className="text-orange-500">*</span>}
|
|
</Label>
|
|
|
|
{isReadOnly && (
|
|
<div className="text-muted-foreground bg-muted flex min-h-[40px] items-center rounded-md px-3 py-2 text-sm">
|
|
{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="min-h-[80px] text-sm"
|
|
/>
|
|
)}
|
|
|
|
{col.type === "component" && col.componentType && (
|
|
<div className="text-muted-foreground rounded border p-2 text-xs">
|
|
컴포넌트: {col.componentType} (개발 중)
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|