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) {
|
||||
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">
|
||||
{/* 해상도 설정 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
@ -1418,7 +1418,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</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">
|
||||
{/* 해상도 설정 - 항상 맨 위에 표시 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처
|
|||
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import "./entity-search-input/EntitySearchInputRenderer";
|
||||
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
|
||||
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
|
||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
|
|
|
|||
|
|
@ -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