feat: 신규 컴포넌트 2종 추가 (SimpleRepeaterTable, RepeatScreenModal) 및 속성 패널 스크롤 개선
- SimpleRepeaterTable: 검색/추가 없이 데이터 표시 및 편집, 자동 계산 지원 - RepeatScreenModal: 그룹핑 기반 카드 레이아웃, 집계 기능, 테이블 모드 지원 - UnifiedPropertiesPanel: overflow-x-auto 추가로 가로 스크롤 활성화
This commit is contained in:
parent
244c597ac9
commit
c94b9da813
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
// 🆕 조건부 컨테이너 컴포넌트
|
// 🆕 조건부 컨테이너 컴포넌트
|
||||||
|
|
|
||||||
|
|
@ -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 모드)
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 {};
|
||||||
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
@ -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"])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue