feat: 신규 컴포넌트 2종 추가 (SimpleRepeaterTable, RepeatScreenModal) 및 속성 패널 스크롤 개선

- SimpleRepeaterTable: 검색/추가 없이 데이터 표시 및 편집, 자동 계산 지원
- RepeatScreenModal: 그룹핑 기반 카드 레이아웃, 집계 기능, 테이블 모드 지원
- UnifiedPropertiesPanel: overflow-x-auto 추가로 가로 스크롤 활성화
This commit is contained in:
SeongHyun Kim 2025-11-28 11:48:46 +09:00
parent 244c597ac9
commit c94b9da813
14 changed files with 4972 additions and 3 deletions

View File

@ -238,9 +238,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
if (!selectedComponent) { if (!selectedComponent) {
return ( return (
<div className="flex h-full flex-col bg-white"> <div className="flex h-full flex-col bg-white overflow-x-auto">
{/* 해상도 설정과 격자 설정 표시 */} {/* 해상도 설정과 격자 설정 표시 */}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto overflow-x-auto p-2">
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* 해상도 설정 */} {/* 해상도 설정 */}
{currentResolution && onResolutionChange && ( {currentResolution && onResolutionChange && (
@ -1418,7 +1418,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
{/* 통합 컨텐츠 (탭 제거) */} {/* 통합 컨텐츠 (탭 제거) */}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto overflow-x-auto p-2">
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* 해상도 설정 - 항상 맨 위에 표시 */} {/* 해상도 설정 - 항상 맨 위에 표시 */}
{currentResolution && onResolutionChange && ( {currentResolution && onResolutionChange && (

View File

@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처
import "./autocomplete-search-input/AutocompleteSearchInputRenderer"; import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
import "./entity-search-input/EntitySearchInputRenderer"; import "./entity-search-input/EntitySearchInputRenderer";
import "./modal-repeater-table/ModalRepeaterTableRenderer"; import "./modal-repeater-table/ModalRepeaterTableRenderer";
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
import "./order-registration-modal/OrderRegistrationModalRenderer"; import "./order-registration-modal/OrderRegistrationModalRenderer";
// 🆕 조건부 컨테이너 컴포넌트 // 🆕 조건부 컨테이너 컴포넌트

View File

@ -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 모드)

View File

@ -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<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>
);
}

View File

@ -0,0 +1,13 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { RepeatScreenModalDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(RepeatScreenModalDefinition);
console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
}
export {};

View File

@ -0,0 +1,140 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel";
import type {
RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
DataSourceConfig,
CardData,
GroupingConfig,
AggregationConfig,
TableLayoutConfig,
TableColumnConfig,
GroupedCardData,
CardRowData,
} from "./types";
/**
* RepeatScreenModal v2
* - ,
*
* :
* - 🆕 모드: 단순(simple) / (withTable)
* - 🆕 그룹핑: 특정
* - 🆕 집계: 그룹 //
* - 🆕 테이블: 그룹
* - 레이아웃: /
* - 설정: 직접 / /
* - 설정: 어느
* - 저장: 하나의
*
* :
* - ( + )
* - ( + )
* - ( + )
* - ( + )
*/
export const RepeatScreenModalDefinition = createComponentDefinition({
id: "repeat-screen-modal",
name: "반복 화면 모달",
nameEng: "Repeat Screen Modal",
description:
"선택한 행을 그룹핑하여 카드로 표시하고, 각 카드는 헤더+테이블 구조로 편집 가능한 폼 (출하계획, 구매발주 등)",
category: ComponentCategory.DATA,
webType: "form",
component: RepeatScreenModalComponent,
defaultConfig: {
// 기본 설정
cardTitle: "{part_code} - {part_name}",
cardSpacing: "24px",
showCardBorder: true,
saveMode: "all",
// 카드 모드 (simple: 1행=1카드, withTable: 그룹핑+테이블)
cardMode: "simple",
// 데이터 소스
dataSource: {
sourceTable: "",
filterField: "selectedIds",
},
// 그룹핑 설정 (withTable 모드에서 사용)
grouping: {
enabled: false,
groupByField: "",
aggregations: [],
},
// Simple 모드 레이아웃
cardLayout: [
{
id: "row-1",
columns: [
{
id: "col-1",
field: "name",
label: "이름",
type: "text",
width: "50%",
editable: true,
required: true,
},
{
id: "col-2",
field: "status",
label: "상태",
type: "select",
width: "50%",
editable: true,
required: false,
selectOptions: [
{ value: "active", label: "활성" },
{ value: "inactive", label: "비활성" },
],
},
],
gap: "16px",
layout: "horizontal",
},
],
// WithTable 모드 레이아웃
tableLayout: {
headerRows: [],
tableColumns: [],
},
} as Partial<RepeatScreenModalProps>,
defaultSize: { width: 1000, height: 800 },
configPanel: RepeatScreenModalConfigPanel,
icon: "LayoutGrid",
tags: ["모달", "폼", "반복", "카드", "그룹핑", "집계", "테이블", "편집", "데이터", "출하계획", "일괄등록"],
version: "2.0.0",
author: "개발팀",
});
// 타입 재 export
export type {
RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
DataSourceConfig,
CardData,
GroupingConfig,
AggregationConfig,
TableLayoutConfig,
TableColumnConfig,
GroupedCardData,
CardRowData,
};
// 컴포넌트 재 export
export { RepeatScreenModalComponent, RepeatScreenModalConfigPanel };

View File

@ -0,0 +1,222 @@
import { ComponentRendererProps } from "@/types/component";
/**
* RepeatScreenModal Props
* ,
*
* 🆕 v2: 그룹핑, ,
*/
export interface RepeatScreenModalProps {
// === 기본 설정 ===
cardTitle?: string; // 카드 제목 템플릿 (예: "{order_no} - {item_code}")
cardSpacing?: string; // 카드 간 간격 (기본: 24px)
showCardBorder?: boolean; // 카드 테두리 표시 여부
saveMode?: "all" | "individual"; // 저장 모드
// === 데이터 소스 ===
dataSource?: DataSourceConfig; // 데이터 소스 설정
// === 🆕 그룹핑 설정 ===
grouping?: GroupingConfig; // 그룹핑 설정
// === 🆕 카드 모드 ===
cardMode?: "simple" | "withTable"; // 단순 필드 vs 테이블 포함
// === 레이아웃 (simple 모드) ===
cardLayout?: CardRowConfig[]; // 카드 내부 레이아웃 (행/컬럼 구조)
// === 🆕 레이아웃 (withTable 모드) ===
tableLayout?: TableLayoutConfig; // 테이블 포함 레이아웃
// === 값 ===
value?: any[];
onChange?: (newData: any[]) => void;
}
/**
*
*/
export interface DataSourceConfig {
sourceTable: string; // 조회할 테이블 (예: "sales_order_mng")
filterField?: string; // formData에서 필터링할 필드 (예: "selectedIds")
selectColumns?: string[]; // 선택할 컬럼 목록
}
/**
* 🆕
*
*/
export interface GroupingConfig {
enabled: boolean; // 그룹핑 활성화 여부
groupByField: string; // 그룹 기준 필드 (예: "part_code")
// 집계 설정 (그룹별 합계, 개수 등)
aggregations?: AggregationConfig[];
}
/**
* 🆕
*/
export interface AggregationConfig {
sourceField: string; // 원본 필드 (예: "balance_qty")
type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
resultField: string; // 결과 필드명 (예: "total_balance_qty")
label: string; // 표시 라벨 (예: "총수주잔량")
}
/**
* 🆕
* = +
*/
export interface TableLayoutConfig {
// 헤더 영역: 그룹 대표값, 집계값 표시
headerRows: CardRowConfig[];
// 테이블 영역: 그룹 내 각 행을 테이블로 표시
tableColumns: TableColumnConfig[];
// 테이블 설정
tableTitle?: string; // 테이블 제목
showTableHeader?: boolean; // 테이블 헤더 표시 여부 (기본: true)
tableMaxHeight?: string; // 테이블 최대 높이 (스크롤용)
}
/**
* 🆕
*/
export interface TableColumnConfig {
id: string; // 컬럼 고유 ID
field: string; // 필드명
label: string; // 헤더 라벨
type: "text" | "number" | "date" | "select" | "badge"; // 타입
width?: string; // 너비 (예: "100px", "20%")
align?: "left" | "center" | "right"; // 정렬
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];
// Badge 타입 설정
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
badgeColorMap?: Record<string, string>; // 값별 색상 매핑
// 데이터 소스 설정
sourceConfig?: ColumnSourceConfig;
// 데이터 타겟 설정
targetConfig?: ColumnTargetConfig;
}
/**
*
* (Row) ,
*/
export interface CardRowConfig {
id: string; // 행 고유 ID
columns: CardColumnConfig[]; // 이 행에 배치할 컬럼들
gap?: string; // 컬럼 간 간격 (기본: 16px)
layout?: "horizontal" | "vertical"; // 레이아웃 방향 (기본: horizontal)
// 🆕 행 스타일 설정
backgroundColor?: string; // 배경색 (예: "blue", "green")
padding?: string; // 패딩
rounded?: boolean; // 둥근 모서리
}
/**
*
*/
export interface CardColumnConfig {
id: string; // 컬럼 고유 ID
field: string; // 필드명 (데이터 바인딩)
label: string; // 라벨
type: "text" | "number" | "date" | "select" | "textarea" | "component" | "aggregation"; // 🆕 aggregation 추가
width?: string; // 너비 (예: "50%", "200px", "1fr")
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
placeholder?: string; // 플레이스홀더
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];
// 데이터 소스 설정 (어디서 조회?)
sourceConfig?: ColumnSourceConfig;
// 데이터 타겟 설정 (어디에 저장?)
targetConfig?: ColumnTargetConfig;
// Component 타입일 때
componentType?: string; // 삽입할 컴포넌트 타입 (예: "simple-repeater-table")
componentConfig?: any; // 컴포넌트 설정
// 🆕 Aggregation 타입일 때 (집계값 표시)
aggregationField?: string; // 표시할 집계 필드명 (GroupingConfig.aggregations의 resultField)
// 🆕 스타일 설정
textColor?: string; // 텍스트 색상
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
fontWeight?: "normal" | "medium" | "semibold" | "bold"; // 폰트 굵기
}
/**
* (SimpleRepeaterTable과 )
*/
export interface ColumnSourceConfig {
type: "direct" | "join" | "manual"; // 조회 타입
sourceTable?: string; // type: "direct" - 조회할 테이블
sourceColumn?: string; // type: "direct" - 조회할 컬럼
joinTable?: string; // type: "join" - 조인할 테이블
joinColumn?: string; // type: "join" - 조인 테이블에서 가져올 컬럼
joinKey?: string; // type: "join" - 현재 데이터의 조인 키 컬럼
joinRefKey?: string; // type: "join" - 조인 테이블의 참조 키 컬럼
}
/**
* (SimpleRepeaterTable과 )
*/
export interface ColumnTargetConfig {
targetTable: string; // 저장할 테이블
targetColumn: string; // 저장할 컬럼
saveEnabled?: boolean; // 저장 활성화 여부 (기본 true)
}
/**
* ( )
*/
export interface CardData {
_cardId: string; // 카드 고유 ID
_originalData: Record<string, any>; // 원본 데이터 (조회된 데이터)
_isDirty: boolean; // 수정 여부
[key: string]: any; // 실제 필드 데이터
}
/**
* 🆕
*/
export interface GroupedCardData {
_cardId: string; // 카드 고유 ID
_groupKey: string; // 그룹 키 값 (예: "PROD-001")
_groupField: string; // 그룹 기준 필드명 (예: "part_code")
_aggregations: Record<string, number>; // 집계 결과 (예: { total_balance_qty: 100 })
_rows: CardRowData[]; // 그룹 내 각 행 데이터
_representativeData: Record<string, any>; // 그룹 대표 데이터 (첫 번째 행 기준)
}
/**
* 🆕
*/
export interface CardRowData {
_rowId: string; // 행 고유 ID
_originalData: Record<string, any>; // 원본 데이터
_isDirty: boolean; // 수정 여부
[key: string]: any; // 실제 필드 데이터
}
/**
* (API )
*/
export interface TableInfo {
tableName: string;
displayName?: string;
}

View File

@ -0,0 +1,535 @@
"use client";
import React, { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2, X } from "lucide-react";
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
import { ComponentRendererProps } from "@/types/component";
import { useCalculation } from "./useCalculation";
import { apiClient } from "@/lib/api/client";
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
config?: SimpleRepeaterTableProps;
// SimpleRepeaterTableProps의 개별 prop들도 지원 (호환성)
value?: any[];
onChange?: (newData: any[]) => void;
columns?: SimpleRepeaterColumnConfig[];
calculationRules?: any[];
readOnly?: boolean;
showRowNumber?: boolean;
allowDelete?: boolean;
maxHeight?: string;
}
export function SimpleRepeaterTableComponent({
// ComponentRendererProps (자동 전달)
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
className,
formData,
onFormDataChange,
// SimpleRepeaterTable 전용 props
config,
value: propValue,
onChange: propOnChange,
columns: propColumns,
calculationRules: propCalculationRules,
readOnly: propReadOnly,
showRowNumber: propShowRowNumber,
allowDelete: propAllowDelete,
maxHeight: propMaxHeight,
...props
}: SimpleRepeaterTableComponentProps) {
// config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = {
...config,
...component?.config,
};
// config prop 우선, 없으면 개별 prop 사용
const columns = componentConfig?.columns || propColumns || [];
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
// value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 로딩 상태
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
const handleChange = (newData: any[]) => {
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
externalOnChange(newData);
}
// onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newData);
}
};
// 계산 hook
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 🆕 초기 데이터 로드
useEffect(() => {
const loadInitialData = async () => {
const initialConfig = componentConfig?.initialDataConfig;
if (!initialConfig || !initialConfig.sourceTable) {
return; // 초기 데이터 설정이 없으면 로드하지 않음
}
setIsLoading(true);
setLoadError(null);
try {
// 필터 조건 생성
const filters: Record<string, any> = {};
if (initialConfig.filterConditions) {
for (const condition of initialConfig.filterConditions) {
let filterValue = condition.value;
// formData에서 값 가져오기
if (condition.valueFromField && formData) {
filterValue = formData[condition.valueFromField];
}
filters[condition.field] = filterValue;
}
}
// API 호출
const response = await apiClient.post(
`/table-management/tables/${initialConfig.sourceTable}/data`,
{
search: filters,
page: 1,
size: 1000, // 대량 조회
}
);
if (response.data.success && response.data.data?.data) {
const loadedData = response.data.data.data;
// 1. 기본 데이터 매핑 (Direct & Manual)
const baseMappedData = loadedData.map((row: any) => {
const mappedRow: any = { ...row }; // 원본 데이터 유지 (조인 키 참조용)
for (const col of columns) {
if (col.sourceConfig) {
if (col.sourceConfig.type === "direct" && col.sourceConfig.sourceColumn) {
mappedRow[col.field] = row[col.sourceConfig.sourceColumn];
} else if (col.sourceConfig.type === "manual") {
mappedRow[col.field] = col.defaultValue;
}
// Join은 2단계에서 처리
} else {
mappedRow[col.field] = row[col.field] ?? col.defaultValue;
}
}
return mappedRow;
});
// 2. 조인 데이터 처리
const joinColumns = columns.filter(
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
);
if (joinColumns.length > 0) {
// 조인 테이블별로 그룹화
const joinGroups = new Map<string, { key: string; refKey: string; cols: typeof columns }>();
joinColumns.forEach((col) => {
const table = col.sourceConfig!.joinTable!;
const key = col.sourceConfig!.joinKey!;
// refKey가 없으면 key와 동일하다고 가정 (하위 호환성)
const refKey = col.sourceConfig!.joinRefKey || key;
const groupKey = `${table}:${key}:${refKey}`;
if (!joinGroups.has(groupKey)) {
joinGroups.set(groupKey, { key, refKey, cols: [] });
}
joinGroups.get(groupKey)!.cols.push(col);
});
// 각 그룹별로 데이터 조회 및 병합
await Promise.all(
Array.from(joinGroups.entries()).map(async ([groupKey, { key, refKey, cols }]) => {
const [tableName] = groupKey.split(":");
// 조인 키 값 수집 (중복 제거)
const keyValues = Array.from(new Set(
baseMappedData
.map((row: any) => row[key])
.filter((v: any) => v !== undefined && v !== null)
));
if (keyValues.length === 0) return;
try {
// 조인 테이블 조회
// refKey(타겟 테이블 컬럼)로 검색
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
page: 1,
size: 1000,
}
);
if (response.data.success && response.data.data?.data) {
const joinedRows = response.data.data.data;
// 조인 데이터 맵 생성 (refKey -> row)
const joinMap = new Map(joinedRows.map((r: any) => [r[refKey], r]));
// 데이터 병합
baseMappedData.forEach((row: any) => {
const keyValue = row[key];
const joinedRow = joinMap.get(keyValue);
if (joinedRow) {
cols.forEach((col) => {
if (col.sourceConfig?.joinColumn) {
row[col.field] = joinedRow[col.sourceConfig.joinColumn];
}
});
}
});
}
} catch (error) {
console.error(`조인 실패 (${tableName}):`, error);
// 실패 시 무시하고 진행 (값은 undefined)
}
})
);
}
const mappedData = baseMappedData;
// 계산 필드 적용
const calculatedData = calculateAll(mappedData);
handleChange(calculatedData);
}
} catch (error: any) {
console.error("초기 데이터 로드 실패:", error);
setLoadError(error.message || "데이터를 불러올 수 없습니다");
} finally {
setIsLoading(false);
}
};
loadInitialData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentConfig?.initialDataConfig]);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
handleChange(calculated);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 🆕 저장 요청 시 테이블별로 데이터 그룹화 (beforeFormSave 이벤트 리스너)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
if (value.length === 0) {
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
return;
}
// 🆕 테이블별로 데이터 그룹화
const dataByTable: Record<string, any[]> = {};
for (const row of value) {
// 각 행의 데이터를 테이블별로 분리
for (const col of columns) {
// 저장 설정이 있고 저장이 활성화된 경우에만
if (col.targetConfig && col.targetConfig.targetTable && col.targetConfig.saveEnabled !== false) {
const targetTable = col.targetConfig.targetTable;
const targetColumn = col.targetConfig.targetColumn || col.field;
// 테이블 그룹 초기화
if (!dataByTable[targetTable]) {
dataByTable[targetTable] = [];
}
// 해당 테이블의 데이터 찾기 또는 생성
let tableRow = dataByTable[targetTable].find((r: any) => r._rowIndex === row._rowIndex);
if (!tableRow) {
tableRow = { _rowIndex: row._rowIndex };
dataByTable[targetTable].push(tableRow);
}
// 컬럼 값 저장
tableRow[targetColumn] = row[col.field];
}
}
}
// _rowIndex 제거
Object.keys(dataByTable).forEach((tableName) => {
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
const { _rowIndex, ...rest } = row;
return rest;
});
});
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
// CustomEvent의 detail에 테이블별 데이터 추가
if (event instanceof CustomEvent && event.detail) {
// 각 테이블별로 데이터 전달
Object.entries(dataByTable).forEach(([tableName, rows]) => {
const key = `${columnName || component?.id}_${tableName}`;
event.detail.formData[key] = rows.map((row: any) => ({
...row,
_targetTable: tableName,
}));
});
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
tables: Object.keys(dataByTable),
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
});
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange && columnName) {
// 테이블별 데이터를 통합하여 전달
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table }))
));
}
};
// 저장 버튼 클릭 시 데이터 수집
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [value, columns, columnName, component?.id, onFormDataChange]);
const handleCellEdit = (rowIndex: number, field: string, cellValue: any) => {
const newRow = { ...value[rowIndex], [field]: cellValue };
// 계산 필드 업데이트
const calculatedRow = calculateRow(newRow);
const newData = [...value];
newData[rowIndex] = calculatedRow;
handleChange(newData);
};
const handleRowDelete = (rowIndex: number) => {
const newData = value.filter((_, i) => i !== rowIndex);
handleChange(newData);
};
const renderCell = (
row: any,
column: SimpleRepeaterColumnConfig,
rowIndex: number
) => {
const cellValue = row[column.field];
// 계산 필드는 편집 불가
if (column.calculated || !column.editable || readOnly) {
return (
<div className="px-2 py-1">
{column.type === "number"
? typeof cellValue === "number"
? cellValue.toLocaleString()
: cellValue || "0"
: cellValue || "-"}
</div>
);
}
// 편집 가능한 필드
switch (column.type) {
case "number":
return (
<Input
type="number"
value={cellValue || ""}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-7 text-xs"
/>
);
case "date":
return (
<Input
type="date"
value={cellValue || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
case "select":
return (
<Select
value={cellValue || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default: // text
return (
<Input
type="text"
value={cellValue || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
}
};
// 로딩 중일 때
if (isLoading) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
</div>
);
}
// 에러 발생 시
if (loadError) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
<X className="h-6 w-6 text-destructive" />
</div>
<p className="text-sm font-medium text-destructive mb-1"> </p>
<p className="text-xs text-muted-foreground">{loadError}</p>
</div>
</div>
</div>
);
}
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight }}
>
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
{showRowNumber && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
#
</th>
)}
{columns.map((col) => (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</th>
))}
{!readOnly && allowDelete && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
</th>
)}
</tr>
</thead>
<tbody className="bg-background">
{value.length === 0 ? (
<tr>
<td
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
className="px-4 py-8 text-center text-muted-foreground"
>
</td>
</tr>
) : (
value.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50">
{showRowNumber && (
<td className="px-4 py-2 text-center text-muted-foreground">
{rowIndex + 1}
</td>
)}
{columns.map((col) => (
<td key={col.field} className="px-2 py-1">
{renderCell(row, col, rowIndex)}
</td>
))}
{!readOnly && allowDelete && (
<td className="px-4 py-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
"use client";
import React from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { SimpleRepeaterTableDefinition } from "./index";
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
import { ComponentRendererProps } from "@/types/component";
// 컴포넌트 자동 등록
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
return <SimpleRepeaterTableComponent {...props} />;
}

View File

@ -0,0 +1,60 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
import { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
/**
* 🆕 SimpleRepeaterTable
* - /
*
* :
* - 로드: 어떤
* - 설정: ( / / )
* - 설정:
* - 계산: 수량 * =
* - 모드: 전체
*/
export const SimpleRepeaterTableDefinition = createComponentDefinition({
id: "simple-repeater-table",
name: "단순 반복 테이블",
nameEng: "Simple Repeater Table",
description: "어떤 테이블에서 조회하고 어떤 테이블에 저장할지 컬럼별로 설정 가능한 반복 테이블 (검색/추가 없음, 자동 계산 지원)",
category: ComponentCategory.DATA,
webType: "table",
component: SimpleRepeaterTableComponent,
defaultConfig: {
columns: [],
calculationRules: [],
initialDataConfig: undefined,
readOnly: false,
showRowNumber: true,
allowDelete: true,
maxHeight: "240px",
},
defaultSize: { width: 800, height: 400 },
configPanel: SimpleRepeaterTableConfigPanel,
icon: "Table",
tags: ["테이블", "반복", "편집", "데이터", "목록", "계산", "조회", "저장"],
version: "2.0.0",
author: "개발팀",
});
// 타입 내보내기
export type {
SimpleRepeaterTableProps,
SimpleRepeaterColumnConfig,
CalculationRule,
ColumnSourceConfig,
ColumnTargetConfig,
InitialDataConfig,
DataFilterCondition,
SourceJoinCondition,
} from "./types";
// 컴포넌트 내보내기
export { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
export { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
export { useCalculation } from "./useCalculation";

View File

@ -0,0 +1,132 @@
/**
* SimpleRepeaterTable
* /
*/
export interface SimpleRepeaterTableProps {
// 데이터
value?: any[]; // 현재 표시할 데이터
onChange?: (newData: any[]) => void; // 데이터 변경 콜백
// 테이블 설정
columns: SimpleRepeaterColumnConfig[]; // 테이블 컬럼 설정
// 🆕 초기 데이터 로드 설정
initialDataConfig?: InitialDataConfig;
// 계산 규칙
calculationRules?: CalculationRule[]; // 자동 계산 규칙 (수량 * 단가 = 금액)
// 옵션
readOnly?: boolean; // 읽기 전용 모드 (편집 불가)
showRowNumber?: boolean; // 행 번호 표시 (기본: true)
allowDelete?: boolean; // 삭제 버튼 표시 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "240px")
// 스타일
className?: string;
}
export interface SimpleRepeaterColumnConfig {
field: string; // 필드명 (화면에 표시용 임시 키)
label: string; // 컬럼 헤더 라벨
type?: "text" | "number" | "date" | "select"; // 입력 타입
editable?: boolean; // 편집 가능 여부
calculated?: boolean; // 계산 필드 여부 (자동 계산되는 필드)
width?: string; // 컬럼 너비
required?: boolean; // 필수 입력 여부
defaultValue?: any; // 기본값
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
// 🆕 데이터 조회 설정 (어디서 가져올지)
sourceConfig?: ColumnSourceConfig;
// 🆕 데이터 저장 설정 (어디에 저장할지)
targetConfig?: ColumnTargetConfig;
}
/**
* 🆕
*
*/
export interface ColumnSourceConfig {
/** 조회 타입 */
type: "direct" | "join" | "manual";
// type: "direct" - 직접 조회 (단일 테이블에서 바로 가져오기)
sourceTable?: string; // 조회할 테이블 (예: "sales_order_mng")
sourceColumn?: string; // 조회할 컬럼 (예: "item_name")
// type: "join" - 조인 조회 (다른 테이블과 조인하여 가져오기)
joinTable?: string; // 조인할 테이블 (예: "customer_item_mapping")
joinColumn?: string; // 조인 테이블에서 가져올 컬럼 (예: "basic_price")
joinKey?: string; // 🆕 조인 키 (현재 테이블의 컬럼, 예: "sales_order_id")
joinRefKey?: string; // 🆕 참조 키 (조인 테이블의 컬럼, 예: "id")
joinConditions?: SourceJoinCondition[]; // 조인 조건 (어떤 키로 조인할지)
// type: "manual" - 사용자 직접 입력 (조회 안 함)
}
/**
* 🆕
*
*/
export interface ColumnTargetConfig {
targetTable?: string; // 저장할 테이블 (예: "shipment_plan")
targetColumn?: string; // 저장할 컬럼 (예: "plan_qty")
saveEnabled?: boolean; // 저장 활성화 여부 (false면 읽기 전용)
}
/**
* 🆕
*
*/
export interface SourceJoinCondition {
/** 기준 테이블 */
baseTable: string; // 기준이 되는 테이블 (예: "sales_order_mng")
/** 기준 컬럼 */
baseColumn: string; // 기준 테이블의 컬럼 (예: "item_code")
/** 조인 테이블의 컬럼 */
joinColumn: string; // 조인 테이블에서 매칭할 컬럼 (예: "item_code")
/** 비교 연산자 */
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
}
/**
* 🆕
*
*/
export interface InitialDataConfig {
/** 로드할 테이블 */
sourceTable: string; // 예: "sales_order_mng"
/** 필터 조건 */
filterConditions?: DataFilterCondition[];
/** 선택할 컬럼 목록 */
selectColumns?: string[];
}
/**
*
*/
export interface DataFilterCondition {
/** 필드명 */
field: string;
/** 연산자 */
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
/** 값 (또는 다른 필드 참조) */
value: any;
/** 값을 다른 필드에서 가져올지 */
valueFromField?: string; // 예: "order_no" (formData에서 가져오기)
}
/**
* ( )
*/
export interface CalculationRule {
result: string; // 결과를 저장할 필드 (예: "total_amount")
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들 (예: ["quantity", "unit_price"])
}

View File

@ -0,0 +1,67 @@
import { useCallback } from "react";
import { CalculationRule } from "./types";
/**
*
*/
export function useCalculation(calculationRules: CalculationRule[] = []) {
/**
*
*/
const calculateRow = useCallback(
(row: any): any => {
if (calculationRules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of calculationRules) {
try {
// formula에서 필드명 자동 추출 (영문자, 숫자, 언더스코어로 구성된 단어)
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
// 추출된 필드명들을 사용 (dependencies가 없으면 자동 추출 사용)
const dependencies = rule.dependencies && rule.dependencies.length > 0
? rule.dependencies
: fieldMatches;
// 필드명을 실제 값으로 대체
for (const dep of dependencies) {
// 결과 필드는 제외
if (dep === rule.result) continue;
const value = parseFloat(row[dep]) || 0;
// 정확한 필드명만 대체 (단어 경계 사용)
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
}
// 계산 실행 (Function 사용)
const result = new Function(`return ${formula}`)();
updatedRow[rule.result] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
updatedRow[rule.result] = 0;
}
}
return updatedRow;
},
[calculationRules]
);
/**
*
*/
const calculateAll = useCallback(
(data: any[]): any[] => {
return data.map((row) => calculateRow(row));
},
[calculateRow]
);
return {
calculateRow,
calculateAll,
};
}