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

1410 lines
49 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 } from "lucide-react";
import {
RepeatScreenModalProps,
CardData,
CardColumnConfig,
GroupedCardData,
CardRowData,
AggregationConfig,
TableColumnConfig,
CardContentRowConfig,
AggregationDisplayConfig,
} from "./types";
import { ComponentRendererProps } from "@/types/component";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
export interface RepeatScreenModalComponentProps extends ComponentRendererProps {
config?: RepeatScreenModalProps;
groupedData?: Record<string, any>[]; // EditModal에서 전달하는 그룹 데이터
}
export function RepeatScreenModalComponent({
component,
isDesignMode = false,
formData,
onFormDataChange,
config,
className,
groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터
...props
}: RepeatScreenModalComponentProps) {
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
const groupedData = propsGroupedData || (props as any).groupedData;
const componentConfig = {
...config,
...component?.config,
};
// 설정 값 추출
const dataSource = componentConfig?.dataSource;
const saveMode = componentConfig?.saveMode || "all";
const cardSpacing = componentConfig?.cardSpacing || "24px";
const showCardBorder = componentConfig?.showCardBorder ?? true;
const showCardTitle = componentConfig?.showCardTitle ?? true;
const cardTitle = componentConfig?.cardTitle || "카드 {index}";
const grouping = componentConfig?.grouping;
// 🆕 v3: 자유 레이아웃
const contentRows = componentConfig?.contentRows || [];
// (레거시 호환)
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);
// 초기 데이터 로드
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]);
// 그룹화된 데이터 처리
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 aggregations: Record<string, number> = {};
if (groupingConfig.aggregations) {
groupingConfig.aggregations.forEach((agg) => {
aggregations[agg.resultField] = calculateAggregation(rows, agg);
});
}
// 행 데이터 생성
const cardRows: CardRowData[] = rows.map((row, idx) => ({
_rowId: `row-${cardIndex}-${idx}-${Date.now()}`,
_originalData: { ...row },
_isDirty: false,
...row,
}));
result.push({
_cardId: `grouped-card-${cardIndex}-${Date.now()}`,
_groupKey: groupKey,
_groupField: groupByField || "",
_aggregations: aggregations,
_rows: cardRows,
_representativeData: rows[0] || {},
});
cardIndex++;
});
return result;
};
// 집계 계산
const calculateAggregation = (rows: any[], agg: AggregationConfig): number => {
const values = rows.map((row) => Number(row[agg.sourceField]) || 0);
switch (agg.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;
}
};
// 카드 데이터 로드 (소스 설정에 따라)
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();
}
alert("저장되었습니다.");
} catch (error: any) {
console.error("저장 실패:", error);
alert(`저장 중 오류가 발생했습니다: ${error.message}`);
} finally {
setIsSaving(false);
}
};
// 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(() => {
if (cardMode === "withTable") {
return groupedCardsData.some((card) => card._rows.some((row) => row._isDirty));
}
return cardsData.some((c) => c._isDirty);
}, [cardMode, cardsData, groupedCardsData]);
// 디자인 모드 렌더링
if (isDesignMode) {
// 행 타입별 개수 계산
const rowTypeCounts = {
header: contentRows.filter((r) => r.type === "header").length,
aggregation: contentRows.filter((r) => r.type === "aggregation").length,
table: contentRows.filter((r) => r.type === "table").length,
fields: contentRows.filter((r) => r.type === "fields").length,
};
return (
<div
className={cn(
"w-full h-full min-h-[400px] border-2 border-dashed border-primary/50 rounded-lg p-8 bg-gradient-to-br from-primary/5 to-primary/10",
className
)}
>
<div className="flex flex-col items-center justify-center h-full space-y-6">
{/* 아이콘 */}
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center">
<Layers className="w-10 h-10 text-primary" />
</div>
{/* 제목 */}
<div className="text-center space-y-2">
<div className="text-xl font-bold text-primary">Repeat Screen Modal</div>
<div className="text-base font-semibold text-foreground"> </div>
<Badge variant="secondary">v3 </Badge>
</div>
{/* 행 구성 정보 */}
<div className="flex flex-wrap gap-2 justify-center">
{contentRows.length > 0 ? (
<>
{rowTypeCounts.header > 0 && (
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
{rowTypeCounts.header}
</Badge>
)}
{rowTypeCounts.aggregation > 0 && (
<Badge className="bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300">
{rowTypeCounts.aggregation}
</Badge>
)}
{rowTypeCounts.table > 0 && (
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{rowTypeCounts.table}
</Badge>
)}
{rowTypeCounts.fields > 0 && (
<Badge className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">
{rowTypeCounts.fields}
</Badge>
)}
</>
) : (
<Badge variant="outline"> </Badge>
)}
</div>
{/* 통계 정보 */}
<div className="flex gap-6 text-center">
<div className="space-y-1">
<div className="text-2xl font-bold text-primary">{contentRows.length}</div>
<div className="text-xs text-muted-foreground"> (Rows)</div>
</div>
<div className="w-px bg-border" />
<div className="space-y-1">
<div className="text-2xl font-bold text-primary">{grouping?.aggregations?.length || 0}</div>
<div className="text-xs text-muted-foreground"> </div>
</div>
<div className="w-px bg-border" />
<div className="space-y-1">
<div className="text-2xl font-bold text-primary">{dataSource?.sourceTable ? 1 : 0}</div>
<div className="text-xs text-muted-foreground"> </div>
</div>
</div>
{/* 데이터 소스 정보 */}
{dataSource?.sourceTable && (
<div className="text-xs bg-green-100 dark:bg-green-900 px-3 py-2 rounded-md">
: <strong>{dataSource.sourceTable}</strong>
{dataSource.filterField && <span className="ml-2">(: {dataSource.filterField})</span>}
</div>
)}
{/* 그룹핑 정보 */}
{grouping?.enabled && (
<div className="text-xs bg-purple-100 dark:bg-purple-900 px-3 py-2 rounded-md">
: <strong>{grouping.groupByField}</strong>
</div>
)}
{/* 카드 제목 정보 */}
{showCardTitle && cardTitle && (
<div className="text-xs bg-muted px-3 py-2 rounded-md">
: <strong>{cardTitle}</strong>
</div>
)}
{/* 설정 안내 */}
<div className="text-xs text-muted-foreground bg-muted/50 px-4 py-2 rounded-md border">
(///)
</div>
</div>
</div>
);
}
// 로딩 상태
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-sm text-muted-foreground"> ...</span>
</div>
);
}
// 오류 상태
if (loadError) {
return (
<div className="p-6 border border-destructive/50 rounded-lg bg-destructive/5">
<div className="flex items-center gap-2 text-destructive mb-2">
<X className="h-5 w-5" />
<span className="font-semibold"> </span>
</div>
<p className="text-sm text-muted-foreground">{loadError}</p>
</div>
);
}
// 🆕 v3: 자유 레이아웃 렌더링 (contentRows 사용)
const useNewLayout = contentRows && contentRows.length > 0;
const useGrouping = grouping?.enabled;
// 그룹핑 모드 렌더링
if (useGrouping) {
return (
<div className={cn("space-y-6 overflow-x-auto", className)}>
<div className="space-y-4 min-w-[800px]" style={{ gap: cardSpacing }}>
{groupedCardsData.map((card, cardIndex) => (
<Card
key={card._cardId}
className={cn(
"transition-shadow",
showCardBorder && "border-2",
card._rows.some((r) => r._isDirty) && "border-primary shadow-lg"
)}
>
{/* 카드 제목 (선택사항) */}
{showCardTitle && (
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<span>{getCardTitle(card._representativeData, cardIndex)}</span>
{card._rows.some((r) => r._isDirty) && (
<Badge variant="outline" className="text-xs text-primary">
</Badge>
)}
</CardTitle>
</CardHeader>
)}
<CardContent className="space-y-4">
{/* 🆕 v3: contentRows 기반 렌더링 */}
{useNewLayout ? (
contentRows.map((contentRow, rowIndex) => (
<div key={contentRow.id || `crow-${rowIndex}`}>
{renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange)}
</div>
))
) : (
// 레거시: tableLayout 사용
<>
{tableLayout?.headerRows && tableLayout.headerRows.length > 0 && (
<div className="space-y-3 p-4 bg-muted/30 rounded-lg">
{tableLayout.headerRows.map((row, rowIndex) => (
<div
key={row.id || `hrow-${rowIndex}`}
className={cn(
"grid gap-4",
row.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr",
row.backgroundColor && getBackgroundClass(row.backgroundColor),
row.rounded && "rounded-lg",
row.padding && `p-${row.padding}`
)}
style={{ gap: row.gap || "16px" }}
>
{row.columns.map((col, colIndex) => (
<div key={col.id || `hcol-${colIndex}`} style={{ width: col.width }}>
{renderHeaderColumn(col, card, grouping?.aggregations || [])}
</div>
))}
</div>
))}
</div>
)}
{tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
{tableLayout.tableColumns.map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
className={cn("text-xs", col.align && `text-${col.align}`)}
>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{card._rows.map((row) => (
<TableRow key={row._rowId} className={cn(row._isDirty && "bg-primary/5")}>
{tableLayout.tableColumns.map((col) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn("text-sm", col.align && `text-${col.align}`)}
>
{renderTableCell(col, row, (value) =>
handleRowDataChange(card._cardId, row._rowId, col.field, value)
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</>
)}
</CardContent>
</Card>
))}
</div>
{/* 저장 버튼 */}
{groupedCardsData.length > 0 && (
<div className="flex justify-end gap-2">
<Button onClick={handleSaveAll} disabled={isSaving || !hasDirtyData} className="gap-2">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</div>
)}
{/* 데이터 없음 */}
{groupedCardsData.length === 0 && !isLoading && (
<div className="text-center py-12 text-muted-foreground"> .</div>
)}
</div>
);
}
// 단순 모드 렌더링 (그룹핑 없음)
return (
<div className={cn("space-y-6 overflow-x-auto", className)}>
<div className="space-y-4 min-w-[600px]" style={{ gap: cardSpacing }}>
{cardsData.map((card, cardIndex) => (
<Card
key={card._cardId}
className={cn("transition-shadow", showCardBorder && "border-2", card._isDirty && "border-primary shadow-lg")}
>
{/* 카드 제목 (선택사항) */}
{showCardTitle && (
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<span>{getCardTitle(card, cardIndex)}</span>
{card._isDirty && <span className="text-xs text-primary font-normal">()</span>}
</CardTitle>
</CardHeader>
)}
<CardContent className="space-y-4">
{/* 🆕 v3: contentRows 기반 렌더링 */}
{useNewLayout ? (
contentRows.map((contentRow, rowIndex) => (
<div key={contentRow.id || `crow-${rowIndex}`}>
{renderSimpleContentRow(contentRow, card, (value, field) =>
handleCardDataChange(card._cardId, field, value)
)}
</div>
))
) : (
// 레거시: cardLayout 사용
cardLayout.map((row, rowIndex) => (
<div
key={row.id || `row-${rowIndex}`}
className={cn(
"grid gap-4",
row.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr"
)}
style={{ gap: row.gap || "16px" }}
>
{row.columns.map((col, colIndex) => (
<div key={col.id || `col-${colIndex}`} style={{ width: col.width }}>
{renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))}
</div>
))}
</div>
))
)}
</CardContent>
</Card>
))}
</div>
{/* 저장 버튼 */}
{cardsData.length > 0 && (
<div className="flex justify-end gap-2">
<Button onClick={handleSaveAll} disabled={isSaving || !hasDirtyData} className="gap-2">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saveMode === "all" ? "전체 저장" : "저장"}
</Button>
</div>
)}
{/* 데이터 없음 */}
{cardsData.length === 0 && !isLoading && (
<div className="text-center py-12 text-muted-foreground"> .</div>
)}
</div>
);
}
// 🆕 v3: contentRow 렌더링 (그룹핑 모드)
function renderContentRow(
contentRow: CardContentRowConfig,
card: GroupedCardData,
aggregations: AggregationConfig[],
onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void
) {
switch (contentRow.type) {
case "header":
case "fields":
// contentRow에서 직접 columns 가져오기 (v3 구조)
const headerColumns = contentRow.columns || [];
if (headerColumns.length === 0) {
return (
<div className="p-4 bg-muted/30 rounded-lg text-sm text-muted-foreground">
.
</div>
);
}
return (
<div
className={cn(
"grid gap-4",
contentRow.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr",
contentRow.backgroundColor && getBackgroundClass(contentRow.backgroundColor),
contentRow.padding && `p-${contentRow.padding}`,
"rounded-lg"
)}
style={{ gap: contentRow.gap || "16px" }}
>
{headerColumns.map((col, colIndex) => (
<div key={col.id || `col-${colIndex}`} style={{ width: col.width }}>
{renderHeaderColumn(col, card, aggregations)}
</div>
))}
</div>
);
case "aggregation":
// contentRow에서 직접 aggregationFields 가져오기 (v3 구조)
const aggFields = contentRow.aggregationFields || [];
if (aggFields.length === 0) {
return (
<div className="p-4 bg-orange-50 dark:bg-orange-950 rounded-lg text-sm text-orange-600 dark:text-orange-400">
. ( )
</div>
);
}
return (
<div
className={cn(
contentRow.aggregationLayout === "vertical"
? "flex flex-col gap-3"
: "flex flex-wrap gap-4"
)}
>
{aggFields.map((aggField, fieldIndex) => {
// 집계 결과에서 값 가져오기 (aggregationResultField 사용)
const value = card._aggregations?.[aggField.aggregationResultField] || 0;
return (
<div
key={fieldIndex}
className={cn(
"p-3 rounded-lg flex-1 min-w-[120px]",
aggField.backgroundColor ? getBackgroundClass(aggField.backgroundColor) : "bg-muted/50"
)}
>
<div className="text-xs text-muted-foreground">{aggField.label || aggField.aggregationResultField}</div>
<div
className={cn(
"font-bold",
aggField.fontSize ? `text-${aggField.fontSize}` : "text-lg",
aggField.textColor && `text-${aggField.textColor}`
)}
>
{typeof value === "number" ? value.toLocaleString() : value || "-"}
</div>
</div>
);
})}
</div>
);
case "table":
// contentRow에서 직접 tableColumns 가져오기 (v3 구조)
const tableColumns = contentRow.tableColumns || [];
if (tableColumns.length === 0) {
return (
<div className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg text-sm text-blue-600 dark:text-blue-400">
. ( )
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
{contentRow.tableTitle && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium">
{contentRow.tableTitle}
</div>
)}
<Table>
{contentRow.showTableHeader !== false && (
<TableHeader>
<TableRow className="bg-muted/50">
{tableColumns.map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
className={cn("text-xs", col.align && `text-${col.align}`)}
>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{card._rows.map((row) => (
<TableRow key={row._rowId} className={cn(row._isDirty && "bg-primary/5")}>
{tableColumns.map((col) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn("text-sm", col.align && `text-${col.align}`)}
>
{renderTableCell(col, row, (value) =>
onRowDataChange(card._cardId, row._rowId, col.field, value)
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
default:
return null;
}
}
// 🆕 v3: contentRow 렌더링 (단순 모드)
function renderSimpleContentRow(
contentRow: CardContentRowConfig,
card: CardData,
onChange: (value: any, field: string) => void
) {
switch (contentRow.type) {
case "header":
case "fields":
return (
<div
className={cn(
"grid gap-4",
contentRow.layout === "vertical" ? "grid-cols-1" : "grid-flow-col auto-cols-fr",
contentRow.backgroundColor && getBackgroundClass(contentRow.backgroundColor),
contentRow.padding && `p-${contentRow.padding}`,
"rounded-lg"
)}
style={{ gap: contentRow.gap || "16px" }}
>
{(contentRow.columns || []).map((col, colIndex) => (
<div key={col.id || `col-${colIndex}`} style={{ width: col.width }}>
{renderColumn(col, card, (value) => onChange(value, col.field))}
</div>
))}
</div>
);
case "aggregation":
// 단순 모드에서도 집계 표시 (단일 카드 기준)
// contentRow에서 직접 aggregationFields 가져오기 (v3 구조)
const aggFields = contentRow.aggregationFields || [];
if (aggFields.length === 0) {
return (
<div className="p-4 bg-orange-50 dark:bg-orange-950 rounded-lg text-sm text-orange-600 dark:text-orange-400">
.
</div>
);
}
return (
<div
className={cn(
contentRow.aggregationLayout === "vertical"
? "flex flex-col gap-3"
: "flex flex-wrap gap-4"
)}
>
{aggFields.map((aggField, fieldIndex) => {
// 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용)
const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField];
return (
<div
key={fieldIndex}
className={cn(
"p-3 rounded-lg flex-1 min-w-[120px]",
aggField.backgroundColor ? getBackgroundClass(aggField.backgroundColor) : "bg-muted/50"
)}
>
<div className="text-xs text-muted-foreground">{aggField.label || aggField.aggregationResultField}</div>
<div
className={cn(
"font-bold",
aggField.fontSize ? `text-${aggField.fontSize}` : "text-lg",
aggField.textColor && `text-${aggField.textColor}`
)}
>
{typeof value === "number" ? value.toLocaleString() : value || "-"}
</div>
</div>
);
})}
</div>
);
case "table":
// 단순 모드에서도 테이블 표시 (단일 행)
// contentRow에서 직접 tableColumns 가져오기 (v3 구조)
const tableColumns = contentRow.tableColumns || [];
if (tableColumns.length === 0) {
return (
<div className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg text-sm text-blue-600 dark:text-blue-400">
.
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
{contentRow.tableTitle && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium">
{contentRow.tableTitle}
</div>
)}
<Table>
{contentRow.showTableHeader !== false && (
<TableHeader>
<TableRow className="bg-muted/50">
{tableColumns.map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
className={cn("text-xs", col.align && `text-${col.align}`)}
>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{/* 단순 모드: 카드 자체가 하나의 행 */}
<TableRow className={cn(card._isDirty && "bg-primary/5")}>
{tableColumns.map((col) => (
<TableCell
key={`${card._cardId}-${col.id}`}
className={cn("text-sm", col.align && `text-${col.align}`)}
>
{renderSimpleTableCell(col, card, (value) => onChange(value, col.field))}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
);
default:
return null;
}
}
// 단순 모드 테이블 셀 렌더링
function renderSimpleTableCell(
col: TableColumnConfig,
card: CardData,
onChange: (value: any) => void
) {
const value = card[col.field] || card._originalData?.[col.field];
if (!col.editable) {
// 읽기 전용
if (col.type === "number") {
return typeof value === "number" ? value.toLocaleString() : value || "-";
}
return value || "-";
}
// 편집 가능
switch (col.type) {
case "number":
return (
<Input
type="number"
value={value || ""}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
className="h-8 text-sm"
/>
);
case "date":
return (
<Input
type="date"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
case "select":
return (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{(col.selectOptions || []).map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default:
return (
<Input
type="text"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
}
}
// 배경색 클래스 변환
function getBackgroundClass(color: string): string {
const colorMap: Record<string, string> = {
blue: "bg-blue-50 dark:bg-blue-950",
green: "bg-green-50 dark:bg-green-950",
purple: "bg-purple-50 dark:bg-purple-950",
orange: "bg-orange-50 dark:bg-orange-950",
};
return colorMap[color] || "";
}
// 헤더 컬럼 렌더링 (집계값 포함)
function renderHeaderColumn(
col: CardColumnConfig,
card: GroupedCardData,
aggregations: AggregationConfig[]
) {
let value: any;
// 집계값 타입이면 집계 결과에서 가져옴
if (col.type === "aggregation" && col.aggregationField) {
value = card._aggregations[col.aggregationField];
const aggConfig = aggregations.find((a) => a.resultField === col.aggregationField);
return (
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">{col.label}</Label>
<div
className={cn(
"text-lg font-bold",
col.textColor && `text-${col.textColor}`,
col.fontSize && `text-${col.fontSize}`
)}
>
{typeof value === "number" ? value.toLocaleString() : value || "-"}
{aggConfig && <span className="text-xs font-normal text-muted-foreground ml-1">({aggConfig.type})</span>}
</div>
</div>
);
}
// 일반 필드는 대표 데이터에서 가져옴
value = card._representativeData[col.field];
return (
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">{col.label}</Label>
<div
className={cn(
"text-sm",
col.fontWeight && `font-${col.fontWeight}`,
col.fontSize && `text-${col.fontSize}`
)}
>
{value || "-"}
</div>
</div>
);
}
// 테이블 셀 렌더링
function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void) {
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>;
}
// 읽기 전용
if (!col.editable) {
if (col.type === "number") {
return <span>{typeof value === "number" ? value.toLocaleString() : value || "-"}</span>;
}
return <span>{value || "-"}</span>;
}
// 편집 가능
switch (col.type) {
case "text":
return (
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
case "number":
return (
<Input
type="number"
value={value || ""}
onChange={(e) => onChange(Number(e.target.value) || 0)}
className="h-8 text-sm text-right"
/>
);
case "date":
return (
<Input
type="date"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
);
default:
return <span>{value || "-"}</span>;
}
}
// 컬럼 렌더링 함수 (Simple 모드)
function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) {
const value = card[col.field];
const isReadOnly = !col.editable;
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium">
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</Label>
{isReadOnly && (
<div className="text-sm text-muted-foreground bg-muted px-3 py-2 rounded-md min-h-[40px] flex items-center">
{value || "-"}
</div>
)}
{!isReadOnly && (
<>
{col.type === "text" && (
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={col.placeholder}
className="h-10 text-sm"
/>
)}
{col.type === "number" && (
<Input
type="number"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={col.placeholder}
className="h-10 text-sm"
/>
)}
{col.type === "date" && (
<Input
type="date"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="h-10 text-sm"
/>
)}
{col.type === "select" && (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-10 text-sm">
<SelectValue placeholder={col.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{col.selectOptions?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{col.type === "textarea" && (
<Textarea
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={col.placeholder}
className="text-sm min-h-[80px]"
/>
)}
{col.type === "component" && col.componentType && (
<div className="text-xs text-muted-foreground p-2 border rounded">
: {col.componentType} ( )
</div>
)}
</>
)}
</div>
);
}