886 lines
30 KiB
TypeScript
886 lines
30 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,
|
|
} 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;
|
|
}
|
|
|
|
export function RepeatScreenModalComponent({
|
|
component,
|
|
isDesignMode = false,
|
|
formData,
|
|
onFormDataChange,
|
|
config,
|
|
className,
|
|
...props
|
|
}: RepeatScreenModalComponentProps) {
|
|
const componentConfig = {
|
|
...config,
|
|
...component?.config,
|
|
};
|
|
|
|
// 설정 값 추출
|
|
const cardLayout = componentConfig?.cardLayout || [];
|
|
const dataSource = componentConfig?.dataSource;
|
|
const saveMode = componentConfig?.saveMode || "all";
|
|
const cardSpacing = componentConfig?.cardSpacing || "24px";
|
|
const showCardBorder = componentConfig?.showCardBorder ?? true;
|
|
const cardTitle = componentConfig?.cardTitle || "카드 {index}";
|
|
const cardMode = componentConfig?.cardMode || "simple";
|
|
const grouping = componentConfig?.grouping;
|
|
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 () => {
|
|
if (!dataSource || !dataSource.sourceTable) {
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setLoadError(null);
|
|
|
|
try {
|
|
// 필터 조건 생성
|
|
const filters: Record<string, any> = {};
|
|
|
|
if (dataSource.filterField && formData) {
|
|
const filterValue = formData[dataSource.filterField];
|
|
if (filterValue) {
|
|
// 배열이면 IN 조건, 아니면 단일 조건
|
|
if (Array.isArray(filterValue)) {
|
|
filters.id = filterValue;
|
|
} else {
|
|
filters.id = filterValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
const loadedData = response.data.data.data;
|
|
setRawData(loadedData);
|
|
|
|
// 모드에 따라 데이터 처리
|
|
if (cardMode === "withTable" && grouping?.enabled && grouping.groupByField) {
|
|
// 그룹핑 모드
|
|
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);
|
|
}
|
|
} else {
|
|
setLoadError("데이터를 불러오는데 실패했습니다.");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("데이터 로드 실패:", error);
|
|
setLoadError(error.message || "데이터 로드 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadInitialData();
|
|
}, [dataSource, formData, cardMode, grouping?.enabled, grouping?.groupByField]);
|
|
|
|
// 그룹화된 데이터 처리
|
|
const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => {
|
|
if (!groupingConfig?.enabled || !groupingConfig.groupByField) {
|
|
return [];
|
|
}
|
|
|
|
const groupByField = groupingConfig.groupByField;
|
|
const groupMap = new Map<string, any[]>();
|
|
|
|
// 그룹별로 데이터 분류
|
|
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> = {};
|
|
|
|
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) {
|
|
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">
|
|
{cardMode === "withTable" ? <TableIcon className="w-10 h-10 text-primary" /> : <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={cardMode === "withTable" ? "default" : "secondary"}>
|
|
{cardMode === "withTable" ? "테이블 모드" : "단순 모드"}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* 통계 정보 */}
|
|
<div className="flex gap-6 text-center">
|
|
{cardMode === "simple" ? (
|
|
<>
|
|
<div className="space-y-1">
|
|
<div className="text-2xl font-bold text-primary">{cardLayout.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">
|
|
{cardLayout.reduce((sum, row) => sum + row.columns.length, 0)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">컬럼 (Columns)</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="space-y-1">
|
|
<div className="text-2xl font-bold text-primary">{tableLayout?.headerRows?.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">{tableLayout?.tableColumns?.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">{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 ? "✓" : "○"}</div>
|
|
<div className="text-xs text-muted-foreground">데이터 소스</div>
|
|
</div>
|
|
</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>
|
|
)}
|
|
|
|
{/* 설정 안내 */}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// WithTable 모드 렌더링
|
|
if (cardMode === "withTable" && grouping?.enabled) {
|
|
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"
|
|
)}
|
|
>
|
|
<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">
|
|
{/* 헤더 영역 (그룹 대표값, 집계값) */}
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// Simple 모드 렌더링
|
|
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")}
|
|
>
|
|
<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">
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// 배경색 클래스 변환
|
|
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>
|
|
);
|
|
}
|