"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([]); // 원본 데이터 const [cardsData, setCardsData] = useState([]); // simple 모드용 const [groupedCardsData, setGroupedCardsData] = useState([]); // withTable 모드용 const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [isSaving, setIsSaving] = useState(false); // 초기 데이터 로드 useEffect(() => { const loadInitialData = async () => { if (!dataSource || !dataSource.sourceTable) { return; } setIsLoading(true); setLoadError(null); try { // 필터 조건 생성 const filters: Record = {}; 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(); // 그룹별로 데이터 분류 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 = {}; 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> => { const cardData: Record = {}; 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 = {}; if (grouping?.aggregations) { grouping.aggregations.forEach((agg) => { newAggregations[agg.resultField] = calculateAggregation(updatedRows, agg); }); } return { ...card, _rows: updatedRows, _aggregations: newAggregations, }; }) ); }; // 카드 제목 생성 const getCardTitle = (data: Record, 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 = {}; 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 = {}; 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) => { 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 (
{/* 아이콘 */}
{cardMode === "withTable" ? : }
{/* 제목 */}
Repeat Screen Modal
반복 화면 모달
{cardMode === "withTable" ? "테이블 모드" : "단순 모드"}
{/* 통계 정보 */}
{cardMode === "simple" ? ( <>
{cardLayout.length}
행 (Rows)
{cardLayout.reduce((sum, row) => sum + row.columns.length, 0)}
컬럼 (Columns)
) : ( <>
{tableLayout?.headerRows?.length || 0}
헤더 행
{tableLayout?.tableColumns?.length || 0}
테이블 컬럼
{grouping?.aggregations?.length || 0}
집계
)}
{dataSource?.sourceTable ? "✓" : "○"}
데이터 소스
{/* 그룹핑 정보 */} {grouping?.enabled && (
그룹핑: {grouping.groupByField}
)} {/* 설정 안내 */}
오른쪽 패널에서 카드 레이아웃과 데이터 소스를 설정하세요
); } // 로딩 상태 if (isLoading) { return (
데이터를 불러오는 중...
); } // 오류 상태 if (loadError) { return (
데이터 로드 실패

{loadError}

); } // WithTable 모드 렌더링 if (cardMode === "withTable" && grouping?.enabled) { return (
{groupedCardsData.map((card, cardIndex) => ( r._isDirty) && "border-primary shadow-lg" )} > {getCardTitle(card._representativeData, cardIndex)} {card._rows.some((r) => r._isDirty) && ( 수정됨 )} {/* 헤더 영역 (그룹 대표값, 집계값) */} {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && (
{tableLayout.headerRows.map((row, rowIndex) => (
{row.columns.map((col, colIndex) => (
{renderHeaderColumn(col, card, grouping?.aggregations || [])}
))}
))}
)} {/* 테이블 영역 */} {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && (
{tableLayout.tableColumns.map((col) => ( {col.label} ))} {card._rows.map((row) => ( {tableLayout.tableColumns.map((col) => ( {renderTableCell(col, row, (value) => handleRowDataChange(card._cardId, row._rowId, col.field, value) )} ))} ))}
)}
))}
{/* 저장 버튼 */} {groupedCardsData.length > 0 && (
)} {/* 데이터 없음 */} {groupedCardsData.length === 0 && !isLoading && (
표시할 데이터가 없습니다.
)}
); } // Simple 모드 렌더링 return (
{cardsData.map((card, cardIndex) => ( {getCardTitle(card, cardIndex)} {card._isDirty && (수정됨)} {cardLayout.map((row, rowIndex) => (
{row.columns.map((col, colIndex) => (
{renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))}
))}
))}
))}
{/* 저장 버튼 */} {cardsData.length > 0 && (
)} {/* 데이터 없음 */} {cardsData.length === 0 && !isLoading && (
표시할 데이터가 없습니다.
)}
); } // 배경색 클래스 변환 function getBackgroundClass(color: string): string { const colorMap: Record = { 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 (
{typeof value === "number" ? value.toLocaleString() : value || "-"} {aggConfig && ({aggConfig.type})}
); } // 일반 필드는 대표 데이터에서 가져옴 value = card._representativeData[col.field]; return (
{value || "-"}
); } // 테이블 셀 렌더링 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 {value || "-"}; } // 읽기 전용 if (!col.editable) { if (col.type === "number") { return {typeof value === "number" ? value.toLocaleString() : value || "-"}; } return {value || "-"}; } // 편집 가능 switch (col.type) { case "text": return ( onChange(e.target.value)} className="h-8 text-sm" /> ); case "number": return ( onChange(Number(e.target.value) || 0)} className="h-8 text-sm text-right" /> ); case "date": return ( onChange(e.target.value)} className="h-8 text-sm" /> ); default: return {value || "-"}; } } // 컬럼 렌더링 함수 (Simple 모드) function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) { const value = card[col.field]; const isReadOnly = !col.editable; return (
{isReadOnly && (
{value || "-"}
)} {!isReadOnly && ( <> {col.type === "text" && ( onChange(e.target.value)} placeholder={col.placeholder} className="h-10 text-sm" /> )} {col.type === "number" && ( onChange(e.target.value)} placeholder={col.placeholder} className="h-10 text-sm" /> )} {col.type === "date" && ( onChange(e.target.value)} className="h-10 text-sm" /> )} {col.type === "select" && ( )} {col.type === "textarea" && (