diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 6e27fb93..69b7d092 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -238,9 +238,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 if (!selectedComponent) { return ( -
+
{/* 해상도 설정과 격자 설정 표시 */} -
+
{/* 해상도 설정 */} {currentResolution && onResolutionChange && ( @@ -1418,7 +1418,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{/* 통합 컨텐츠 (탭 제거) */} -
+
{/* 해상도 설정 - 항상 맨 위에 표시 */} {currentResolution && onResolutionChange && ( diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 12e6e944..c722b564 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처 import "./autocomplete-search-input/AutocompleteSearchInputRenderer"; import "./entity-search-input/EntitySearchInputRenderer"; import "./modal-repeater-table/ModalRepeaterTableRenderer"; +import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블 +import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태) import "./order-registration-modal/OrderRegistrationModalRenderer"; // 🆕 조건부 컨테이너 컴포넌트 diff --git a/frontend/lib/registry/components/repeat-screen-modal/README.md b/frontend/lib/registry/components/repeat-screen-modal/README.md new file mode 100644 index 00000000..87e8114f --- /dev/null +++ b/frontend/lib/registry/components/repeat-screen-modal/README.md @@ -0,0 +1,236 @@ +# RepeatScreenModal 컴포넌트 v2 + +## 개요 + +`RepeatScreenModal`은 선택한 데이터를 그룹핑하여 카드 형태로 표시하고, 각 카드 내에서 데이터를 편집할 수 있는 **만능 폼 컴포넌트**입니다. + +**이 컴포넌트 하나로 대부분의 ERP 화면을 설정만으로 구현할 수 있습니다.** + +## 핵심 철학 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ERP 화면 구성의 핵심 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 어떤 테이블에서 → 어떤 컬럼을 → 어떻게 보여줄 것인가? │ +│ │ +│ 2. 보기만 할 것인가? vs 수정 가능하게 할 것인가? │ +│ │ +│ 3. 수정한다면 → 어떤 테이블의 → 어떤 컬럼에 저장할 것인가? │ +│ │ +│ 4. 데이터를 어떻게 그룹화해서 보여줄 것인가? │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 카드 모드 + +### 1. Simple 모드 (단순) + +- **1행 = 1카드**: 선택한 각 행이 독립적인 카드로 표시 +- 자유로운 레이아웃 구성 (행/컬럼 기반) +- 적합한 상황: 단순 데이터 편집, 개별 레코드 수정 + +### 2. WithTable 모드 (테이블 포함) + +- **N행 = 1카드**: 그룹핑된 여러 행이 하나의 카드로 표시 +- 카드 = 헤더 영역 + 테이블 영역 +- 헤더: 그룹 대표값, 집계값 표시 +- 테이블: 그룹 내 각 행을 테이블로 표시 +- 적합한 상황: 출하계획, 구매발주, 생산계획 등 일괄 등록 + +## 주요 기능 + +| 기능 | 설명 | +|------|------| +| 그룹핑 | 특정 필드 기준으로 여러 행을 하나의 카드로 묶음 | +| 집계 | 그룹 내 데이터의 합계/개수/평균/최소/최대 자동 계산 | +| 카드 내 테이블 | 그룹 내 각 행을 테이블 형태로 표시 | +| 테이블 내 편집 | 테이블의 특정 컬럼을 편집 가능하게 설정 | +| 다중 테이블 저장 | 하나의 카드에서 여러 테이블 동시 저장 | +| 컬럼별 소스 설정 | 직접 조회/조인 조회/수동 입력 선택 | +| 컬럼별 타겟 설정 | 저장할 테이블과 컬럼 지정 | + +## 사용 시나리오 + +### 시나리오 1: 출하계획 동시 등록 + +``` +그룹핑: part_code (품목코드) +헤더: 품목정보 + 총수주잔량 + 현재고 +테이블: 수주별 출하계획 입력 +``` + +**설정 예시:** +```typescript +{ + cardMode: "withTable", + dataSource: { + sourceTable: "sales_order_mng", + filterField: "selectedIds" + }, + grouping: { + enabled: true, + groupByField: "part_code", + aggregations: [ + { sourceField: "balance_qty", type: "sum", resultField: "total_balance", label: "총수주잔량" }, + { sourceField: "id", type: "count", resultField: "order_count", label: "수주건수" } + ] + }, + tableLayout: { + headerRows: [ + { + columns: [ + { field: "part_code", label: "품목코드", type: "text", editable: false }, + { field: "part_name", label: "품목명", type: "text", editable: false }, + { field: "total_balance", label: "총수주잔량", type: "aggregation", aggregationField: "total_balance" } + ] + } + ], + tableColumns: [ + { field: "order_no", label: "수주번호", type: "text", editable: false }, + { field: "partner_id", label: "거래처", type: "text", editable: false }, + { field: "due_date", label: "납기일", type: "date", editable: false }, + { field: "balance_qty", label: "미출하", type: "number", editable: false }, + { + field: "plan_qty", + label: "출하계획", + type: "number", + editable: true, + targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true } + } + ] + } +} +``` + +### 시나리오 2: 구매발주 일괄 등록 + +``` +그룹핑: supplier_id (공급업체) +헤더: 공급업체정보 + 총발주금액 +테이블: 품목별 발주수량 입력 +``` + +### 시나리오 3: 생산계획 일괄 등록 + +``` +그룹핑: product_code (제품코드) +헤더: 제품정보 + 현재고 + 필요수량 +테이블: 작업지시별 생산수량 입력 +``` + +### 시나리오 4: 입고검사 일괄 처리 + +``` +그룹핑: po_no (발주번호) +헤더: 발주정보 + 공급업체 +테이블: 품목별 검사결과 입력 +``` + +## ConfigPanel 사용법 + +### 1. 기본 설정 탭 + +- **카드 제목**: `{field}` 형식으로 동적 제목 설정 +- **카드 간격**: 카드 사이 간격 (8px ~ 32px) +- **테두리**: 카드 테두리 표시 여부 +- **저장 모드**: 전체 저장 / 개별 저장 +- **카드 모드**: 단순 / 테이블 + +### 2. 데이터 소스 탭 + +- **소스 테이블**: 데이터를 조회할 테이블 +- **필터 필드**: formData에서 가져올 필터 필드명 (예: `selectedIds`) + +### 3. 그룹핑 탭 (테이블 모드에서 활성화) + +- **그룹핑 활성화**: ON/OFF +- **그룹 기준 필드**: 그룹핑할 필드 선택 +- **집계 설정**: 합계/개수/평균 등 집계 추가 + +### 4. 레이아웃 탭 + +**Simple 모드:** +- 행 추가/삭제 +- 각 행에 컬럼 추가/삭제 +- 컬럼별 필드명, 라벨, 타입, 너비, 편집 가능 여부 설정 + +**WithTable 모드:** +- 헤더 영역: 그룹 대표값, 집계값 표시용 행/컬럼 설정 +- 테이블 영역: 그룹 내 각 행을 표시할 테이블 컬럼 설정 + +## 컬럼 설정 상세 + +### 소스 설정 (데이터 조회) + +| 타입 | 설명 | +|------|------| +| direct | 소스 테이블에서 직접 조회 | +| join | 다른 테이블과 조인하여 조회 | +| manual | 사용자 직접 입력 | + +### 타겟 설정 (데이터 저장) + +- **저장 테이블**: 데이터를 저장할 테이블 +- **저장 컬럼**: 데이터를 저장할 컬럼 +- **저장 활성화**: 저장 여부 + +## 타입 정의 + +```typescript +interface RepeatScreenModalProps { + // 기본 설정 + cardTitle?: string; + cardSpacing?: string; + showCardBorder?: boolean; + saveMode?: "all" | "individual"; + cardMode?: "simple" | "withTable"; + + // 데이터 소스 + dataSource?: DataSourceConfig; + + // 그룹핑 설정 + grouping?: GroupingConfig; + + // 레이아웃 + cardLayout?: CardRowConfig[]; // simple 모드 + tableLayout?: TableLayoutConfig; // withTable 모드 +} + +interface GroupingConfig { + enabled: boolean; + groupByField: string; + aggregations?: AggregationConfig[]; +} + +interface AggregationConfig { + sourceField: string; + type: "sum" | "count" | "avg" | "min" | "max"; + resultField: string; + label: string; +} + +interface TableLayoutConfig { + headerRows: CardRowConfig[]; + tableColumns: TableColumnConfig[]; +} +``` + +## 파일 구조 + +``` +repeat-screen-modal/ +├── index.ts # 컴포넌트 정의 및 export +├── types.ts # 타입 정의 +├── RepeatScreenModalComponent.tsx # 메인 컴포넌트 +├── RepeatScreenModalConfigPanel.tsx # 설정 패널 +├── RepeatScreenModalRenderer.tsx # 자동 등록 +└── README.md # 문서 +``` + +## 버전 히스토리 + +- **v2.0.0**: 그룹핑, 집계, 테이블 모드 추가 +- **v1.0.0**: 초기 버전 (Simple 모드) diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx new file mode 100644 index 00000000..5f2e1690 --- /dev/null +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -0,0 +1,885 @@ +"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" && ( +