V2 컴포넌트 규칙 추가 및 기존 컴포넌트 자동 등록 개선: 화면 컴포넌트 개발 가이드에 V2 컴포넌트 사용 규칙을 명시하고, ComponentsPanel에서 수동으로 추가하던 table-list 컴포넌트를 자동 등록으로 변경하여 관리 효율성을 높였습니다. 또한, V2 컴포넌트 목록과 수정/개발 시 규칙을 추가하여 일관된 개발 환경을 조성하였습니다.
This commit is contained in:
parent
901cb04a88
commit
338f3c27fd
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
description: 화면 컴포넌트 개발 시 필수 가이드 - 엔티티 조인, 폼 데이터, 다국어 지원
|
||||
description: 화면 컴포넌트 개발 시 필수 가이드 - V2 컴포넌트, 엔티티 조인, 폼 데이터, 다국어 지원
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ alwaysApply: false
|
|||
|
||||
## 목차
|
||||
|
||||
0. [V2 컴포넌트 규칙 (최우선)](#0-v2-컴포넌트-규칙-최우선)
|
||||
1. [컴포넌트별 테이블 설정 (핵심 원칙)](#1-컴포넌트별-테이블-설정-핵심-원칙)
|
||||
2. [엔티티 조인 컬럼 활용 (필수)](#2-엔티티-조인-컬럼-활용-필수)
|
||||
3. [폼 데이터 관리](#3-폼-데이터-관리)
|
||||
|
|
@ -22,6 +23,93 @@ alwaysApply: false
|
|||
|
||||
---
|
||||
|
||||
## 0. V2 컴포넌트 규칙 (최우선)
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
**화면관리 시스템에서는 반드시 V2 컴포넌트만 사용하고 수정합니다.**
|
||||
|
||||
원본 컴포넌트(v2 접두사 없는 것)는 더 이상 사용하지 않으며, 모든 수정/개발은 V2 폴더에서 진행합니다.
|
||||
|
||||
### V2 컴포넌트 목록 (18개)
|
||||
|
||||
| 컴포넌트 ID | 이름 | 경로 |
|
||||
|------------|------|------|
|
||||
| `v2-button-primary` | 기본 버튼 | `v2-button-primary/` |
|
||||
| `v2-text-display` | 텍스트 표시 | `v2-text-display/` |
|
||||
| `v2-divider-line` | 구분선 | `v2-divider-line/` |
|
||||
| `v2-table-list` | 테이블 리스트 | `v2-table-list/` |
|
||||
| `v2-card-display` | 카드 디스플레이 | `v2-card-display/` |
|
||||
| `v2-split-panel-layout` | 분할 패널 | `v2-split-panel-layout/` |
|
||||
| `v2-numbering-rule` | 채번 규칙 | `v2-numbering-rule/` |
|
||||
| `v2-table-search-widget` | 검색 필터 | `v2-table-search-widget/` |
|
||||
| `v2-repeat-screen-modal` | 반복 화면 모달 | `v2-repeat-screen-modal/` |
|
||||
| `v2-section-paper` | 섹션 페이퍼 | `v2-section-paper/` |
|
||||
| `v2-section-card` | 섹션 카드 | `v2-section-card/` |
|
||||
| `v2-tabs-widget` | 탭 위젯 | `v2-tabs-widget/` |
|
||||
| `v2-location-swap-selector` | 출발지/도착지 선택 | `v2-location-swap-selector/` |
|
||||
| `v2-rack-structure` | 렉 구조 | `v2-rack-structure/` |
|
||||
| `v2-unified-repeater` | 통합 리피터 | `v2-unified-repeater/` |
|
||||
| `v2-pivot-grid` | 피벗 그리드 | `v2-pivot-grid/` |
|
||||
| `v2-aggregation-widget` | 집계 위젯 | `v2-aggregation-widget/` |
|
||||
| `v2-repeat-container` | 리피터 컨테이너 | `v2-repeat-container/` |
|
||||
|
||||
### 파일 경로
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/
|
||||
├── v2-button-primary/ ← V2 컴포넌트 (수정 대상)
|
||||
├── v2-table-list/ ← V2 컴포넌트 (수정 대상)
|
||||
├── v2-split-panel-layout/ ← V2 컴포넌트 (수정 대상)
|
||||
├── ...
|
||||
├── button-primary/ ← 원본 (수정 금지)
|
||||
├── table-list/ ← 원본 (수정 금지)
|
||||
├── split-panel-layout/ ← 원본 (수정 금지)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 수정/개발 시 규칙
|
||||
|
||||
1. **버그 수정**: V2 폴더의 파일만 수정
|
||||
2. **기능 추가**: V2 폴더에만 추가
|
||||
3. **새 컴포넌트 생성**: `v2-` 접두사로 폴더 생성, ID도 `v2-` 접두사 사용
|
||||
4. **원본 폴더는 절대 수정하지 않음**
|
||||
|
||||
### 컴포넌트 등록
|
||||
|
||||
V2 컴포넌트는 `frontend/lib/registry/components/index.ts`에서 등록됩니다:
|
||||
|
||||
```typescript
|
||||
// V2 컴포넌트들 (화면관리 전용)
|
||||
import "./v2-unified-repeater/UnifiedRepeaterRenderer";
|
||||
import "./v2-button-primary/ButtonPrimaryRenderer";
|
||||
import "./v2-split-panel-layout/SplitPanelLayoutRenderer";
|
||||
// ... 기타 v2 컴포넌트들
|
||||
```
|
||||
|
||||
### Definition 네이밍 규칙
|
||||
|
||||
V2 컴포넌트의 Definition은 `V2` 접두사를 사용합니다:
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
export const V2TableListDefinition = createComponentDefinition({
|
||||
id: "v2-table-list",
|
||||
name: "테이블 리스트",
|
||||
// ...
|
||||
});
|
||||
|
||||
// Renderer.tsx
|
||||
import { V2TableListDefinition } from "./index";
|
||||
|
||||
export class TableListRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2TableListDefinition;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 컴포넌트별 테이블 설정 (핵심 원칙)
|
||||
|
||||
### 핵심 원칙
|
||||
|
|
@ -949,6 +1037,14 @@ export const MyComponentConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||
|
||||
새 컴포넌트 개발 시 다음 항목을 확인하세요:
|
||||
|
||||
### V2 컴포넌트 규칙 (최우선)
|
||||
|
||||
- [ ] V2 폴더(`v2-*/`)에서 작업 중인지 확인
|
||||
- [ ] 원본 폴더는 수정하지 않음
|
||||
- [ ] 컴포넌트 ID에 `v2-` 접두사 사용
|
||||
- [ ] Definition 이름에 `V2` 접두사 사용 (예: `V2TableListDefinition`)
|
||||
- [ ] Renderer에서 올바른 V2 Definition 참조 확인
|
||||
|
||||
### 컴포넌트별 테이블 설정 (핵심)
|
||||
|
||||
- [ ] 화면 메인 테이블과 다른 테이블을 사용할 수 있는지 확인
|
||||
|
|
|
|||
|
|
@ -39,20 +39,7 @@ export function ComponentsPanel({
|
|||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
const components = ComponentRegistry.getAllComponents();
|
||||
|
||||
// 수동으로 table-list 컴포넌트 추가 (임시)
|
||||
const hasTableList = components.some((c) => c.id === "table-list");
|
||||
if (!hasTableList) {
|
||||
components.push({
|
||||
id: "table-list",
|
||||
name: "데이터 테이블 v2",
|
||||
description: "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
|
||||
category: "display",
|
||||
tags: ["table", "data", "crud"],
|
||||
defaultSize: { width: 1000, height: 680 },
|
||||
} as ComponentDefinition);
|
||||
}
|
||||
|
||||
// v2-table-list가 자동 등록되므로 수동 추가 불필요
|
||||
return components;
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ import { initializeHotReload } from "../utils/hotReload";
|
|||
* CLI로 생성된 컴포넌트들은 여기에 import만 추가하면 자동으로 등록됩니다
|
||||
*/
|
||||
|
||||
// 예시 컴포넌트들 (CLI로 생성 후 주석 해제)
|
||||
import "./button-primary/ButtonPrimaryRenderer";
|
||||
// 기본 입력 컴포넌트들 (v2 버전 없음 - 유지)
|
||||
import "./text-input/TextInputRenderer";
|
||||
import "./textarea-basic/TextareaBasicRenderer";
|
||||
import "./number-input/NumberInputRenderer";
|
||||
|
|
@ -25,80 +24,87 @@ import "./select-basic/SelectBasicRenderer";
|
|||
import "./checkbox-basic/CheckboxBasicRenderer";
|
||||
import "./radio-basic/RadioBasicRenderer";
|
||||
import "./date-input/DateInputRenderer";
|
||||
// import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
|
||||
import "./text-display/TextDisplayRenderer";
|
||||
import "./file-upload/FileUploadRenderer";
|
||||
import "./image-widget/ImageWidgetRenderer";
|
||||
import "./slider-basic/SliderBasicRenderer";
|
||||
import "./toggle-switch/ToggleSwitchRenderer";
|
||||
import "./image-display/ImageDisplayRenderer";
|
||||
import "./divider-line/DividerLineRenderer";
|
||||
import "./accordion-basic/AccordionBasicRenderer"; // 컴포넌트 패널에서만 숨김
|
||||
import "./table-list/TableListRenderer";
|
||||
import "./card-display/CardDisplayRenderer";
|
||||
import "./split-panel-layout/SplitPanelLayoutRenderer";
|
||||
import "./split-panel-layout2/SplitPanelLayout2Renderer"; // 분할 패널 레이아웃 v2
|
||||
import "./map/MapRenderer";
|
||||
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||
import "./flow-widget/FlowWidgetRenderer";
|
||||
import "./numbering-rule/NumberingRuleRenderer";
|
||||
import "./category-manager/CategoryManagerRenderer";
|
||||
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
|
||||
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
|
||||
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 거래처별 품목정보
|
||||
|
||||
// 🆕 수주 등록 관련 컴포넌트들
|
||||
// 수주 등록 관련 컴포넌트들 (v2 버전 없음 - 유지)
|
||||
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 "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 단순 반복 테이블
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
// 조건부 컨테이너 컴포넌트
|
||||
import "./conditional-container/ConditionalContainerRenderer"; // 컴포넌트 패널에서만 숨김
|
||||
import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
|
||||
|
||||
// 🆕 섹션 그룹화 레이아웃 컴포넌트
|
||||
import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식
|
||||
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
|
||||
|
||||
// 🆕 탭 컴포넌트
|
||||
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
||||
|
||||
// 🆕 반복 화면 모달 컴포넌트
|
||||
import "./repeat-screen-modal/RepeatScreenModalRenderer";
|
||||
|
||||
// 🆕 출발지/도착지 선택 컴포넌트
|
||||
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||
|
||||
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
||||
// 화면 임베딩 및 분할 패널 컴포넌트
|
||||
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
||||
|
||||
// 🆕 범용 폼 모달 컴포넌트
|
||||
// 범용 폼 모달 컴포넌트
|
||||
import "./universal-form-modal/UniversalFormModalRenderer"; // 컴포넌트 패널에서만 숨김
|
||||
|
||||
// 🆕 렉 구조 설정 컴포넌트
|
||||
import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생성
|
||||
|
||||
// 🆕 세금계산서 관리 컴포넌트
|
||||
// 세금계산서 관리 컴포넌트
|
||||
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
||||
|
||||
// 🆕 메일 수신자 선택 컴포넌트
|
||||
// 메일 수신자 선택 컴포넌트
|
||||
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
|
||||
|
||||
// 🆕 연관 데이터 버튼 컴포넌트
|
||||
// 연관 데이터 버튼 컴포넌트
|
||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||
|
||||
// 🆕 통합 반복 데이터 컴포넌트 (Unified)
|
||||
import "./unified-repeater/UnifiedRepeaterRenderer"; // 인라인/모달/버튼 모드 통합
|
||||
// ============================================================
|
||||
// 아래 컴포넌트들은 V2 버전으로 대체됨 (주석 처리)
|
||||
// ============================================================
|
||||
// import "./button-primary/ButtonPrimaryRenderer"; // → v2-button-primary
|
||||
// import "./text-display/TextDisplayRenderer"; // → v2-text-display
|
||||
// import "./divider-line/DividerLineRenderer"; // → v2-divider-line
|
||||
// import "./table-list/TableListRenderer"; // → v2-table-list
|
||||
// import "./card-display/CardDisplayRenderer"; // → v2-card-display
|
||||
// import "./split-panel-layout/SplitPanelLayoutRenderer"; // → v2-split-panel-layout
|
||||
// import "./numbering-rule/NumberingRuleRenderer"; // → v2-numbering-rule
|
||||
// import "./table-search-widget"; // → v2-table-search-widget
|
||||
// import "./repeat-screen-modal/RepeatScreenModalRenderer"; // → v2-repeat-screen-modal
|
||||
// import "./section-paper/SectionPaperRenderer"; // → v2-section-paper
|
||||
// import "./section-card/SectionCardRenderer"; // → v2-section-card
|
||||
// import "./tabs/tabs-component"; // → v2-tabs-widget
|
||||
// import "./location-swap-selector/LocationSwapSelectorRenderer"; // → v2-location-swap-selector
|
||||
// import "./rack-structure/RackStructureRenderer"; // → v2-rack-structure
|
||||
// import "./unified-repeater/UnifiedRepeaterRenderer"; // → v2-unified-repeater
|
||||
// import "./pivot-grid/PivotGridRenderer"; // → v2-pivot-grid
|
||||
// import "./aggregation-widget/AggregationWidgetRenderer"; // → v2-aggregation-widget
|
||||
// import "./repeat-container/RepeatContainerRenderer"; // → v2-repeat-container
|
||||
|
||||
// 🆕 피벗 그리드 컴포넌트
|
||||
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
|
||||
|
||||
// 🆕 집계 위젯 컴포넌트
|
||||
import "./aggregation-widget/AggregationWidgetRenderer"; // 데이터 집계 (합계, 평균, 개수 등)
|
||||
|
||||
// 🆕 리피터 컨테이너 컴포넌트
|
||||
import "./repeat-container/RepeatContainerRenderer"; // 데이터 수만큼 반복 렌더링
|
||||
// ============================================================
|
||||
// V2 컴포넌트들 (화면관리 전용 - 충돌 방지용 별도 버전)
|
||||
// ============================================================
|
||||
import "./v2-unified-repeater/UnifiedRepeaterRenderer";
|
||||
import "./v2-button-primary/ButtonPrimaryRenderer";
|
||||
import "./v2-split-panel-layout/SplitPanelLayoutRenderer";
|
||||
import "./v2-aggregation-widget/AggregationWidgetRenderer";
|
||||
import "./v2-card-display/CardDisplayRenderer";
|
||||
import "./v2-numbering-rule/NumberingRuleRenderer";
|
||||
import "./v2-table-list/TableListRenderer";
|
||||
import "./v2-text-display/TextDisplayRenderer";
|
||||
import "./v2-pivot-grid/PivotGridRenderer";
|
||||
import "./v2-repeat-screen-modal/RepeatScreenModalRenderer";
|
||||
import "./v2-divider-line/DividerLineRenderer";
|
||||
import "./v2-repeat-container/RepeatContainerRenderer";
|
||||
import "./v2-section-card/SectionCardRenderer";
|
||||
import "./v2-section-paper/SectionPaperRenderer";
|
||||
import "./v2-rack-structure/RackStructureRenderer";
|
||||
import "./v2-location-swap-selector/LocationSwapSelectorRenderer";
|
||||
import "./v2-table-search-widget";
|
||||
import "./v2-tabs-widget/tabs-component";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,312 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
|
||||
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
interface AggregationWidgetComponentProps extends ComponentRendererProps {
|
||||
config?: AggregationWidgetConfig;
|
||||
// 외부에서 데이터를 직접 전달받을 수 있음
|
||||
externalData?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 위젯 컴포넌트
|
||||
* 연결된 테이블 리스트나 리피터의 데이터를 집계하여 표시
|
||||
*/
|
||||
export function AggregationWidgetComponent({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
config: propsConfig,
|
||||
externalData,
|
||||
}: AggregationWidgetComponentProps) {
|
||||
// 다국어 지원
|
||||
const { getText } = useScreenMultiLang();
|
||||
|
||||
const componentConfig: AggregationWidgetConfig = {
|
||||
dataSourceType: "manual",
|
||||
items: [],
|
||||
layout: "horizontal",
|
||||
showLabels: true,
|
||||
showIcons: true,
|
||||
gap: "16px",
|
||||
...propsConfig,
|
||||
...component?.config,
|
||||
};
|
||||
|
||||
// 다국어 라벨 가져오기
|
||||
const getItemLabel = (item: AggregationItem): string => {
|
||||
if (item.labelLangKey) {
|
||||
const translated = getText(item.labelLangKey);
|
||||
if (translated && translated !== item.labelLangKey) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
return item.columnLabel || item.columnName || "컬럼";
|
||||
};
|
||||
|
||||
const {
|
||||
dataSourceType,
|
||||
dataSourceComponentId,
|
||||
items,
|
||||
layout,
|
||||
showLabels,
|
||||
showIcons,
|
||||
gap,
|
||||
backgroundColor,
|
||||
borderRadius,
|
||||
padding,
|
||||
fontSize,
|
||||
labelFontSize,
|
||||
valueFontSize,
|
||||
labelColor,
|
||||
valueColor,
|
||||
} = componentConfig;
|
||||
|
||||
// 데이터 상태
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
|
||||
// 외부 데이터가 있으면 사용
|
||||
useEffect(() => {
|
||||
if (externalData && Array.isArray(externalData)) {
|
||||
setData(externalData);
|
||||
}
|
||||
}, [externalData]);
|
||||
|
||||
// 컴포넌트 데이터 변경 이벤트 리스닝
|
||||
useEffect(() => {
|
||||
if (!dataSourceComponentId || isDesignMode) return;
|
||||
|
||||
const handleDataChange = (event: CustomEvent) => {
|
||||
const { componentId, data: eventData } = event.detail || {};
|
||||
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
|
||||
setData(eventData);
|
||||
}
|
||||
};
|
||||
|
||||
// 리피터 데이터 변경 이벤트
|
||||
window.addEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
// 테이블 리스트 데이터 변경 이벤트
|
||||
window.addEventListener("tableListDataChange" as any, handleDataChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
window.removeEventListener("tableListDataChange" as any, handleDataChange);
|
||||
};
|
||||
}, [dataSourceComponentId, isDesignMode]);
|
||||
|
||||
// 집계 계산
|
||||
const aggregationResults = useMemo((): AggregationResult[] => {
|
||||
if (!items || items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((item) => {
|
||||
const values = data
|
||||
.map((row) => {
|
||||
const val = row[item.columnName];
|
||||
return typeof val === "number" ? val : parseFloat(val) || 0;
|
||||
})
|
||||
.filter((v) => !isNaN(v));
|
||||
|
||||
let value: number = 0;
|
||||
|
||||
switch (item.type) {
|
||||
case "sum":
|
||||
value = values.reduce((acc, v) => acc + v, 0);
|
||||
break;
|
||||
case "avg":
|
||||
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
|
||||
break;
|
||||
case "count":
|
||||
value = data.length;
|
||||
break;
|
||||
case "max":
|
||||
value = values.length > 0 ? Math.max(...values) : 0;
|
||||
break;
|
||||
case "min":
|
||||
value = values.length > 0 ? Math.min(...values) : 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// 포맷팅
|
||||
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
|
||||
|
||||
if (item.format === "currency") {
|
||||
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
|
||||
} else if (item.format === "percent") {
|
||||
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
|
||||
} else if (item.format === "number") {
|
||||
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
|
||||
}
|
||||
|
||||
if (item.prefix) {
|
||||
formattedValue = `${item.prefix}${formattedValue}`;
|
||||
}
|
||||
if (item.suffix) {
|
||||
formattedValue = `${formattedValue}${item.suffix}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
label: getItemLabel(item),
|
||||
value,
|
||||
formattedValue,
|
||||
type: item.type,
|
||||
};
|
||||
});
|
||||
}, [data, items, getText]);
|
||||
|
||||
// 집계 타입에 따른 아이콘
|
||||
const getIcon = (type: AggregationType) => {
|
||||
switch (type) {
|
||||
case "sum":
|
||||
return <Calculator className="h-4 w-4" />;
|
||||
case "avg":
|
||||
return <TrendingUp className="h-4 w-4" />;
|
||||
case "count":
|
||||
return <Hash className="h-4 w-4" />;
|
||||
case "max":
|
||||
return <ArrowUp className="h-4 w-4" />;
|
||||
case "min":
|
||||
return <ArrowDown className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 집계 타입 라벨
|
||||
const getTypeLabel = (type: AggregationType) => {
|
||||
switch (type) {
|
||||
case "sum":
|
||||
return "합계";
|
||||
case "avg":
|
||||
return "평균";
|
||||
case "count":
|
||||
return "개수";
|
||||
case "max":
|
||||
return "최대";
|
||||
case "min":
|
||||
return "최소";
|
||||
}
|
||||
};
|
||||
|
||||
// 디자인 모드 미리보기
|
||||
if (isDesignMode) {
|
||||
const previewItems: AggregationResult[] =
|
||||
items.length > 0
|
||||
? items.map((item) => ({
|
||||
id: item.id,
|
||||
label: getItemLabel(item),
|
||||
value: 0,
|
||||
formattedValue: item.prefix ? `${item.prefix}0${item.suffix || ""}` : `0${item.suffix || ""}`,
|
||||
type: item.type,
|
||||
}))
|
||||
: [
|
||||
{ id: "1", label: "총 수량", value: 150, formattedValue: "150", type: "sum" },
|
||||
{ id: "2", label: "총 금액", value: 1500000, formattedValue: "₩1,500,000", type: "sum" },
|
||||
{ id: "3", label: "건수", value: 5, formattedValue: "5건", type: "count" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border bg-slate-50 p-3",
|
||||
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
gap: gap || "12px",
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
borderRadius: borderRadius || undefined,
|
||||
padding: padding || undefined,
|
||||
fontSize: fontSize || undefined,
|
||||
}}
|
||||
>
|
||||
{previewItems.map((result, index) => (
|
||||
<div
|
||||
key={result.id || index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
||||
layout === "vertical" ? "w-full justify-between" : ""
|
||||
)}
|
||||
>
|
||||
{showIcons && (
|
||||
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
style={{ fontSize: labelFontSize, color: labelColor }}
|
||||
>
|
||||
{result.label} ({getTypeLabel(result.type)}):
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: valueFontSize, color: valueColor }}
|
||||
>
|
||||
{result.formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 렌더링
|
||||
if (aggregationResults.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-md border border-dashed bg-slate-50 p-4 text-sm text-muted-foreground">
|
||||
집계 항목을 설정해주세요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border bg-slate-50 p-3",
|
||||
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
gap: gap || "12px",
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
borderRadius: borderRadius || undefined,
|
||||
padding: padding || undefined,
|
||||
fontSize: fontSize || undefined,
|
||||
}}
|
||||
>
|
||||
{aggregationResults.map((result, index) => (
|
||||
<div
|
||||
key={result.id || index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
||||
layout === "vertical" ? "w-full justify-between" : ""
|
||||
)}
|
||||
>
|
||||
{showIcons && (
|
||||
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
style={{ fontSize: labelFontSize, color: labelColor }}
|
||||
>
|
||||
{result.label} ({getTypeLabel(result.type)}):
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: valueFontSize, color: valueColor }}
|
||||
>
|
||||
{result.formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AggregationWidgetWrapper = AggregationWidgetComponent;
|
||||
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Plus, Trash2, GripVertical, Database, Table2, ChevronsUpDown, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AggregationWidgetConfig, AggregationItem, AggregationType } from "./types";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
interface AggregationWidgetConfigPanelProps {
|
||||
config: AggregationWidgetConfig;
|
||||
onChange: (config: Partial<AggregationWidgetConfig>) => void;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 위젯 설정 패널
|
||||
*/
|
||||
export function AggregationWidgetConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
}: AggregationWidgetConfigPanelProps) {
|
||||
const [columns, setColumns] = useState<Array<{ columnName: string; label?: string; dataType?: string; inputType?: string; webType?: string }>>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
|
||||
// 실제 사용할 테이블 이름 계산
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.tableName || screenTableName;
|
||||
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
|
||||
|
||||
// 화면 테이블명 자동 설정 (초기 한 번만)
|
||||
useEffect(() => {
|
||||
if (screenTableName && !config.tableName && !config.customTableName) {
|
||||
onChange({ tableName: screenTableName });
|
||||
}
|
||||
}, [screenTableName, config.tableName, config.customTableName, onChange]);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const fetchTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableTypeApi.getTables();
|
||||
setAvailableTables(
|
||||
response.map((table: any) => ({
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 가져오기 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
fetchTables();
|
||||
}, []);
|
||||
|
||||
// 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (result.success && result.data?.columns) {
|
||||
setColumns(
|
||||
result.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
label: col.displayName || col.columnLabel || col.column_label || col.label || col.columnName || col.column_name,
|
||||
dataType: col.dataType || col.data_type,
|
||||
inputType: col.inputType || col.input_type,
|
||||
webType: col.webType || col.web_type,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
// 집계 항목 추가
|
||||
const addItem = () => {
|
||||
const newItem: AggregationItem = {
|
||||
id: `agg-${Date.now()}`,
|
||||
columnName: "",
|
||||
columnLabel: "",
|
||||
type: "sum",
|
||||
format: "number",
|
||||
decimalPlaces: 0,
|
||||
};
|
||||
onChange({
|
||||
items: [...(config.items || []), newItem],
|
||||
});
|
||||
};
|
||||
|
||||
// 집계 항목 삭제
|
||||
const removeItem = (id: string) => {
|
||||
onChange({
|
||||
items: (config.items || []).filter((item) => item.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
// 집계 항목 업데이트
|
||||
const updateItem = (id: string, updates: Partial<AggregationItem>) => {
|
||||
onChange({
|
||||
items: (config.items || []).map((item) =>
|
||||
item.id === id ? { ...item, ...updates } : item
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// 숫자형 컬럼만 필터링 (count 제외) - 입력 타입(inputType/webType)으로만 확인
|
||||
const numericColumns = columns.filter((col) => {
|
||||
const inputType = (col.inputType || col.webType || "")?.toLowerCase();
|
||||
|
||||
return (
|
||||
inputType === "number" ||
|
||||
inputType === "decimal" ||
|
||||
inputType === "integer" ||
|
||||
inputType === "float" ||
|
||||
inputType === "currency" ||
|
||||
inputType === "percent"
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">집계 위젯 설정</div>
|
||||
|
||||
{/* 테이블 설정 (컴포넌트 개발 가이드 준수) */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">데이터 소스 테이블</h3>
|
||||
<p className="text-muted-foreground text-[10px]">집계할 데이터의 테이블을 선택합니다</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* 현재 선택된 테이블 표시 (카드 형태) */}
|
||||
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
|
||||
<Database className="h-4 w-4 text-blue-500" />
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium">
|
||||
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 Combobox */}
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
테이블 변경...
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
|
||||
{/* 그룹 1: 화면 기본 테이블 */}
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
key={`default-${screenTableName}`}
|
||||
value={screenTableName}
|
||||
onSelect={() => {
|
||||
onChange({
|
||||
useCustomTable: false,
|
||||
customTableName: undefined,
|
||||
tableName: screenTableName,
|
||||
items: [], // 테이블 변경 시 집계 항목 초기화
|
||||
});
|
||||
setTableComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!config.useCustomTable ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3 w-3 text-blue-500" />
|
||||
{screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* 그룹 2: 전체 테이블 */}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{availableTables
|
||||
.filter((table) => table.tableName !== screenTableName)
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName || ""}`}
|
||||
onSelect={() => {
|
||||
onChange({
|
||||
useCustomTable: true,
|
||||
customTableName: table.tableName,
|
||||
tableName: table.tableName,
|
||||
items: [], // 테이블 변경 시 집계 항목 초기화
|
||||
});
|
||||
setTableComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">레이아웃</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">배치 방향</Label>
|
||||
<Select
|
||||
value={config.layout || "horizontal"}
|
||||
onValueChange={(value) => onChange({ layout: value as "horizontal" | "vertical" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horizontal">가로 배치</SelectItem>
|
||||
<SelectItem value="vertical">세로 배치</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">항목 간격</Label>
|
||||
<Input
|
||||
value={config.gap || "16px"}
|
||||
onChange={(e) => onChange({ gap: e.target.value })}
|
||||
placeholder="16px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showLabels"
|
||||
checked={config.showLabels ?? true}
|
||||
onCheckedChange={(checked) => onChange({ showLabels: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showLabels" className="text-xs">
|
||||
라벨 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showIcons"
|
||||
checked={config.showIcons ?? true}
|
||||
onCheckedChange={(checked) => onChange({ showIcons: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showIcons" className="text-xs">
|
||||
아이콘 표시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 집계 항목 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">집계 항목</h3>
|
||||
<Button variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{(config.items || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||
집계 항목을 추가해주세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(config.items || []).map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-md border bg-slate-50 p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
|
||||
<span className="text-xs font-medium">항목 {index + 1}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="h-6 w-6 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 컬럼 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">컬럼</Label>
|
||||
<Select
|
||||
value={item.columnName}
|
||||
onValueChange={(value) => {
|
||||
const col = columns.find((c) => c.columnName === value);
|
||||
updateItem(item.id, {
|
||||
columnName: value,
|
||||
columnLabel: col?.label || value,
|
||||
});
|
||||
}}
|
||||
disabled={loadingColumns || columns.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder={
|
||||
loadingColumns
|
||||
? "로딩 중..."
|
||||
: columns.length === 0
|
||||
? "테이블을 선택하세요"
|
||||
: "컬럼 선택"
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(item.type === "count" ? columns : numericColumns).length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
{item.type === "count"
|
||||
? "컬럼이 없습니다"
|
||||
: "숫자형 컬럼이 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
(item.type === "count" ? columns : numericColumns).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.label || col.columnName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 집계 타입 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">집계 타입</Label>
|
||||
<Select
|
||||
value={item.type}
|
||||
onValueChange={(value) => updateItem(item.id, { type: value as AggregationType })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sum">합계 (SUM)</SelectItem>
|
||||
<SelectItem value="avg">평균 (AVG)</SelectItem>
|
||||
<SelectItem value="count">개수 (COUNT)</SelectItem>
|
||||
<SelectItem value="max">최대 (MAX)</SelectItem>
|
||||
<SelectItem value="min">최소 (MIN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 라벨 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">표시 라벨</Label>
|
||||
<Input
|
||||
value={item.columnLabel || ""}
|
||||
onChange={(e) => updateItem(item.id, { columnLabel: e.target.value })}
|
||||
placeholder="표시될 라벨"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 표시 형식 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">표시 형식</Label>
|
||||
<Select
|
||||
value={item.format || "number"}
|
||||
onValueChange={(value) =>
|
||||
updateItem(item.id, { format: value as "number" | "currency" | "percent" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="currency">통화</SelectItem>
|
||||
<SelectItem value="percent">퍼센트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 접두사 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">접두사</Label>
|
||||
<Input
|
||||
value={item.prefix || ""}
|
||||
onChange={(e) => updateItem(item.id, { prefix: e.target.value })}
|
||||
placeholder="예: ₩"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 접미사 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">접미사</Label>
|
||||
<Input
|
||||
value={item.suffix || ""}
|
||||
onChange={(e) => updateItem(item.id, { suffix: e.target.value })}
|
||||
placeholder="예: 원, 개"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">스타일</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config.backgroundColor || "#f8fafc"}
|
||||
onChange={(e) => onChange({ backgroundColor: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">모서리 둥글기</Label>
|
||||
<Input
|
||||
value={config.borderRadius || "6px"}
|
||||
onChange={(e) => onChange({ borderRadius: e.target.value })}
|
||||
placeholder="6px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config.labelColor || "#64748b"}
|
||||
onChange={(e) => onChange({ labelColor: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">값 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config.valueColor || "#0f172a"}
|
||||
onChange={(e) => onChange({ valueColor: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { V2AggregationWidgetDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(V2AggregationWidgetDefinition);
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { AggregationWidgetWrapper } from "./AggregationWidgetComponent";
|
||||
import { AggregationWidgetConfigPanel } from "./AggregationWidgetConfigPanel";
|
||||
import type { AggregationWidgetConfig } from "./types";
|
||||
|
||||
/**
|
||||
* AggregationWidget 컴포넌트 정의
|
||||
* 데이터 집계 (합계, 평균, 개수 등)를 표시하는 위젯
|
||||
*/
|
||||
export const V2AggregationWidgetDefinition = createComponentDefinition({
|
||||
id: "v2-aggregation-widget",
|
||||
name: "집계 위젯",
|
||||
nameEng: "Aggregation Widget",
|
||||
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: AggregationWidgetWrapper,
|
||||
defaultConfig: {
|
||||
dataSourceType: "manual",
|
||||
items: [],
|
||||
layout: "horizontal",
|
||||
showLabels: true,
|
||||
showIcons: true,
|
||||
gap: "16px",
|
||||
backgroundColor: "#f8fafc",
|
||||
borderRadius: "6px",
|
||||
padding: "12px",
|
||||
} as Partial<AggregationWidgetConfig>,
|
||||
defaultSize: { width: 400, height: 60 },
|
||||
configPanel: AggregationWidgetConfigPanel,
|
||||
icon: "Calculator",
|
||||
tags: ["집계", "합계", "평균", "개수", "통계", "데이터"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { AggregationWidgetConfig, AggregationItem, AggregationType, AggregationResult } from "./types";
|
||||
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 집계 타입
|
||||
*/
|
||||
export type AggregationType = "sum" | "avg" | "count" | "max" | "min";
|
||||
|
||||
/**
|
||||
* 개별 집계 항목 설정
|
||||
*/
|
||||
export interface AggregationItem {
|
||||
id: string;
|
||||
columnName: string; // 집계할 컬럼
|
||||
columnLabel?: string; // 표시 라벨
|
||||
labelLangKeyId?: number; // 다국어 키 ID
|
||||
labelLangKey?: string; // 다국어 키
|
||||
type: AggregationType; // 집계 타입
|
||||
format?: "number" | "currency" | "percent"; // 표시 형식
|
||||
decimalPlaces?: number; // 소수점 자릿수
|
||||
prefix?: string; // 접두사 (예: "₩")
|
||||
suffix?: string; // 접미사 (예: "원", "개")
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 위젯 설정
|
||||
*/
|
||||
export interface AggregationWidgetConfig extends ComponentConfig {
|
||||
// 데이터 소스 설정
|
||||
dataSourceType: "repeater" | "tableList" | "manual"; // 데이터 소스 타입
|
||||
dataSourceComponentId?: string; // 연결할 컴포넌트 ID (repeater 또는 tableList)
|
||||
|
||||
// 컴포넌트별 테이블 설정 (개발 가이드 준수)
|
||||
tableName?: string; // 사용할 테이블명
|
||||
customTableName?: string; // 커스텀 테이블명
|
||||
useCustomTable?: boolean; // true: customTableName 사용
|
||||
|
||||
// 집계 항목들
|
||||
items: AggregationItem[];
|
||||
|
||||
// 레이아웃 설정
|
||||
layout: "horizontal" | "vertical"; // 배치 방향
|
||||
showLabels: boolean; // 라벨 표시 여부
|
||||
showIcons: boolean; // 아이콘 표시 여부
|
||||
gap?: string; // 항목 간 간격
|
||||
|
||||
// 스타일 설정
|
||||
backgroundColor?: string;
|
||||
borderRadius?: string;
|
||||
padding?: string;
|
||||
fontSize?: string;
|
||||
labelFontSize?: string;
|
||||
valueFontSize?: string;
|
||||
labelColor?: string;
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 결과
|
||||
*/
|
||||
export interface AggregationResult {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number | string;
|
||||
formattedValue: string;
|
||||
type: AggregationType;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2ButtonPrimaryDefinition } from "./index";
|
||||
import { ButtonPrimaryComponent } from "./ButtonPrimaryComponent";
|
||||
|
||||
/**
|
||||
* ButtonPrimary 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class ButtonPrimaryRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2ButtonPrimaryDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <ButtonPrimaryComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// button 타입 특화 속성 처리
|
||||
protected getButtonPrimaryProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// button 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 button 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
ButtonPrimaryRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
ButtonPrimaryRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# ButtonPrimary 컴포넌트
|
||||
|
||||
button-primary 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `button-primary`
|
||||
- **카테고리**: action
|
||||
- **웹타입**: button
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { ButtonPrimaryComponent } from "@/lib/registry/components/button-primary";
|
||||
|
||||
<ButtonPrimaryComponent
|
||||
component={{
|
||||
id: "my-button-primary",
|
||||
type: "widget",
|
||||
webType: "button",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 120, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| text | string | "버튼" | 버튼 텍스트 |
|
||||
| actionType | string | "button" | 버튼 타입 |
|
||||
| variant | string | "primary" | 버튼 스타일 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<ButtonPrimaryComponent
|
||||
component={{
|
||||
id: "sample-button-primary",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js button-primary --category=action --webType=button`
|
||||
- **경로**: `lib/registry/components/button-primary/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/button-primary)
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { ButtonPrimaryConfig } from "./types";
|
||||
|
||||
/**
|
||||
* ButtonPrimary 컴포넌트 기본 설정
|
||||
*/
|
||||
export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = {
|
||||
text: "버튼",
|
||||
actionType: "button",
|
||||
variant: "primary",
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* ButtonPrimary 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const ButtonPrimaryConfigSchema = {
|
||||
text: { type: "string", default: "버튼" },
|
||||
actionType: {
|
||||
type: "enum",
|
||||
values: ["button", "submit", "reset"],
|
||||
default: "button"
|
||||
},
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["primary", "secondary", "danger"],
|
||||
default: "primary"
|
||||
},
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { ButtonPrimaryWrapper } from "./ButtonPrimaryComponent";
|
||||
import { ButtonPrimaryConfig } from "./types";
|
||||
|
||||
/**
|
||||
* ButtonPrimary 컴포넌트 정의
|
||||
* button-primary 컴포넌트입니다
|
||||
*/
|
||||
export const V2ButtonPrimaryDefinition = createComponentDefinition({
|
||||
id: "v2-button-primary",
|
||||
name: "기본 버튼",
|
||||
nameEng: "ButtonPrimary Component",
|
||||
description: "일반적인 액션을 위한 기본 버튼 컴포넌트",
|
||||
category: ComponentCategory.ACTION,
|
||||
webType: "button",
|
||||
component: ButtonPrimaryWrapper,
|
||||
defaultConfig: {
|
||||
text: "저장",
|
||||
actionType: "button",
|
||||
variant: "primary",
|
||||
action: {
|
||||
type: "save",
|
||||
successMessage: "저장되었습니다.",
|
||||
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||
},
|
||||
},
|
||||
defaultSize: { width: 120, height: 40 },
|
||||
configPanel: undefined, // 상세 설정 패널(ButtonConfigPanel)이 대신 사용됨
|
||||
icon: "MousePointer",
|
||||
tags: ["버튼", "액션", "클릭"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/button-primary",
|
||||
});
|
||||
|
||||
// 컴포넌트는 ButtonPrimaryRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { ButtonPrimaryConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { ButtonPrimaryComponent } from "./ButtonPrimaryComponent";
|
||||
export { ButtonPrimaryRenderer } from "./ButtonPrimaryRenderer";
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
import { ButtonActionConfig } from "@/lib/utils/buttonActions";
|
||||
|
||||
/**
|
||||
* ButtonPrimary 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface ButtonPrimaryConfig extends ComponentConfig {
|
||||
// 버튼 관련 설정
|
||||
text?: string;
|
||||
actionType?: "button" | "submit" | "reset";
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
|
||||
// 버튼 액션 설정
|
||||
action?: ButtonActionConfig;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ButtonPrimary 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface ButtonPrimaryProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: ButtonPrimaryConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,732 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Trash2, Database, ChevronsUpDown, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardDisplayConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
}
|
||||
|
||||
interface EntityJoinColumn {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}
|
||||
|
||||
interface JoinTable {
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: {
|
||||
sourceColumn: string;
|
||||
};
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 설정 패널
|
||||
* 카드 레이아웃과 동일한 설정 UI 제공 + 엔티티 조인 컬럼 지원
|
||||
*/
|
||||
export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
tableColumns = [],
|
||||
}) => {
|
||||
// 테이블 선택 상태
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 엔티티 조인 컬럼 상태
|
||||
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||
availableColumns: EntityJoinColumn[];
|
||||
joinTables: JoinTable[];
|
||||
}>({ availableColumns: [], joinTables: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
// 현재 사용할 테이블명
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.tableName || screenTableName;
|
||||
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 커스텀 테이블이 아니면 props로 받은 tableColumns 사용
|
||||
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
|
||||
setAvailableColumns(tableColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (result.success && result.data?.columns) {
|
||||
setAvailableColumns(result.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setAvailableColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [targetTableName, config.useCustomTable, tableColumns]);
|
||||
|
||||
// 엔티티 조인 컬럼 정보 가져오기
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
|
||||
setEntityJoinColumns({
|
||||
availableColumns: result.availableColumns || [],
|
||||
joinTables: result.joinTables || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 컬럼 조회 오류:", error);
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEntityJoinColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableSelect = (tableName: string, isScreenTable: boolean) => {
|
||||
if (isScreenTable) {
|
||||
// 화면 기본 테이블 선택
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: false,
|
||||
customTableName: undefined,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
|
||||
});
|
||||
} else {
|
||||
// 다른 테이블 선택
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: true,
|
||||
customTableName: tableName,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
|
||||
});
|
||||
}
|
||||
setTableComboboxOpen(false);
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블 표시명 가져오기
|
||||
const getSelectedTableDisplay = () => {
|
||||
if (!targetTableName) return "테이블을 선택하세요";
|
||||
const found = allTables.find(t => t.tableName === targetTableName);
|
||||
return found?.displayName || targetTableName;
|
||||
};
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
onChange({ ...config, [key]: value });
|
||||
};
|
||||
|
||||
const handleNestedChange = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
let newConfig = { ...config };
|
||||
let current = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트
|
||||
const handleColumnSelect = (path: string, columnName: string) => {
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === columnName
|
||||
);
|
||||
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const existingJoinColumn = joinColumnsConfig.find(
|
||||
(jc: any) => jc.columnName === columnName
|
||||
);
|
||||
|
||||
if (!existingJoinColumn) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: {
|
||||
...config.columnMapping,
|
||||
[path.split(".")[1]]: columnName,
|
||||
},
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleNestedChange(path, columnName);
|
||||
};
|
||||
|
||||
// 표시 컬럼 추가
|
||||
const addDisplayColumn = () => {
|
||||
const currentColumns = config.columnMapping?.displayColumns || [];
|
||||
const newColumns = [...currentColumns, ""];
|
||||
handleNestedChange("columnMapping.displayColumns", newColumns);
|
||||
};
|
||||
|
||||
// 표시 컬럼 삭제
|
||||
const removeDisplayColumn = (index: number) => {
|
||||
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
||||
currentColumns.splice(index, 1);
|
||||
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
||||
};
|
||||
|
||||
// 표시 컬럼 값 변경
|
||||
const updateDisplayColumn = (index: number, value: string) => {
|
||||
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
||||
currentColumns[index] = value;
|
||||
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === value
|
||||
);
|
||||
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const existingJoinColumn = joinColumnsConfig.find(
|
||||
(jc: any) => jc.columnName === value
|
||||
);
|
||||
|
||||
if (!existingJoinColumn) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: {
|
||||
...config.columnMapping,
|
||||
displayColumns: currentColumns,
|
||||
},
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
||||
};
|
||||
|
||||
// 테이블별로 조인 컬럼 그룹화
|
||||
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
|
||||
entityJoinColumns.availableColumns.forEach((col) => {
|
||||
if (!joinColumnsByTable[col.tableName]) {
|
||||
joinColumnsByTable[col.tableName] = [];
|
||||
}
|
||||
joinColumnsByTable[col.tableName].push(col);
|
||||
});
|
||||
|
||||
// 현재 사용할 컬럼 목록 (커스텀 테이블이면 로드한 컬럼, 아니면 props)
|
||||
const currentTableColumns = config.useCustomTable ? availableColumns : (tableColumns.length > 0 ? tableColumns : availableColumns);
|
||||
|
||||
// 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI)
|
||||
const renderColumnSelect = (
|
||||
value: string,
|
||||
onChangeHandler: (value: string) => void,
|
||||
placeholder: string = "컬럼을 선택하세요"
|
||||
) => {
|
||||
return (
|
||||
<Select
|
||||
value={value || "__none__"}
|
||||
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 선택 안함 옵션 */}
|
||||
<SelectItem value="__none__" className="text-xs text-muted-foreground">
|
||||
선택 안함
|
||||
</SelectItem>
|
||||
|
||||
{/* 기본 테이블 컬럼 */}
|
||||
{currentTableColumns.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs font-semibold text-muted-foreground">
|
||||
기본 컬럼
|
||||
</SelectLabel>
|
||||
{currentTableColumns.map((column: any) => (
|
||||
<SelectItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{column.columnLabel || column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* 조인 테이블별 컬럼 */}
|
||||
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
|
||||
<SelectGroup key={tableName}>
|
||||
<SelectLabel className="text-xs font-semibold text-blue-600">
|
||||
{tableName} (조인)
|
||||
</SelectLabel>
|
||||
{columns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.joinAlias}
|
||||
value={col.joinAlias}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.suggestedLabel || col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">카드 디스플레이 설정</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 테이블</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getSelectedTableDisplay()}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
|
||||
{/* 화면 기본 테이블 */}
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
value={screenTableName}
|
||||
onSelect={() => handleTableSelect(screenTableName, true)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTableName === screenTableName && !config.useCustomTable
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
|
||||
{allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* 전체 테이블 */}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{allTables
|
||||
.filter(t => t.tableName !== screenTableName)
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => handleTableSelect(table.tableName, false)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.useCustomTable && targetTableName === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{table.displayName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.useCustomTable && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
|
||||
{(currentTableColumns.length > 0 || loadingColumns) && (
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">컬럼 매핑</h5>
|
||||
|
||||
{(loadingEntityJoins || loadingColumns) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타이틀 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.titleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.titleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">서브타이틀 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.subtitleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">설명 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.descriptionColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">이미지 컬럼</Label>
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.imageColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.imageColumn", value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 동적 표시 컬럼 추가 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">표시 컬럼들</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addDisplayColumn}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
+ 컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.columnMapping?.displayColumns || []).map((column: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{renderColumnSelect(
|
||||
column,
|
||||
(value) => updateDisplayColumn(index, value)
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisplayColumn(index)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
|
||||
"컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 스타일 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">카드 스타일</h5>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">한 행당 카드 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
value={config.cardsPerRow || 3}
|
||||
onChange={(e) => handleChange("cardsPerRow", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">카드 간격 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.cardSpacing || 16}
|
||||
onChange={(e) => handleChange("cardSpacing", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showTitle"
|
||||
checked={config.cardStyle?.showTitle ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showTitle", checked)}
|
||||
/>
|
||||
<Label htmlFor="showTitle" className="text-xs font-normal">
|
||||
타이틀 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showSubtitle"
|
||||
checked={config.cardStyle?.showSubtitle ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showSubtitle", checked)}
|
||||
/>
|
||||
<Label htmlFor="showSubtitle" className="text-xs font-normal">
|
||||
서브타이틀 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showDescription"
|
||||
checked={config.cardStyle?.showDescription ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDescription", checked)}
|
||||
/>
|
||||
<Label htmlFor="showDescription" className="text-xs font-normal">
|
||||
설명 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showImage"
|
||||
checked={config.cardStyle?.showImage ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showImage", checked)}
|
||||
/>
|
||||
<Label htmlFor="showImage" className="text-xs font-normal">
|
||||
이미지 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showActions"
|
||||
checked={config.cardStyle?.showActions ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showActions", checked)}
|
||||
/>
|
||||
<Label htmlFor="showActions" className="text-xs font-normal">
|
||||
액션 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 개별 버튼 설정 */}
|
||||
{(config.cardStyle?.showActions ?? true) && (
|
||||
<div className="ml-5 space-y-2 border-l-2 border-muted pl-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showViewButton"
|
||||
checked={config.cardStyle?.showViewButton ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showViewButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showViewButton" className="text-xs font-normal">
|
||||
상세보기 버튼
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showEditButton"
|
||||
checked={config.cardStyle?.showEditButton ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showEditButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showEditButton" className="text-xs font-normal">
|
||||
편집 버튼
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showDeleteButton"
|
||||
checked={config.cardStyle?.showDeleteButton ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDeleteButton", checked)}
|
||||
/>
|
||||
<Label htmlFor="showDeleteButton" className="text-xs font-normal">
|
||||
삭제 버튼
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">설명 최대 길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={config.cardStyle?.maxDescriptionLength || 100}
|
||||
onChange={(e) => handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">공통 설정</h5>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="disabled" className="text-xs font-normal">
|
||||
비활성화
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs font-normal">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2CardDisplayDefinition } from "./index";
|
||||
import { CardDisplayComponent } from "./CardDisplayComponent";
|
||||
|
||||
/**
|
||||
* CardDisplay 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class CardDisplayRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2CardDisplayDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <CardDisplayComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getCardDisplayProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
CardDisplayRenderer.registerSelf();
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# CardDisplay 컴포넌트
|
||||
|
||||
테이블 데이터를 카드 형태로 표시하는 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `card-display`
|
||||
- **카테고리**: display
|
||||
- **웹타입**: text
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { CardDisplayComponent } from "@/lib/registry/components/card-display";
|
||||
|
||||
<CardDisplayComponent
|
||||
component={{
|
||||
id: "my-card-display",
|
||||
type: "widget",
|
||||
webType: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| maxLength | number | 255 | 최대 입력 길이 |
|
||||
| minLength | number | 0 | 최소 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<CardDisplayComponent
|
||||
component={{
|
||||
id: "sample-card-display",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-15
|
||||
- **CLI 명령어**: `node scripts/create-component.js card-display "카드 디스플레이" "테이블 데이터를 카드 형태로 표시하는 컴포넌트" display text`
|
||||
- **경로**: `lib/registry/components/card-display/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/card-display)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { CardDisplayComponent } from "./CardDisplayComponent";
|
||||
import { CardDisplayConfigPanel } from "./CardDisplayConfigPanel";
|
||||
import { CardDisplayConfig } from "./types";
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 정의
|
||||
* 테이블 데이터를 카드 형태로 표시하는 컴포넌트
|
||||
*/
|
||||
export const V2CardDisplayDefinition = createComponentDefinition({
|
||||
id: "v2-card-display",
|
||||
name: "카드 디스플레이",
|
||||
nameEng: "CardDisplay Component",
|
||||
description: "테이블 데이터를 카드 형태로 표시하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: CardDisplayComponent,
|
||||
defaultConfig: {
|
||||
cardsPerRow: 3, // 기본값 3 (한 행당 카드 수)
|
||||
cardSpacing: 16,
|
||||
cardStyle: {
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showDescription: true,
|
||||
showImage: false,
|
||||
showActions: true,
|
||||
maxDescriptionLength: 100,
|
||||
imagePosition: "top",
|
||||
imageSize: "medium",
|
||||
},
|
||||
columnMapping: {},
|
||||
dataSource: "table",
|
||||
staticData: [],
|
||||
},
|
||||
defaultSize: { width: 800, height: 400 },
|
||||
configPanel: CardDisplayConfigPanel,
|
||||
icon: "Grid3x3",
|
||||
tags: ["card", "display", "table", "grid"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation:
|
||||
"테이블 데이터를 카드 형태로 표시하는 컴포넌트입니다. 레이아웃과 다르게 컴포넌트로서 재사용 가능하며, 다양한 설정이 가능합니다.",
|
||||
});
|
||||
|
||||
// 컴포넌트는 CardDisplayRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { CardDisplayConfig } from "./types";
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 카드 스타일 설정
|
||||
*/
|
||||
export interface CardStyleConfig {
|
||||
showTitle?: boolean;
|
||||
showSubtitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showImage?: boolean;
|
||||
maxDescriptionLength?: number;
|
||||
imagePosition?: "top" | "left" | "right";
|
||||
imageSize?: "small" | "medium" | "large";
|
||||
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
|
||||
showViewButton?: boolean; // 상세보기 버튼 표시 여부
|
||||
showEditButton?: boolean; // 편집 버튼 표시 여부
|
||||
showDeleteButton?: boolean; // 삭제 버튼 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 설정
|
||||
*/
|
||||
export interface ColumnMappingConfig {
|
||||
titleColumn?: string;
|
||||
subtitleColumn?: string;
|
||||
descriptionColumn?: string;
|
||||
imageColumn?: string;
|
||||
displayColumns?: string[];
|
||||
actionColumns?: string[]; // 액션 버튼으로 표시할 컬럼들
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface CardDisplayConfig extends ComponentConfig {
|
||||
// 카드 레이아웃 설정
|
||||
cardsPerRow?: number;
|
||||
cardSpacing?: number;
|
||||
|
||||
// 카드 스타일 설정
|
||||
cardStyle?: CardStyleConfig;
|
||||
|
||||
// 컬럼 매핑 설정
|
||||
columnMapping?: ColumnMappingConfig;
|
||||
|
||||
// 컴포넌트별 테이블 설정
|
||||
useCustomTable?: boolean;
|
||||
customTableName?: string;
|
||||
tableName?: string;
|
||||
isReadOnly?: boolean;
|
||||
|
||||
// 테이블 데이터 설정
|
||||
dataSource?: "static" | "table" | "api";
|
||||
tableId?: string;
|
||||
staticData?: any[];
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onCardClick?: (data: any) => void;
|
||||
onCardHover?: (data: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardDisplay 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface CardDisplayProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: CardDisplayConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { DividerLineConfig } from "./types";
|
||||
|
||||
export interface DividerLineComponentProps extends ComponentRendererProps {
|
||||
config?: DividerLineConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* DividerLine 컴포넌트
|
||||
* divider-line 컴포넌트입니다
|
||||
*/
|
||||
export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as DividerLineConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
// 추가된 props 필터링
|
||||
webType: _webType,
|
||||
autoGeneration: _autoGeneration,
|
||||
isInteractive: _isInteractive,
|
||||
formData: _formData,
|
||||
onFormDataChange: _onFormDataChange,
|
||||
menuId: _menuId,
|
||||
menuObjid: _menuObjid,
|
||||
onSave: _onSave,
|
||||
userId: _userId,
|
||||
userName: _userName,
|
||||
companyCode: _companyCode,
|
||||
isInModal: _isInModal,
|
||||
readonly: _readonly,
|
||||
originalData: _originalData,
|
||||
_originalData: __originalData,
|
||||
_initialData: __initialData,
|
||||
_groupedData: __groupedData,
|
||||
allComponents: _allComponents,
|
||||
onUpdateLayout: _onUpdateLayout,
|
||||
selectedRows: _selectedRows,
|
||||
selectedRowsData: _selectedRowsData,
|
||||
onSelectedRowsChange: _onSelectedRowsChange,
|
||||
sortBy: _sortBy,
|
||||
sortOrder: _sortOrder,
|
||||
tableDisplayData: _tableDisplayData,
|
||||
flowSelectedData: _flowSelectedData,
|
||||
flowSelectedStepId: _flowSelectedStepId,
|
||||
onFlowSelectedDataChange: _onFlowSelectedDataChange,
|
||||
onConfigChange: _onConfigChange,
|
||||
refreshKey: _refreshKey,
|
||||
flowRefreshKey: _flowRefreshKey,
|
||||
onFlowRefresh: _onFlowRefresh,
|
||||
isPreview: _isPreview,
|
||||
groupedData: _groupedData,
|
||||
...domProps
|
||||
} = props as any;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#64748b",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{componentConfig.dividerText ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "1px",
|
||||
backgroundColor: componentConfig.color || "#d1d5db",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: componentConfig.textColor || "#6b7280",
|
||||
fontSize: "14px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{componentConfig.dividerText}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "1px",
|
||||
backgroundColor: componentConfig.color || "#d1d5db",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: componentConfig.thickness || "1px",
|
||||
backgroundColor: componentConfig.color || "#d1d5db",
|
||||
borderRadius: componentConfig.rounded ? "999px" : "0",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* DividerLine 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const DividerLineWrapper: React.FC<DividerLineComponentProps> = (props) => {
|
||||
return <DividerLineComponent {...props} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DividerLineConfig } from "./types";
|
||||
|
||||
export interface DividerLineConfigPanelProps {
|
||||
config: DividerLineConfig;
|
||||
onChange: (config: Partial<DividerLineConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DividerLine 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const DividerLineConfigPanel: React.FC<DividerLineConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof DividerLineConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
divider-line 설정
|
||||
</div>
|
||||
|
||||
{/* 텍스트 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxLength">최대 길이</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
value={config.maxLength || ""}
|
||||
onChange={(e) => handleChange("maxLength", parseInt(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2DividerLineDefinition } from "./index";
|
||||
import { DividerLineComponent } from "./DividerLineComponent";
|
||||
|
||||
/**
|
||||
* DividerLine 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class DividerLineRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2DividerLineDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <DividerLineComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getDividerLineProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
DividerLineRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
DividerLineRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# DividerLine 컴포넌트
|
||||
|
||||
divider-line 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `divider-line`
|
||||
- **카테고리**: layout
|
||||
- **웹타입**: text
|
||||
- **작성자**: Developer
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { DividerLineComponent } from "@/lib/registry/components/divider-line";
|
||||
|
||||
<DividerLineComponent
|
||||
component={{
|
||||
id: "my-divider-line",
|
||||
type: "widget",
|
||||
webType: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| maxLength | number | 255 | 최대 입력 길이 |
|
||||
| minLength | number | 0 | 최소 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<DividerLineComponent
|
||||
component={{
|
||||
id: "sample-divider-line",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js divider-line --category=layout --webType=text`
|
||||
- **경로**: `lib/registry/components/divider-line/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/divider-line)
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { DividerLineConfig } from "./types";
|
||||
|
||||
/**
|
||||
* DividerLine 컴포넌트 기본 설정
|
||||
*/
|
||||
export const DividerLineDefaultConfig: DividerLineConfig = {
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* DividerLine 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const DividerLineConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
maxLength: { type: "number", min: 1 },
|
||||
minLength: { type: "number", min: 0 },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { DividerLineWrapper } from "./DividerLineComponent";
|
||||
import { DividerLineConfigPanel } from "./DividerLineConfigPanel";
|
||||
import { DividerLineConfig } from "./types";
|
||||
|
||||
/**
|
||||
* DividerLine 컴포넌트 정의
|
||||
* divider-line 컴포넌트입니다
|
||||
*/
|
||||
export const V2DividerLineDefinition = createComponentDefinition({
|
||||
id: "v2-divider-line",
|
||||
name: "구분선",
|
||||
nameEng: "DividerLine Component",
|
||||
description: "영역 구분을 위한 구분선 컴포넌트",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "text",
|
||||
component: DividerLineWrapper,
|
||||
defaultConfig: {
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
},
|
||||
defaultSize: { width: 400, height: 2 },
|
||||
configPanel: DividerLineConfigPanel,
|
||||
icon: "Layout",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "Developer",
|
||||
documentation: "https://docs.example.com/components/divider-line",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { DividerLineConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { DividerLineComponent } from "./DividerLineComponent";
|
||||
export { DividerLineRenderer } from "./DividerLineRenderer";
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* DividerLine 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface DividerLineConfig extends ComponentConfig {
|
||||
// 텍스트 관련 설정
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DividerLine 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface DividerLineProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: DividerLineConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,645 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ArrowLeftRight, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DataSourceConfig {
|
||||
type: "table" | "code" | "static";
|
||||
tableName?: string;
|
||||
valueField?: string;
|
||||
labelField?: string;
|
||||
codeCategory?: string;
|
||||
staticOptions?: LocationOption[];
|
||||
}
|
||||
|
||||
export interface LocationSwapSelectorProps {
|
||||
// 기본 props
|
||||
id?: string;
|
||||
style?: React.CSSProperties;
|
||||
isDesignMode?: boolean;
|
||||
|
||||
// 데이터 소스 설정
|
||||
dataSource?: DataSourceConfig;
|
||||
|
||||
// 필드 매핑
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
|
||||
// UI 설정
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
|
||||
// 폼 데이터
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
|
||||
// 🆕 사용자 정보 (DB에서 초기값 로드용)
|
||||
userId?: string;
|
||||
|
||||
// componentConfig (화면 디자이너에서 전달)
|
||||
componentConfig?: {
|
||||
dataSource?: DataSourceConfig;
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
// 🆕 DB 초기값 로드 설정
|
||||
loadFromDb?: boolean; // DB에서 초기값 로드 여부
|
||||
dbTableName?: string; // 조회할 테이블명 (기본: vehicles)
|
||||
dbKeyField?: string; // 키 필드 (기본: user_id)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트
|
||||
* 출발지/도착지 선택 및 교환 기능
|
||||
*/
|
||||
export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {
|
||||
const {
|
||||
id,
|
||||
style,
|
||||
isDesignMode = false,
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
componentConfig,
|
||||
userId,
|
||||
} = props;
|
||||
|
||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||
const config = componentConfig || {};
|
||||
const dataSource = config.dataSource || props.dataSource || { type: "static", staticOptions: [] };
|
||||
const departureField = config.departureField || props.departureField || "departure";
|
||||
const destinationField = config.destinationField || props.destinationField || "destination";
|
||||
const departureLabelField = config.departureLabelField || props.departureLabelField;
|
||||
const destinationLabelField = config.destinationLabelField || props.destinationLabelField;
|
||||
const departureLabel = config.departureLabel || props.departureLabel || "출발지";
|
||||
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
||||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||
const variant = config.variant || props.variant || "card";
|
||||
|
||||
// 🆕 DB 초기값 로드 설정
|
||||
const loadFromDb = config.loadFromDb !== false; // 기본값 true
|
||||
const dbTableName = config.dbTableName || "vehicles";
|
||||
const dbKeyField = config.dbKeyField || "user_id";
|
||||
|
||||
// 기본 옵션 (포항/광양) - 한글로 저장
|
||||
const DEFAULT_OPTIONS: LocationOption[] = [
|
||||
{ value: "포항", label: "포항" },
|
||||
{ value: "광양", label: "광양" },
|
||||
];
|
||||
|
||||
// 상태
|
||||
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
|
||||
|
||||
// 로컬 선택 상태 (Select 컴포넌트용)
|
||||
const [localDeparture, setLocalDeparture] = useState<string>("");
|
||||
const [localDestination, setLocalDestination] = useState<string>("");
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
|
||||
|
||||
// 정적 옵션 처리 (기본값)
|
||||
// type이 없거나 static이거나, table인데 tableName이 없는 경우
|
||||
const shouldUseStatic =
|
||||
!dataSource.type ||
|
||||
dataSource.type === "static" ||
|
||||
(dataSource.type === "table" && !dataSource.tableName) ||
|
||||
(dataSource.type === "code" && !dataSource.codeCategory);
|
||||
|
||||
if (shouldUseStatic) {
|
||||
const staticOpts = dataSource.staticOptions || [];
|
||||
// 정적 옵션이 설정되어 있고, value가 유효한 경우 사용
|
||||
// (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용)
|
||||
const isValidOptions = staticOpts.length > 0 &&
|
||||
staticOpts[0]?.value &&
|
||||
staticOpts[0].value !== departureField &&
|
||||
staticOpts[0].value !== destinationField;
|
||||
|
||||
if (isValidOptions) {
|
||||
console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts);
|
||||
setOptions(staticOpts);
|
||||
} else {
|
||||
// 기본값 (포항/광양)
|
||||
console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS });
|
||||
setOptions(DEFAULT_OPTIONS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "code" && dataSource.codeCategory) {
|
||||
// 코드 관리에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/code-management/codes`, {
|
||||
params: { categoryCode: dataSource.codeCategory },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
const codeOptions = response.data.data.map((code: any) => ({
|
||||
value: code.code_value || code.codeValue || code.code,
|
||||
label: code.code_name || code.codeName || code.name,
|
||||
}));
|
||||
setOptions(codeOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "table" && dataSource.tableName) {
|
||||
// 테이블에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
|
||||
params: { page: 1, pageSize: 1000 },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
// data가 배열인지 또는 data.rows인지 확인
|
||||
const rows = Array.isArray(response.data.data)
|
||||
? response.data.data
|
||||
: response.data.data.rows || [];
|
||||
const tableOptions = rows.map((row: any) => ({
|
||||
value: String(row[dataSource.valueField || "id"] || ""),
|
||||
label: String(row[dataSource.labelField || "name"] || ""),
|
||||
}));
|
||||
setOptions(tableOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadOptions();
|
||||
}, [dataSource, isDesignMode]);
|
||||
|
||||
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
|
||||
useEffect(() => {
|
||||
const loadFromDatabase = async () => {
|
||||
// 디자인 모드이거나, DB 로드 비활성화이거나, userId가 없으면 스킵
|
||||
if (isDesignMode || !loadFromDb || !userId) {
|
||||
console.log("[LocationSwapSelector] DB 로드 스킵:", { isDesignMode, loadFromDb, userId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로드했으면 스킵
|
||||
if (dbLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[LocationSwapSelector] DB에서 출발지/목적지 로드 시작:", { dbTableName, dbKeyField, userId });
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${dbTableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 1,
|
||||
search: { [dbKeyField]: userId },
|
||||
autoFilter: true,
|
||||
}
|
||||
);
|
||||
|
||||
const vehicleData = response.data?.data?.data?.[0] || response.data?.data?.rows?.[0];
|
||||
|
||||
if (vehicleData) {
|
||||
const dbDeparture = vehicleData[departureField] || vehicleData.departure;
|
||||
const dbDestination = vehicleData[destinationField] || vehicleData.arrival || vehicleData.destination;
|
||||
|
||||
console.log("[LocationSwapSelector] DB에서 로드된 값:", { dbDeparture, dbDestination });
|
||||
|
||||
// DB에 값이 있으면 로컬 상태 및 formData 업데이트
|
||||
if (dbDeparture && options.some(o => o.value === dbDeparture)) {
|
||||
setLocalDeparture(dbDeparture);
|
||||
onFormDataChange?.(departureField, dbDeparture);
|
||||
|
||||
// 라벨도 업데이트
|
||||
if (departureLabelField) {
|
||||
const opt = options.find(o => o.value === dbDeparture);
|
||||
if (opt) {
|
||||
onFormDataChange?.(departureLabelField, opt.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dbDestination && options.some(o => o.value === dbDestination)) {
|
||||
setLocalDestination(dbDestination);
|
||||
onFormDataChange?.(destinationField, dbDestination);
|
||||
|
||||
// 라벨도 업데이트
|
||||
if (destinationLabelField) {
|
||||
const opt = options.find(o => o.value === dbDestination);
|
||||
if (opt) {
|
||||
onFormDataChange?.(destinationLabelField, opt.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDbLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("[LocationSwapSelector] DB 로드 실패:", error);
|
||||
setDbLoaded(true); // 실패해도 다시 시도하지 않음
|
||||
}
|
||||
};
|
||||
|
||||
// 옵션이 로드된 후에 DB 로드 실행
|
||||
if (options.length > 0) {
|
||||
loadFromDatabase();
|
||||
}
|
||||
}, [userId, loadFromDb, dbTableName, dbKeyField, departureField, destinationField, options, isDesignMode, dbLoaded, onFormDataChange, departureLabelField, destinationLabelField]);
|
||||
|
||||
// formData에서 초기값 동기화 (DB 로드 후에도 formData 변경 시 반영)
|
||||
useEffect(() => {
|
||||
// DB 로드가 완료되지 않았으면 스킵 (DB 값 우선)
|
||||
if (loadFromDb && userId && !dbLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const depVal = formData[departureField];
|
||||
const destVal = formData[destinationField];
|
||||
|
||||
if (depVal && options.some(o => o.value === depVal)) {
|
||||
setLocalDeparture(depVal);
|
||||
}
|
||||
if (destVal && options.some(o => o.value === destVal)) {
|
||||
setLocalDestination(destVal);
|
||||
}
|
||||
}, [formData, departureField, destinationField, options, loadFromDb, userId, dbLoaded]);
|
||||
|
||||
// 출발지 변경
|
||||
const handleDepartureChange = (selectedValue: string) => {
|
||||
console.log("[LocationSwapSelector] 출발지 변경:", {
|
||||
selectedValue,
|
||||
departureField,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
options
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setLocalDeparture(selectedValue);
|
||||
|
||||
// 부모에게 전달
|
||||
if (onFormDataChange) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`);
|
||||
onFormDataChange(departureField, selectedValue);
|
||||
// 라벨 필드도 업데이트
|
||||
if (departureLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||
if (selectedOption) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`);
|
||||
onFormDataChange(departureLabelField, selectedOption.label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
// 도착지 변경
|
||||
const handleDestinationChange = (selectedValue: string) => {
|
||||
console.log("[LocationSwapSelector] 도착지 변경:", {
|
||||
selectedValue,
|
||||
destinationField,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
options
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setLocalDestination(selectedValue);
|
||||
|
||||
// 부모에게 전달
|
||||
if (onFormDataChange) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`);
|
||||
onFormDataChange(destinationField, selectedValue);
|
||||
// 라벨 필드도 업데이트
|
||||
if (destinationLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||
if (selectedOption) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`);
|
||||
onFormDataChange(destinationLabelField, selectedOption.label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
// 출발지/도착지 교환
|
||||
const handleSwap = () => {
|
||||
setIsSwapping(true);
|
||||
|
||||
// 로컬 상태 교환
|
||||
const tempDeparture = localDeparture;
|
||||
const tempDestination = localDestination;
|
||||
|
||||
setLocalDeparture(tempDestination);
|
||||
setLocalDestination(tempDeparture);
|
||||
|
||||
// 부모에게 전달
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(departureField, tempDestination);
|
||||
onFormDataChange(destinationField, tempDeparture);
|
||||
|
||||
// 라벨도 교환
|
||||
if (departureLabelField && destinationLabelField) {
|
||||
const depOption = options.find(o => o.value === tempDestination);
|
||||
const destOption = options.find(o => o.value === tempDeparture);
|
||||
onFormDataChange(departureLabelField, depOption?.label || "");
|
||||
onFormDataChange(destinationLabelField, destOption?.label || "");
|
||||
}
|
||||
}
|
||||
|
||||
// 애니메이션 효과
|
||||
setTimeout(() => setIsSwapping(false), 300);
|
||||
};
|
||||
|
||||
// 스타일에서 width, height 추출
|
||||
const { width, height, ...restStyle } = style || {};
|
||||
|
||||
// 선택된 라벨 가져오기
|
||||
const getDepartureLabel = () => {
|
||||
const opt = options.find(o => o.value === localDeparture);
|
||||
return opt?.label || "";
|
||||
};
|
||||
|
||||
const getDestinationLabel = () => {
|
||||
const opt = options.find(o => o.value === localDestination);
|
||||
return opt?.label || "";
|
||||
};
|
||||
|
||||
// 디버그 로그
|
||||
console.log("[LocationSwapSelector] 렌더:", {
|
||||
localDeparture,
|
||||
localDestination,
|
||||
options: options.map(o => `${o.value}:${o.label}`),
|
||||
});
|
||||
|
||||
// Card 스타일 (이미지 참고)
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="h-full w-full"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
||||
{/* 출발지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
|
||||
<Select
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className={cn(
|
||||
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
||||
isSwapping && "animate-pulse"
|
||||
)}>
|
||||
{localDeparture ? (
|
||||
<span>{getDepartureLabel()}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">선택</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDestination}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDestination && " (도착지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 교환 버튼 */}
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
className={cn(
|
||||
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
||||
isSwapping && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<ArrowLeftRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 도착지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
|
||||
<Select
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className={cn(
|
||||
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
||||
isSwapping && "animate-pulse"
|
||||
)}>
|
||||
{localDestination ? (
|
||||
<span>{getDestinationLabel()}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">선택</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDeparture}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDeparture && " (출발지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline 스타일
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-2"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
|
||||
<Select
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">선택</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDestination}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDestination && " (도착지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
className="mt-5 h-10 w-10"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
|
||||
<Select
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">선택</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDeparture}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDeparture && " (출발지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal 스타일
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-1"
|
||||
style={restStyle}
|
||||
>
|
||||
<Select
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">{departureLabel}</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDestination}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDestination && " (도착지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">{destinationLabel}</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDeparture}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDeparture && " (출발지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationSwapSelectorConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 설정 패널
|
||||
*/
|
||||
export function LocationSwapSelectorConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
screenTableName,
|
||||
}: LocationSwapSelectorConfigPanelProps) {
|
||||
const [tables, setTables] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [codeCategories, setCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data.success && response.data.data) {
|
||||
setTables(
|
||||
response.data.data.map((t: any) => ({
|
||||
name: t.tableName || t.table_name,
|
||||
label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
const tableName = config?.dataSource?.tableName;
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data.success) {
|
||||
// API 응답 구조 처리: data가 배열이거나 data.columns가 배열일 수 있음
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
setColumns(
|
||||
columnData.map((c: any) => ({
|
||||
name: c.columnName || c.column_name || c.name,
|
||||
label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (config?.dataSource?.type === "table") {
|
||||
loadColumns();
|
||||
}
|
||||
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
|
||||
|
||||
// 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시)
|
||||
useEffect(() => {
|
||||
const loadCodeCategories = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/code-management/categories");
|
||||
if (response.data.success && response.data.data) {
|
||||
setCodeCategories(
|
||||
response.data.data.map((c: any) => ({
|
||||
value: c.category_code || c.categoryCode || c.code,
|
||||
label: c.category_name || c.categoryName || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 404는 API가 없는 것이므로 무시
|
||||
if (error?.response?.status !== 404) {
|
||||
console.error("코드 카테고리 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCodeCategories();
|
||||
}, []);
|
||||
|
||||
const handleChange = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const newConfig = { ...config };
|
||||
let current: any = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.type || "static"}
|
||||
onValueChange={(value) => handleChange("dataSource.type", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정 옵션 (포항/광양 등)</SelectItem>
|
||||
<SelectItem value="table">테이블에서 가져오기</SelectItem>
|
||||
<SelectItem value="code">코드 관리에서 가져오기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 고정 옵션 설정 (type이 static일 때) */}
|
||||
{(!config?.dataSource?.type || config?.dataSource?.type === "static") && (
|
||||
<div className="space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
|
||||
<h4 className="text-sm font-medium">고정 옵션 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 1 (값)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[0]?.value || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[0] = { ...newOptions[0], value: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: pohang"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 1 (표시명)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[0]?.label || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[0] = { ...newOptions[0], label: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: 포항"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 2 (값)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[1]?.value || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[1] = { ...newOptions[1], value: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: gwangyang"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 2 (표시명)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[1]?.label || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[1] = { ...newOptions[1], label: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: 광양"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
고정된 2개 장소만 사용할 때 설정하세요. (예: 포항 ↔ 광양)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 선택 (type이 table일 때) */}
|
||||
{config?.dataSource?.type === "table" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>테이블</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.tableName || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.tableName", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>값 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.valueField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.valueField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>표시 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.labelField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.labelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 코드 카테고리 선택 (type이 code일 때) */}
|
||||
{config?.dataSource?.type === "code" && (
|
||||
<div className="space-y-2">
|
||||
<Label>코드 카테고리</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.codeCategory || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.codeCategory", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeCategories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">필드 매핑 (저장 위치)</h4>
|
||||
{screenTableName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 화면 테이블: <strong>{screenTableName}</strong>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureField || ""}
|
||||
onValueChange={(value) => handleChange("departureField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureField || "departure"}
|
||||
onChange={(e) => handleChange("departureField", e.target.value)}
|
||||
placeholder="departure"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationField || ""}
|
||||
onValueChange={(value) => handleChange("destinationField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationField || "destination"}
|
||||
onChange={(e) => handleChange("destinationField", e.target.value)}
|
||||
placeholder="destination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureLabelField || "__none__"}
|
||||
onValueChange={(value) => handleChange("departureLabelField", value === "__none__" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureLabelField || ""}
|
||||
onChange={(e) => handleChange("departureLabelField", e.target.value)}
|
||||
placeholder="departure_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationLabelField || "__none__"}
|
||||
onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationLabelField || ""}
|
||||
onChange={(e) => handleChange("destinationLabelField", e.target.value)}
|
||||
placeholder="destination_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI 설정 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">UI 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 라벨</Label>
|
||||
<Input
|
||||
value={config?.departureLabel || "출발지"}
|
||||
onChange={(e) => handleChange("departureLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 라벨</Label>
|
||||
<Input
|
||||
value={config?.destinationLabel || "도착지"}
|
||||
onChange={(e) => handleChange("destinationLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>스타일</Label>
|
||||
<Select
|
||||
value={config?.variant || "card"}
|
||||
onValueChange={(value) => handleChange("variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="card">카드 (이미지 참고)</SelectItem>
|
||||
<SelectItem value="inline">인라인</SelectItem>
|
||||
<SelectItem value="minimal">미니멀</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>교환 버튼 표시</Label>
|
||||
<Switch
|
||||
checked={config?.showSwapButton !== false}
|
||||
onCheckedChange={(checked) => handleChange("showSwapButton", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB 초기값 로드 설정 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">DB 초기값 로드</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
새로고침 시에도 DB에 저장된 출발지/목적지를 자동으로 불러옵니다
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>DB에서 초기값 로드</Label>
|
||||
<Switch
|
||||
checked={config?.loadFromDb !== false}
|
||||
onCheckedChange={(checked) => handleChange("loadFromDb", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config?.loadFromDb !== false && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>조회 테이블</Label>
|
||||
<Select
|
||||
value={config?.dbTableName || "vehicles"}
|
||||
onValueChange={(value) => handleChange("dbTableName", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vehicles">vehicles (기본)</SelectItem>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>키 필드</Label>
|
||||
<Input
|
||||
value={config?.dbKeyField || "user_id"}
|
||||
onChange={(e) => handleChange("dbKeyField", e.target.value)}
|
||||
placeholder="user_id"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 사용자 ID로 조회할 필드 (기본: user_id)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 데이터 소스에서 장소 목록을 가져올 위치를 선택합니다
|
||||
<br />
|
||||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||
<br />
|
||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||
<br />
|
||||
4. DB 초기값 로드를 활성화하면 새로고침 후에도 값이 유지됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2LocationSwapSelectorDefinition } from "./index";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 렌더러
|
||||
*/
|
||||
export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2LocationSwapSelectorDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props;
|
||||
|
||||
// component.componentConfig에서 설정 가져오기
|
||||
const componentConfig = component?.componentConfig || {};
|
||||
|
||||
console.log("[LocationSwapSelectorRenderer] render:", {
|
||||
componentConfig,
|
||||
formData,
|
||||
isDesignMode
|
||||
});
|
||||
|
||||
return (
|
||||
<LocationSwapSelectorComponent
|
||||
id={component?.id}
|
||||
style={style}
|
||||
isDesignMode={isDesignMode}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
componentConfig={componentConfig}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
LocationSwapSelectorRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
LocationSwapSelectorRenderer.enableHotReload();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트 정의
|
||||
* 출발지/도착지 선택 및 교환 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
export const V2LocationSwapSelectorDefinition = createComponentDefinition({
|
||||
id: "v2-location-swap-selector",
|
||||
name: "출발지/도착지 선택",
|
||||
nameEng: "Location Swap Selector",
|
||||
description: "출발지와 도착지를 선택하고 교환할 수 있는 컴포넌트 (모바일 최적화)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "form",
|
||||
component: LocationSwapSelectorComponent,
|
||||
defaultConfig: {
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
type: "static", // "table" | "code" | "static"
|
||||
tableName: "", // 장소 테이블명
|
||||
valueField: "location_code", // 값 필드
|
||||
labelField: "location_name", // 표시 필드
|
||||
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
||||
staticOptions: [
|
||||
{ value: "포항", label: "포항" },
|
||||
{ value: "광양", label: "광양" },
|
||||
], // 정적 옵션 (type이 "static"일 때) - 한글로 저장
|
||||
},
|
||||
// 필드 매핑
|
||||
departureField: "departure", // 출발지 저장 필드
|
||||
destinationField: "destination", // 도착지 저장 필드
|
||||
departureLabelField: "departure_name", // 출발지명 저장 필드 (선택)
|
||||
destinationLabelField: "destination_name", // 도착지명 저장 필드 (선택)
|
||||
// UI 설정
|
||||
departureLabel: "출발지",
|
||||
destinationLabel: "도착지",
|
||||
showSwapButton: true,
|
||||
swapButtonPosition: "center", // "center" | "right"
|
||||
// 스타일
|
||||
variant: "card", // "card" | "inline" | "minimal"
|
||||
},
|
||||
defaultSize: { width: 400, height: 100 },
|
||||
configPanel: LocationSwapSelectorConfigPanel,
|
||||
icon: "ArrowLeftRight",
|
||||
tags: ["출발지", "도착지", "교환", "스왑", "위치", "모바일"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
export { LocationSwapSelectorRenderer } from "./LocationSwapSelectorRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
interface NumberingRuleWrapperProps {
|
||||
config: NumberingRuleComponentConfig;
|
||||
onChange?: (config: NumberingRuleComponentConfig) => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string; // 현재 화면의 테이블명
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID
|
||||
}
|
||||
|
||||
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
menuObjid,
|
||||
}) => {
|
||||
console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", {
|
||||
tableName,
|
||||
menuObjid,
|
||||
hasMenuObjid: !!menuObjid,
|
||||
config
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<NumberingRuleDesigner
|
||||
maxRules={config.maxRules || 6}
|
||||
isPreview={isPreview}
|
||||
className="h-full"
|
||||
currentTableName={tableName} // 테이블명 전달
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NumberingRuleComponent = NumberingRuleWrapper;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
interface NumberingRuleConfigPanelProps {
|
||||
config: NumberingRuleComponentConfig;
|
||||
onChange: (config: NumberingRuleComponentConfig) => void;
|
||||
}
|
||||
|
||||
export const NumberingRuleConfigPanel: React.FC<NumberingRuleConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">최대 규칙 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={config.maxRules || 6}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, maxRules: parseInt(e.target.value) || 6 })
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
한 규칙에 추가할 수 있는 최대 파트 개수 (1-10)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">읽기 전용 모드</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
편집 기능을 비활성화합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, readonly: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">미리보기 표시</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
코드 미리보기를 항상 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showPreview !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, showPreview: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">규칙 목록 표시</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
저장된 규칙 목록을 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showRuleList !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, showRuleList: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">카드 레이아웃</Label>
|
||||
<Select
|
||||
value={config.cardLayout || "vertical"}
|
||||
onValueChange={(value: "vertical" | "horizontal") =>
|
||||
onChange({ ...config, cardLayout: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vertical">세로</SelectItem>
|
||||
<SelectItem value="horizontal">가로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
규칙 파트 카드의 배치 방향
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2NumberingRuleDefinition } from "./index";
|
||||
import { NumberingRuleComponent } from "./NumberingRuleComponent";
|
||||
|
||||
/**
|
||||
* 채번 규칙 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2NumberingRuleDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <NumberingRuleComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 컴포넌트 특화 메서드
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
NumberingRuleRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
NumberingRuleRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
# 코드 채번 규칙 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집
|
||||
- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합
|
||||
- **실시간 미리보기**: 설정 즉시 생성될 코드 확인
|
||||
- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀
|
||||
|
||||
## 생성 코드 예시
|
||||
|
||||
- 제품 코드: `PROD-20251104-0001`
|
||||
- 프로젝트 코드: `PRJ-2025-001`
|
||||
- 거래처 코드: `CUST-A-0001`
|
||||
|
||||
## 파트 유형
|
||||
|
||||
### 1. 접두사 (prefix)
|
||||
고정된 문자열을 코드 앞에 추가합니다.
|
||||
- 예: `PROD`, `PRJ`, `CUST`
|
||||
|
||||
### 2. 순번 (sequence)
|
||||
자동으로 증가하는 번호를 생성합니다.
|
||||
- 자릿수 설정 가능 (1-10)
|
||||
- 시작 번호 설정 가능
|
||||
- 예: `0001`, `00001`
|
||||
|
||||
### 3. 날짜 (date)
|
||||
현재 날짜를 다양한 형식으로 추가합니다.
|
||||
- YYYY: 2025
|
||||
- YYYYMMDD: 20251104
|
||||
- YYMMDD: 251104
|
||||
|
||||
### 4. 연도 (year)
|
||||
현재 연도를 추가합니다.
|
||||
- YYYY: 2025
|
||||
- YY: 25
|
||||
|
||||
### 5. 월 (month)
|
||||
현재 월을 2자리로 추가합니다.
|
||||
- 예: 01, 02, ..., 12
|
||||
|
||||
### 6. 사용자 정의 (custom)
|
||||
원하는 값을 직접 입력합니다.
|
||||
|
||||
## 생성 방식
|
||||
|
||||
### 자동 생성 (auto)
|
||||
시스템이 자동으로 값을 생성합니다.
|
||||
|
||||
### 직접 입력 (manual)
|
||||
사용자가 값을 직접 입력합니다.
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `maxRules` | number | 6 | 최대 파트 개수 |
|
||||
| `readonly` | boolean | false | 읽기 전용 모드 |
|
||||
| `showPreview` | boolean | true | 미리보기 표시 |
|
||||
| `showRuleList` | boolean | true | 규칙 목록 표시 |
|
||||
| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 |
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```typescript
|
||||
<NumberingRuleDesigner
|
||||
maxRules={6}
|
||||
isPreview={false}
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
## 데이터베이스 구조
|
||||
|
||||
### numbering_rules (마스터 테이블)
|
||||
- 규칙 ID, 규칙명, 구분자
|
||||
- 초기화 주기, 현재 시퀀스
|
||||
- 적용 대상 테이블/컬럼
|
||||
|
||||
### numbering_rule_parts (파트 테이블)
|
||||
- 파트 순서, 파트 유형
|
||||
- 생성 방식, 설정 (JSONB)
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
- `GET /api/numbering-rules` - 규칙 목록 조회
|
||||
- `POST /api/numbering-rules` - 규칙 생성
|
||||
- `PUT /api/numbering-rules/:ruleId` - 규칙 수정
|
||||
- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제
|
||||
- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성
|
||||
|
||||
## 버전 정보
|
||||
|
||||
- **버전**: 1.0.0
|
||||
- **작성일**: 2025-11-04
|
||||
- **작성자**: 개발팀
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 채번 규칙 컴포넌트 기본 설정
|
||||
*/
|
||||
|
||||
import { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
export const defaultConfig: NumberingRuleComponentConfig = {
|
||||
maxRules: 6,
|
||||
readonly: false,
|
||||
showPreview: true,
|
||||
showRuleList: true,
|
||||
enableReorder: false,
|
||||
cardLayout: "vertical",
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { NumberingRuleWrapper } from "./NumberingRuleComponent";
|
||||
import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel";
|
||||
import { defaultConfig } from "./config";
|
||||
|
||||
/**
|
||||
* 채번 규칙 컴포넌트 정의
|
||||
* 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트
|
||||
*/
|
||||
export const V2NumberingRuleDefinition = createComponentDefinition({
|
||||
id: "v2-numbering-rule",
|
||||
name: "코드 채번 규칙",
|
||||
nameEng: "Numbering Rule Component",
|
||||
description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "component",
|
||||
component: NumberingRuleWrapper,
|
||||
defaultConfig: defaultConfig,
|
||||
defaultSize: {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
gridColumnSpan: "12",
|
||||
},
|
||||
configPanel: NumberingRuleConfigPanel,
|
||||
icon: "Hash",
|
||||
tags: ["코드", "채번", "규칙", "표시", "자동생성"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { NumberingRuleComponent } from "./NumberingRuleComponent";
|
||||
export { NumberingRuleRenderer } from "./NumberingRuleRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 채번 규칙 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface NumberingRuleComponentConfig {
|
||||
ruleConfig?: NumberingRuleConfig;
|
||||
maxRules?: number;
|
||||
readonly?: boolean;
|
||||
showPreview?: boolean;
|
||||
showRuleList?: boolean;
|
||||
enableReorder?: boolean;
|
||||
cardLayout?: "vertical" | "horizontal";
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
# PivotGrid 컴포넌트 전체 구현 계획
|
||||
|
||||
## 개요
|
||||
DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현
|
||||
|
||||
## 현재 상태: ✅ 모든 기능 구현 완료!
|
||||
|
||||
---
|
||||
|
||||
## 구현된 기능 목록
|
||||
|
||||
### 1. 기본 피벗 테이블 ✅
|
||||
- [x] 피벗 테이블 렌더링
|
||||
- [x] 행/열 확장/축소
|
||||
- [x] 합계/소계 표시
|
||||
- [x] 전체 확장/축소 버튼
|
||||
|
||||
### 2. 필드 패널 (드래그앤드롭) ✅
|
||||
- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터)
|
||||
- [x] 각 영역에 배치된 필드 칩/태그 표시
|
||||
- [x] 필드 제거 버튼 (X)
|
||||
- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용)
|
||||
- [x] 영역 간 필드 이동
|
||||
- [x] 같은 영역 내 순서 변경
|
||||
- [x] 드래그 시 시각적 피드백
|
||||
|
||||
### 3. 필드 선택기 (모달) ✅
|
||||
- [x] 모달 열기/닫기
|
||||
- [x] 사용 가능한 필드 목록
|
||||
- [x] 필드 검색 기능
|
||||
- [x] 필드별 영역 선택 드롭다운
|
||||
- [x] 데이터 타입 아이콘 표시
|
||||
- [x] 집계 함수 선택 (데이터 영역)
|
||||
- [x] 표시 모드 선택 (데이터 영역)
|
||||
|
||||
### 4. 데이터 요약 (누계, % 모드) ✅
|
||||
- [x] 절대값 표시 (기본)
|
||||
- [x] 행 총계 대비 %
|
||||
- [x] 열 총계 대비 %
|
||||
- [x] 전체 총계 대비 %
|
||||
- [x] 행/열 방향 누계
|
||||
- [x] 이전 대비 차이
|
||||
- [x] 이전 대비 % 차이
|
||||
|
||||
### 5. 필터링 ✅
|
||||
- [x] 필터 팝업 컴포넌트 (FilterPopup)
|
||||
- [x] 값 검색 기능
|
||||
- [x] 체크박스 기반 값 선택
|
||||
- [x] 포함/제외 모드
|
||||
- [x] 전체 선택/해제
|
||||
- [x] 선택된 항목 수 표시
|
||||
|
||||
### 6. Drill Down ✅
|
||||
- [x] 셀 더블클릭 시 상세 데이터 모달
|
||||
- [x] 원본 데이터 테이블 표시
|
||||
- [x] 검색 기능
|
||||
- [x] 정렬 기능
|
||||
- [x] 페이지네이션
|
||||
- [x] CSV/Excel 내보내기
|
||||
|
||||
### 7. Virtual Scrolling ✅
|
||||
- [x] useVirtualScroll 훅 (행)
|
||||
- [x] useVirtualColumnScroll 훅 (열)
|
||||
- [x] useVirtual2DScroll 훅 (행+열)
|
||||
- [x] overscan 버퍼 지원
|
||||
|
||||
### 8. Excel 내보내기 ✅
|
||||
- [x] xlsx 라이브러리 사용
|
||||
- [x] 피벗 데이터 Excel 내보내기
|
||||
- [x] Drill Down 데이터 Excel 내보내기
|
||||
- [x] CSV 내보내기 (기본)
|
||||
- [x] 스타일링 (헤더, 데이터, 총계)
|
||||
- [x] 숫자 포맷
|
||||
|
||||
### 9. 차트 통합 ✅
|
||||
- [x] recharts 라이브러리 사용
|
||||
- [x] 막대 차트
|
||||
- [x] 누적 막대 차트
|
||||
- [x] 선 차트
|
||||
- [x] 영역 차트
|
||||
- [x] 파이 차트
|
||||
- [x] 범례 표시
|
||||
- [x] 커스텀 툴팁
|
||||
- [x] 차트 토글 버튼
|
||||
|
||||
### 10. 조건부 서식 (Conditional Formatting) ✅
|
||||
- [x] Color Scale (색상 그라데이션)
|
||||
- [x] Data Bar (데이터 막대)
|
||||
- [x] Icon Set (아이콘)
|
||||
- [x] Cell Value (조건 기반 스타일)
|
||||
- [x] ConfigPanel에서 설정 UI
|
||||
|
||||
### 11. 상태 저장/복원 ✅
|
||||
- [x] usePivotState 훅
|
||||
- [x] localStorage/sessionStorage 지원
|
||||
- [x] 자동 저장 (디바운스)
|
||||
|
||||
### 12. ConfigPanel 고도화 ✅
|
||||
- [x] 데이터 소스 설정 (테이블 선택)
|
||||
- [x] 필드별 영역 설정 (행, 열, 데이터, 필터)
|
||||
- [x] 총계 옵션 설정
|
||||
- [x] 스타일 설정 (테마, 교차 색상 등)
|
||||
- [x] 내보내기 설정 (Excel/CSV)
|
||||
- [x] 차트 설정 UI
|
||||
- [x] 필드 선택기 설정 UI
|
||||
- [x] 조건부 서식 설정 UI
|
||||
- [x] 크기 설정
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
pivot-grid/
|
||||
├── components/
|
||||
│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭)
|
||||
│ ├── FieldChooser.tsx # 필드 선택기 모달
|
||||
│ ├── DrillDownModal.tsx # Drill Down 모달
|
||||
│ ├── FilterPopup.tsx # 필터 팝업
|
||||
│ ├── PivotChart.tsx # 차트 컴포넌트
|
||||
│ └── index.ts # 내보내기
|
||||
├── hooks/
|
||||
│ ├── useVirtualScroll.ts # 가상 스크롤 훅
|
||||
│ ├── usePivotState.ts # 상태 저장 훅
|
||||
│ └── index.ts # 내보내기
|
||||
├── utils/
|
||||
│ ├── aggregation.ts # 집계 함수
|
||||
│ ├── pivotEngine.ts # 피벗 엔진
|
||||
│ ├── exportExcel.ts # Excel 내보내기
|
||||
│ ├── conditionalFormat.ts # 조건부 서식
|
||||
│ └── index.ts # 내보내기
|
||||
├── types.ts # 타입 정의
|
||||
├── PivotGridComponent.tsx # 메인 컴포넌트
|
||||
├── PivotGridConfigPanel.tsx # 설정 패널
|
||||
├── PivotGridRenderer.tsx # 렌더러
|
||||
├── index.ts # 모듈 내보내기
|
||||
└── PLAN.md # 이 파일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 후순위 기능 (선택적)
|
||||
|
||||
다음 기능들은 필요 시 추가 구현 가능:
|
||||
|
||||
### 데이터 바인딩 확장
|
||||
- [ ] OLAP Data Source 연동 (복잡)
|
||||
- [ ] GraphQL 연동
|
||||
- [ ] 실시간 데이터 업데이트 (WebSocket)
|
||||
|
||||
### 고급 기능
|
||||
- [ ] 피벗 테이블 병합 (여러 데이터 소스)
|
||||
- [ ] 계산 필드 (커스텀 수식)
|
||||
- [ ] 데이터 정렬 옵션 강화
|
||||
- [ ] 그룹핑 옵션 (날짜 그룹핑 등)
|
||||
|
||||
---
|
||||
|
||||
## 완료일: 2026-01-08
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,798 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 설정 패널 - 간소화 버전
|
||||
*
|
||||
* 피벗 테이블 설정 방법:
|
||||
* 1. 테이블 선택
|
||||
* 2. 컬럼을 드래그하여 행/열/값 영역에 배치
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridComponentConfig,
|
||||
PivotFieldConfig,
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
FieldDataType,
|
||||
} from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Rows,
|
||||
Columns,
|
||||
Calculator,
|
||||
X,
|
||||
Plus,
|
||||
GripVertical,
|
||||
Table2,
|
||||
BarChart3,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_comment?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
column_comment?: string;
|
||||
}
|
||||
|
||||
interface PivotGridConfigPanelProps {
|
||||
config: PivotGridComponentConfig;
|
||||
onChange: (config: PivotGridComponentConfig) => void;
|
||||
}
|
||||
|
||||
// DB 타입을 FieldDataType으로 변환
|
||||
function mapDbTypeToFieldType(dbType: string): FieldDataType {
|
||||
const type = dbType.toLowerCase();
|
||||
if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) {
|
||||
return "number";
|
||||
}
|
||||
if (type.includes("date") || type.includes("time") || type.includes("timestamp")) {
|
||||
return "date";
|
||||
}
|
||||
if (type.includes("bool")) {
|
||||
return "boolean";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
// ==================== 컬럼 칩 컴포넌트 ====================
|
||||
|
||||
interface ColumnChipProps {
|
||||
column: ColumnInfo;
|
||||
isUsed: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ColumnChip: React.FC<ColumnChipProps> = ({ column, isUsed, onClick }) => {
|
||||
const dataType = mapDbTypeToFieldType(column.data_type);
|
||||
const typeColor = {
|
||||
number: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
string: "bg-green-100 text-green-700 border-green-200",
|
||||
date: "bg-purple-100 text-purple-700 border-purple-200",
|
||||
boolean: "bg-orange-100 text-orange-700 border-orange-200",
|
||||
}[dataType];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isUsed}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs border transition-all",
|
||||
isUsed
|
||||
? "bg-muted text-muted-foreground border-muted cursor-not-allowed opacity-50"
|
||||
: cn(typeColor, "hover:shadow-sm cursor-pointer")
|
||||
)}
|
||||
>
|
||||
<span className="font-medium truncate max-w-[120px]">
|
||||
{column.column_comment || column.column_name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 영역 드롭존 컴포넌트 ====================
|
||||
|
||||
interface AreaDropZoneProps {
|
||||
area: PivotAreaType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
fields: PivotFieldConfig[];
|
||||
columns: ColumnInfo[];
|
||||
onAddField: (column: ColumnInfo) => void;
|
||||
onRemoveField: (index: number) => void;
|
||||
onUpdateField: (index: number, updates: Partial<PivotFieldConfig>) => void;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const AreaDropZone: React.FC<AreaDropZoneProps> = ({
|
||||
area,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
fields,
|
||||
columns,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
onUpdateField,
|
||||
color,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
// 사용 가능한 컬럼 (이미 추가된 컬럼 제외)
|
||||
const availableColumns = columns.filter(
|
||||
(col) => !fields.some((f) => f.field === col.column_name)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border-2 p-3", color)}>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{fields.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{/* 추가된 필드 목록 */}
|
||||
{fields.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{fields.map((field, idx) => (
|
||||
<div
|
||||
key={`${field.field}-${idx}`}
|
||||
className="flex items-center gap-2 bg-background rounded-md px-2 py-1.5 border"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="flex-1 text-xs font-medium truncate">
|
||||
{field.caption || field.field}
|
||||
</span>
|
||||
|
||||
{/* 데이터 영역일 때 집계 함수 선택 */}
|
||||
{area === "data" && (
|
||||
<Select
|
||||
value={field.summaryType || "sum"}
|
||||
onValueChange={(v) => onUpdateField(idx, { summaryType: v as AggregationType })}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sum">합계</SelectItem>
|
||||
<SelectItem value="count">개수</SelectItem>
|
||||
<SelectItem value="avg">평균</SelectItem>
|
||||
<SelectItem value="min">최소</SelectItem>
|
||||
<SelectItem value="max">최대</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onRemoveField(idx)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center py-2 border border-dashed rounded-md">
|
||||
아래에서 컬럼을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 추가 드롭다운 */}
|
||||
{availableColumns.length > 0 && (
|
||||
<Select onValueChange={(v) => {
|
||||
const col = columns.find(c => c.column_name === v);
|
||||
if (col) onAddField(col);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
<span>컬럼 추가</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.column_comment || col.column_name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({mapDbTypeToFieldType(col.data_type)})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
const mappedTables: TableInfo[] = tableList.map((t: any) => ({
|
||||
table_name: t.tableName,
|
||||
table_comment: t.tableLabel || t.displayName || t.tableName,
|
||||
}));
|
||||
setTables(mappedTables);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.dataSource?.tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const columnList = await tableTypeApi.getColumns(config.dataSource.tableName);
|
||||
const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({
|
||||
column_name: c.columnName || c.column_name,
|
||||
data_type: c.dataType || c.data_type || "text",
|
||||
column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name,
|
||||
}));
|
||||
setColumns(mappedColumns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.dataSource?.tableName]);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<PivotGridComponentConfig>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
// 필드 추가
|
||||
const handleAddField = (area: PivotAreaType, column: ColumnInfo) => {
|
||||
const currentFields = config.fields || [];
|
||||
const areaFields = currentFields.filter(f => f.area === area);
|
||||
|
||||
const newField: PivotFieldConfig = {
|
||||
field: column.column_name,
|
||||
caption: column.column_comment || column.column_name,
|
||||
area,
|
||||
areaIndex: areaFields.length,
|
||||
dataType: mapDbTypeToFieldType(column.data_type),
|
||||
visible: true,
|
||||
};
|
||||
|
||||
if (area === "data") {
|
||||
newField.summaryType = "sum";
|
||||
}
|
||||
|
||||
updateConfig({ fields: [...currentFields, newField] });
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const handleRemoveField = (area: PivotAreaType, index: number) => {
|
||||
const currentFields = config.fields || [];
|
||||
const newFields = currentFields.filter(
|
||||
(f) => !(f.area === area && f.areaIndex === index)
|
||||
);
|
||||
|
||||
// 인덱스 재정렬
|
||||
let idx = 0;
|
||||
newFields.forEach((f) => {
|
||||
if (f.area === area) {
|
||||
f.areaIndex = idx++;
|
||||
}
|
||||
});
|
||||
|
||||
updateConfig({ fields: newFields });
|
||||
};
|
||||
|
||||
// 필드 업데이트
|
||||
const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial<PivotFieldConfig>) => {
|
||||
const currentFields = config.fields || [];
|
||||
const newFields = currentFields.map((f) => {
|
||||
if (f.area === area && f.areaIndex === index) {
|
||||
return { ...f, ...updates };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
updateConfig({ fields: newFields });
|
||||
};
|
||||
|
||||
// 영역별 필드 가져오기
|
||||
const getFieldsByArea = (area: PivotAreaType) => {
|
||||
return (config.fields || [])
|
||||
.filter(f => f.area === area)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 사용 가이드 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-blue-600 mt-0.5" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium mb-1">피벗 테이블 설정 방법</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5 text-blue-700">
|
||||
<li>데이터를 가져올 <strong>테이블</strong>을 선택하세요</li>
|
||||
<li><strong>행 그룹</strong>에 그룹화할 컬럼을 추가하세요 (예: 지역, 부서)</li>
|
||||
<li><strong>열 그룹</strong>에 가로로 펼칠 컬럼을 추가하세요 (예: 월, 분기)</li>
|
||||
<li><strong>값</strong>에 집계할 숫자 컬럼을 추가하세요 (예: 매출, 수량)</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 1: 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table2 className="h-4 w-4 text-primary" />
|
||||
<Label className="text-sm font-semibold">STEP 1. 테이블 선택</Label>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={config.dataSource?.tableName || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
updateConfig({
|
||||
dataSource: {
|
||||
...config.dataSource,
|
||||
type: "table",
|
||||
tableName: v === "__none__" ? undefined : v,
|
||||
},
|
||||
fields: [], // 테이블 변경 시 필드 초기화
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안 함</SelectItem>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_comment || table.table_name}</span>
|
||||
{table.table_comment && (
|
||||
<span className="text-muted-foreground text-xs">({table.table_name})</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* STEP 2: 필드 배치 */}
|
||||
{config.dataSource?.tableName && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-primary" />
|
||||
<Label className="text-sm font-semibold">STEP 2. 필드 배치</Label>
|
||||
{loadingColumns && <span className="text-xs text-muted-foreground">(컬럼 로딩 중...)</span>}
|
||||
</div>
|
||||
|
||||
{/* 사용 가능한 컬럼 목록 */}
|
||||
{columns.length > 0 && (
|
||||
<div className="bg-muted/30 rounded-lg p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">사용 가능한 컬럼</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{columns.map((col) => {
|
||||
const isUsed = (config.fields || []).some(f => f.field === col.column_name);
|
||||
return (
|
||||
<ColumnChip
|
||||
key={col.column_name}
|
||||
column={col}
|
||||
isUsed={isUsed}
|
||||
onClick={() => {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 영역별 드롭존 */}
|
||||
<div className="grid gap-3">
|
||||
<AreaDropZone
|
||||
area="row"
|
||||
label="행 그룹"
|
||||
description="세로로 그룹화할 항목 (예: 지역, 부서, 제품)"
|
||||
icon={<Rows className="h-4 w-4 text-emerald-600" />}
|
||||
fields={getFieldsByArea("row")}
|
||||
columns={columns}
|
||||
onAddField={(col) => handleAddField("row", col)}
|
||||
onRemoveField={(idx) => handleRemoveField("row", idx)}
|
||||
onUpdateField={(idx, updates) => handleUpdateField("row", idx, updates)}
|
||||
color="border-emerald-200 bg-emerald-50/50"
|
||||
/>
|
||||
|
||||
<AreaDropZone
|
||||
area="column"
|
||||
label="열 그룹"
|
||||
description="가로로 펼칠 항목 (예: 월, 분기, 연도)"
|
||||
icon={<Columns className="h-4 w-4 text-blue-600" />}
|
||||
fields={getFieldsByArea("column")}
|
||||
columns={columns}
|
||||
onAddField={(col) => handleAddField("column", col)}
|
||||
onRemoveField={(idx) => handleRemoveField("column", idx)}
|
||||
onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)}
|
||||
color="border-blue-200 bg-blue-50/50"
|
||||
/>
|
||||
|
||||
<AreaDropZone
|
||||
area="data"
|
||||
label="값 (집계)"
|
||||
description="합계, 평균 등을 계산할 숫자 항목 (예: 매출, 수량)"
|
||||
icon={<Calculator className="h-4 w-4 text-amber-600" />}
|
||||
fields={getFieldsByArea("data")}
|
||||
columns={columns}
|
||||
onAddField={(col) => handleAddField("data", col)}
|
||||
onRemoveField={(idx) => handleRemoveField("data", idx)}
|
||||
onUpdateField={(idx, updates) => handleUpdateField("data", idx, updates)}
|
||||
color="border-amber-200 bg-amber-50/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 설정 토글 */}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>고급 설정</span>
|
||||
</div>
|
||||
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
{/* 표시 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">표시 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">행 총계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showRowGrandTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">열 총계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showColumnGrandTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">행 총계 위치</Label>
|
||||
<Select
|
||||
value={config.totals?.rowGrandTotalPosition || "bottom"}
|
||||
onValueChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, rowGrandTotalPosition: v as "top" | "bottom" } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">상단</SelectItem>
|
||||
<SelectItem value="bottom">하단</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">열 총계 위치</Label>
|
||||
<Select
|
||||
value={config.totals?.columnGrandTotalPosition || "right"}
|
||||
onValueChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, columnGrandTotalPosition: v as "left" | "right" } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">좌측</SelectItem>
|
||||
<SelectItem value="right">우측</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">행 소계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showRowTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, showRowTotals: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">열 소계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showColumnTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ totals: { ...config.totals, showColumnTotals: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">줄무늬</Label>
|
||||
<Switch
|
||||
checked={config.style?.alternateRowColors !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...config.style, alternateRowColors: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">셀 병합</Label>
|
||||
<Switch
|
||||
checked={config.style?.mergeCells === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ style: { ...config.style, mergeCells: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">CSV 내보내기</Label>
|
||||
<Switch
|
||||
checked={config.exportConfig?.excel === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||
<Label className="text-xs">상태 저장</Label>
|
||||
<Switch
|
||||
checked={config.saveState === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ saveState: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 크기 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">크기 설정</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
value={config.height || ""}
|
||||
onChange={(e) => updateConfig({ height: e.target.value })}
|
||||
placeholder="400px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">최대 높이</Label>
|
||||
<Input
|
||||
value={config.maxHeight || ""}
|
||||
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
|
||||
placeholder="600px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건부 서식 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">조건부 서식</Label>
|
||||
<div className="space-y-2">
|
||||
{(config.style?.conditionalFormats || []).map((rule, index) => (
|
||||
<div key={rule.id} className="flex items-center gap-2 p-2 rounded-md bg-muted/30">
|
||||
<Select
|
||||
value={rule.type}
|
||||
onValueChange={(v) => {
|
||||
const newFormats = [...(config.style?.conditionalFormats || [])];
|
||||
newFormats[index] = { ...rule, type: v as any };
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="colorScale">색상 스케일</SelectItem>
|
||||
<SelectItem value="dataBar">데이터 바</SelectItem>
|
||||
<SelectItem value="iconSet">아이콘 세트</SelectItem>
|
||||
<SelectItem value="cellValue">셀 값 조건</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{rule.type === "colorScale" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.colorScale?.minColor || "#ff0000"}
|
||||
onChange={(e) => {
|
||||
const newFormats = [...(config.style?.conditionalFormats || [])];
|
||||
newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: e.target.value, maxColor: rule.colorScale?.maxColor || "#00ff00" } };
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
title="최소값 색상"
|
||||
/>
|
||||
<span className="text-xs">→</span>
|
||||
<input
|
||||
type="color"
|
||||
value={rule.colorScale?.maxColor || "#00ff00"}
|
||||
onChange={(e) => {
|
||||
const newFormats = [...(config.style?.conditionalFormats || [])];
|
||||
newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: rule.colorScale?.minColor || "#ff0000", maxColor: e.target.value } };
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
title="최대값 색상"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.type === "dataBar" && (
|
||||
<input
|
||||
type="color"
|
||||
value={rule.dataBar?.color || "#3b82f6"}
|
||||
onChange={(e) => {
|
||||
const newFormats = [...(config.style?.conditionalFormats || [])];
|
||||
newFormats[index] = { ...rule, dataBar: { color: e.target.value } };
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
className="w-6 h-6 rounded cursor-pointer"
|
||||
title="바 색상"
|
||||
/>
|
||||
)}
|
||||
|
||||
{rule.type === "iconSet" && (
|
||||
<Select
|
||||
value={rule.iconSet?.type || "traffic"}
|
||||
onValueChange={(v) => {
|
||||
const newFormats = [...(config.style?.conditionalFormats || [])];
|
||||
newFormats[index] = { ...rule, iconSet: { type: v as any, thresholds: [33, 67] } };
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="arrows">화살표</SelectItem>
|
||||
<SelectItem value="traffic">신호등</SelectItem>
|
||||
<SelectItem value="rating">별점</SelectItem>
|
||||
<SelectItem value="flags">깃발</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-auto"
|
||||
onClick={() => {
|
||||
const newFormats = (config.style?.conditionalFormats || []).filter((_, i) => i !== index);
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
const newFormats = [
|
||||
...(config.style?.conditionalFormats || []),
|
||||
{ id: `cf_${Date.now()}`, type: "colorScale" as const, colorScale: { minColor: "#ff0000", maxColor: "#00ff00" } }
|
||||
];
|
||||
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조건부 서식 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotGridConfigPanel;
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { PivotGridComponent } from "./PivotGridComponent";
|
||||
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
||||
import { PivotFieldConfig } from "./types";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
|
||||
// ==================== 샘플 데이터 (미리보기용) ====================
|
||||
|
||||
const SAMPLE_DATA = [
|
||||
{ region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 },
|
||||
{ region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 },
|
||||
{ region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 },
|
||||
{ region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 },
|
||||
{ region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 },
|
||||
{ region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 },
|
||||
{ region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 },
|
||||
{ region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 },
|
||||
{ region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 },
|
||||
{ region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 },
|
||||
];
|
||||
|
||||
const SAMPLE_FIELDS: PivotFieldConfig[] = [
|
||||
{
|
||||
field: "region",
|
||||
caption: "지역",
|
||||
area: "row",
|
||||
areaIndex: 0,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "product",
|
||||
caption: "제품",
|
||||
area: "row",
|
||||
areaIndex: 1,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "quarter",
|
||||
caption: "분기",
|
||||
area: "column",
|
||||
areaIndex: 0,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
field: "sales",
|
||||
caption: "매출",
|
||||
area: "data",
|
||||
areaIndex: 0,
|
||||
dataType: "number",
|
||||
summaryType: "sum",
|
||||
format: { type: "number", precision: 0 },
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입)
|
||||
*/
|
||||
const PivotGridWrapper: React.FC<any> = (props) => {
|
||||
// 컴포넌트 설정에서 값 추출
|
||||
const componentConfig = props.componentConfig || props.config || {};
|
||||
const configFields = componentConfig.fields || props.fields;
|
||||
const configData = props.data;
|
||||
|
||||
// 🆕 테이블에서 데이터 자동 로딩
|
||||
const [loadedData, setLoadedData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTableData = async () => {
|
||||
const tableName = componentConfig.dataSource?.tableName;
|
||||
|
||||
// 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음
|
||||
if (configData || !tableName || props.isDesignMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("🔷 [PivotGrid] 테이블 데이터 로딩 시작:", tableName);
|
||||
|
||||
const response = await dataApi.getTableData(tableName, {
|
||||
page: 1,
|
||||
size: 10000, // 피벗 분석용 대량 데이터 (pageSize → size)
|
||||
});
|
||||
|
||||
console.log("🔷 [PivotGrid] API 응답:", response);
|
||||
|
||||
// dataApi.getTableData는 { data, total, page, size, totalPages } 구조
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
setLoadedData(response.data);
|
||||
console.log("✅ [PivotGrid] 데이터 로딩 완료:", response.data.length, "건");
|
||||
} else {
|
||||
console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음");
|
||||
setLoadedData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [PivotGrid] 데이터 로딩 에러:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTableData();
|
||||
}, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]);
|
||||
|
||||
// 디버깅 로그
|
||||
console.log("🔷 PivotGridWrapper props:", {
|
||||
isDesignMode: props.isDesignMode,
|
||||
isInteractive: props.isInteractive,
|
||||
hasComponentConfig: !!props.componentConfig,
|
||||
hasConfig: !!props.config,
|
||||
hasData: !!configData,
|
||||
dataLength: configData?.length,
|
||||
hasLoadedData: loadedData.length > 0,
|
||||
loadedDataLength: loadedData.length,
|
||||
hasFields: !!configFields,
|
||||
fieldsLength: configFields?.length,
|
||||
isLoading,
|
||||
});
|
||||
|
||||
// 디자인 모드 판단:
|
||||
// 1. isDesignMode === true
|
||||
// 2. isInteractive === false (편집 모드)
|
||||
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||
|
||||
// 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터
|
||||
const actualData = configData || loadedData;
|
||||
const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0;
|
||||
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||
|
||||
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||
const usePreviewData = isDesignMode || (!hasValidData && !isLoading);
|
||||
|
||||
// 최종 데이터/필드 결정
|
||||
const finalData = usePreviewData ? SAMPLE_DATA : actualData;
|
||||
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||
const finalTitle = usePreviewData
|
||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||
: (componentConfig.title || props.title);
|
||||
|
||||
console.log("🔷 PivotGridWrapper final:", {
|
||||
isDesignMode,
|
||||
usePreviewData,
|
||||
finalDataLength: finalData?.length,
|
||||
finalFieldsLength: finalFields?.length,
|
||||
});
|
||||
|
||||
// 총계 설정
|
||||
const totalsConfig = componentConfig.totals || props.totals || {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
};
|
||||
|
||||
// 🆕 로딩 중 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PivotGridComponent
|
||||
title={finalTitle}
|
||||
data={finalData}
|
||||
fields={finalFields}
|
||||
totals={totalsConfig}
|
||||
style={componentConfig.style || props.style}
|
||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||
chart={componentConfig.chart || props.chart}
|
||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||
height={componentConfig.height || props.height || "400px"}
|
||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||
onCellClick={props.onCellClick}
|
||||
onCellDoubleClick={props.onCellDoubleClick}
|
||||
onFieldDrop={props.onFieldDrop}
|
||||
onExpandChange={props.onExpandChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PivotGrid 컴포넌트 정의
|
||||
*/
|
||||
const V2PivotGridDefinition = createComponentDefinition({
|
||||
id: "v2-pivot-grid",
|
||||
name: "피벗 그리드",
|
||||
nameEng: "PivotGrid Component",
|
||||
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: PivotGridWrapper, // 래퍼 컴포넌트 사용
|
||||
defaultConfig: {
|
||||
dataSource: {
|
||||
type: "table",
|
||||
tableName: "",
|
||||
},
|
||||
fields: SAMPLE_FIELDS,
|
||||
// 미리보기용 샘플 데이터
|
||||
sampleData: SAMPLE_DATA,
|
||||
totals: {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
},
|
||||
style: {
|
||||
theme: "default",
|
||||
headerStyle: "default",
|
||||
cellPadding: "normal",
|
||||
borderStyle: "light",
|
||||
alternateRowColors: true,
|
||||
highlightTotals: true,
|
||||
},
|
||||
allowExpandAll: true,
|
||||
exportConfig: {
|
||||
excel: true,
|
||||
},
|
||||
height: "400px",
|
||||
},
|
||||
defaultSize: { width: 800, height: 500 },
|
||||
configPanel: PivotGridConfigPanel,
|
||||
icon: "BarChart3",
|
||||
tags: ["피벗", "분석", "집계", "그리드", "데이터"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* PivotGrid 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2PivotGridDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const props = this.props as any;
|
||||
|
||||
// 컴포넌트 설정에서 값 추출
|
||||
const componentConfig = props.componentConfig || props.config || {};
|
||||
const configFields = componentConfig.fields || props.fields;
|
||||
const configData = props.data;
|
||||
|
||||
// 디버깅 로그
|
||||
console.log("🔷 PivotGridRenderer props:", {
|
||||
isDesignMode: props.isDesignMode,
|
||||
isInteractive: props.isInteractive,
|
||||
hasComponentConfig: !!props.componentConfig,
|
||||
hasConfig: !!props.config,
|
||||
hasData: !!configData,
|
||||
dataLength: configData?.length,
|
||||
hasFields: !!configFields,
|
||||
fieldsLength: configFields?.length,
|
||||
});
|
||||
|
||||
// 디자인 모드 판단:
|
||||
// 1. isDesignMode === true
|
||||
// 2. isInteractive === false (편집 모드)
|
||||
// 3. 데이터가 없는 경우
|
||||
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
|
||||
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||
|
||||
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||
const usePreviewData = isDesignMode || !hasValidData;
|
||||
|
||||
// 최종 데이터/필드 결정
|
||||
const finalData = usePreviewData ? SAMPLE_DATA : configData;
|
||||
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||
const finalTitle = usePreviewData
|
||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||
: (componentConfig.title || props.title);
|
||||
|
||||
console.log("🔷 PivotGridRenderer final:", {
|
||||
isDesignMode,
|
||||
usePreviewData,
|
||||
finalDataLength: finalData?.length,
|
||||
finalFieldsLength: finalFields?.length,
|
||||
});
|
||||
|
||||
// 총계 설정
|
||||
const totalsConfig = componentConfig.totals || props.totals || {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<PivotGridComponent
|
||||
title={finalTitle}
|
||||
data={finalData}
|
||||
fields={finalFields}
|
||||
totals={totalsConfig}
|
||||
style={componentConfig.style || props.style}
|
||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||
chart={componentConfig.chart || props.chart}
|
||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||
height={componentConfig.height || props.height || "400px"}
|
||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||
onCellClick={props.onCellClick}
|
||||
onCellDoubleClick={props.onCellDoubleClick}
|
||||
onFieldDrop={props.onFieldDrop}
|
||||
onExpandChange={props.onExpandChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
PivotGridRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
PivotGridRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("❌ PivotGrid 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
# PivotGrid 컴포넌트
|
||||
|
||||
다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 다차원 데이터 배치
|
||||
|
||||
- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시)
|
||||
- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기)
|
||||
- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량)
|
||||
- **필터 영역(Filter Area)**: 전체 데이터 필터링
|
||||
|
||||
### 2. 집계 함수
|
||||
|
||||
| 함수 | 설명 | 사용 예 |
|
||||
|------|------|---------|
|
||||
| `sum` | 합계 | 매출 합계 |
|
||||
| `count` | 개수 | 건수 |
|
||||
| `avg` | 평균 | 평균 단가 |
|
||||
| `min` | 최소값 | 최저가 |
|
||||
| `max` | 최대값 | 최고가 |
|
||||
| `countDistinct` | 고유값 개수 | 거래처 수 |
|
||||
|
||||
### 3. 날짜 그룹화
|
||||
|
||||
날짜 필드를 다양한 단위로 그룹화할 수 있습니다:
|
||||
|
||||
- `year`: 연도별
|
||||
- `quarter`: 분기별
|
||||
- `month`: 월별
|
||||
- `week`: 주별
|
||||
- `day`: 일별
|
||||
|
||||
### 4. 드릴다운
|
||||
|
||||
계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다.
|
||||
|
||||
### 5. 총합계/소계
|
||||
|
||||
- 행 총합계 (Row Grand Total)
|
||||
- 열 총합계 (Column Grand Total)
|
||||
- 행 소계 (Row Subtotal)
|
||||
- 열 소계 (Column Subtotal)
|
||||
|
||||
### 6. 내보내기
|
||||
|
||||
CSV 형식으로 데이터를 내보낼 수 있습니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용
|
||||
|
||||
```tsx
|
||||
import { PivotGridComponent } from "@/lib/registry/components/pivot-grid";
|
||||
|
||||
const salesData = [
|
||||
{ region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 },
|
||||
{ region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 },
|
||||
// ...
|
||||
];
|
||||
|
||||
<PivotGridComponent
|
||||
title="매출 분석"
|
||||
data={salesData}
|
||||
fields={[
|
||||
{ field: "region", caption: "지역", area: "row", areaIndex: 0 },
|
||||
{ field: "city", caption: "도시", area: "row", areaIndex: 1 },
|
||||
{ field: "year", caption: "연도", area: "column", areaIndex: 0 },
|
||||
{ field: "quarter", caption: "분기", area: "column", areaIndex: 1 },
|
||||
{ field: "amount", caption: "매출액", area: "data", summaryType: "sum" },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 날짜 그룹화
|
||||
|
||||
```tsx
|
||||
<PivotGridComponent
|
||||
data={orderData}
|
||||
fields={[
|
||||
{ field: "customer", caption: "거래처", area: "row" },
|
||||
{
|
||||
field: "orderDate",
|
||||
caption: "주문일",
|
||||
area: "column",
|
||||
dataType: "date",
|
||||
groupInterval: "month", // 월별 그룹화
|
||||
},
|
||||
{ field: "totalAmount", caption: "주문금액", area: "data", summaryType: "sum" },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 포맷 설정
|
||||
|
||||
```tsx
|
||||
<PivotGridComponent
|
||||
data={salesData}
|
||||
fields={[
|
||||
{ field: "region", caption: "지역", area: "row" },
|
||||
{ field: "year", caption: "연도", area: "column" },
|
||||
{
|
||||
field: "amount",
|
||||
caption: "매출액",
|
||||
area: "data",
|
||||
summaryType: "sum",
|
||||
format: {
|
||||
type: "currency",
|
||||
prefix: "₩",
|
||||
thousandSeparator: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "ratio",
|
||||
caption: "비율",
|
||||
area: "data",
|
||||
summaryType: "avg",
|
||||
format: {
|
||||
type: "percent",
|
||||
precision: 1,
|
||||
suffix: "%",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 화면 관리에서 사용
|
||||
|
||||
설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다.
|
||||
|
||||
```tsx
|
||||
import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid";
|
||||
|
||||
<PivotGridRenderer
|
||||
id="pivot1"
|
||||
config={{
|
||||
dataSource: {
|
||||
type: "table",
|
||||
tableName: "sales_data",
|
||||
},
|
||||
fields: [...],
|
||||
totals: {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
},
|
||||
exportConfig: {
|
||||
excel: true,
|
||||
},
|
||||
}}
|
||||
autoFilter={{ companyCode: "COMPANY_A" }}
|
||||
/>
|
||||
```
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### PivotGridProps
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `title` | `string` | - | 피벗 테이블 제목 |
|
||||
| `data` | `any[]` | `[]` | 원본 데이터 배열 |
|
||||
| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 |
|
||||
| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 |
|
||||
| `style` | `PivotStyleConfig` | - | 스타일 설정 |
|
||||
| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 |
|
||||
| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 |
|
||||
| `height` | `string | number` | `"auto"` | 높이 |
|
||||
| `maxHeight` | `string` | - | 최대 높이 |
|
||||
|
||||
### PivotFieldConfig
|
||||
|
||||
| 속성 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `field` | `string` | O | 데이터 필드명 |
|
||||
| `caption` | `string` | O | 표시 라벨 |
|
||||
| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 |
|
||||
| `areaIndex` | `number` | - | 영역 내 순서 |
|
||||
| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 |
|
||||
| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) |
|
||||
| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 |
|
||||
| `format` | `PivotFieldFormat` | - | 값 포맷 |
|
||||
| `visible` | `boolean` | - | 표시 여부 |
|
||||
|
||||
### PivotTotalsConfig
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 |
|
||||
| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 |
|
||||
| `showRowTotals` | `boolean` | `true` | 행 소계 표시 |
|
||||
| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 |
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
pivot-grid/
|
||||
├── index.ts # 모듈 진입점
|
||||
├── types.ts # 타입 정의
|
||||
├── PivotGridComponent.tsx # 메인 컴포넌트
|
||||
├── PivotGridRenderer.tsx # 화면 관리 렌더러
|
||||
├── PivotGridConfigPanel.tsx # 설정 패널
|
||||
├── README.md # 문서
|
||||
└── utils/
|
||||
├── index.ts # 유틸리티 모듈 진입점
|
||||
├── aggregation.ts # 집계 함수
|
||||
└── pivotEngine.ts # 피벗 데이터 처리 엔진
|
||||
```
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
### 1. 매출 분석
|
||||
|
||||
지역별/기간별/제품별 매출 현황을 분석합니다.
|
||||
|
||||
### 2. 재고 현황
|
||||
|
||||
창고별/품목별 재고 수량을 한눈에 파악합니다.
|
||||
|
||||
### 3. 생산 실적
|
||||
|
||||
생산라인별/일자별 생산량을 분석합니다.
|
||||
|
||||
### 4. 비용 분석
|
||||
|
||||
부서별/계정별 비용을 집계하여 분석합니다.
|
||||
|
||||
### 5. 수주 현황
|
||||
|
||||
거래처별/품목별/월별 수주 현황을 분석합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요.
|
||||
2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다.
|
||||
3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요.
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 컨텍스트 메뉴 컴포넌트
|
||||
* 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
ArrowUpAZ,
|
||||
ArrowDownAZ,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { PivotFieldConfig, AggregationType } from "../types";
|
||||
|
||||
interface PivotContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
// 현재 컨텍스트 정보
|
||||
cellType: "header" | "data" | "rowHeader" | "columnHeader";
|
||||
field?: PivotFieldConfig;
|
||||
rowPath?: string[];
|
||||
columnPath?: string[];
|
||||
value?: any;
|
||||
// 콜백
|
||||
onSort?: (field: string, direction: "asc" | "desc") => void;
|
||||
onFilter?: (field: string) => void;
|
||||
onExpand?: (path: string[]) => void;
|
||||
onCollapse?: (path: string[]) => void;
|
||||
onExpandAll?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
onCopy?: (value: any) => void;
|
||||
onHideField?: (field: string) => void;
|
||||
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
|
||||
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
|
||||
}
|
||||
|
||||
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
|
||||
children,
|
||||
cellType,
|
||||
field,
|
||||
rowPath,
|
||||
columnPath,
|
||||
value,
|
||||
onSort,
|
||||
onFilter,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
onCopy,
|
||||
onHideField,
|
||||
onChangeSummary,
|
||||
onDrillDown,
|
||||
}) => {
|
||||
const handleCopy = () => {
|
||||
if (value !== undefined && value !== null) {
|
||||
navigator.clipboard.writeText(String(value));
|
||||
onCopy?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{/* 정렬 옵션 (헤더에서만) */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
정렬
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
오름차순
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
|
||||
<ArrowDownAZ className="mr-2 h-4 w-4" />
|
||||
내림차순
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 확장/축소 옵션 */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && (
|
||||
<>
|
||||
{rowPath && rowPath.length > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
축소
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={onExpandAll}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
전체 확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onCollapseAll}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
전체 축소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필터 옵션 */}
|
||||
{field && onFilter && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onFilter(field.field)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
필터
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 집계 함수 변경 (데이터 필드에서만) */}
|
||||
{cellType === "data" && field && onChangeSummary && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
집계 함수
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "sum")}
|
||||
>
|
||||
합계
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "count")}
|
||||
>
|
||||
개수
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "avg")}
|
||||
>
|
||||
평균
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "min")}
|
||||
>
|
||||
최소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "max")}
|
||||
>
|
||||
최대
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 드릴다운 (데이터 셀에서만) */}
|
||||
{cellType === "data" && rowPath && columnPath && onDrillDown && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세 데이터 보기
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필드 숨기기 */}
|
||||
{field && onHideField && (
|
||||
<ContextMenuItem onClick={() => onHideField(field.field)}>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
필드 숨기기
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{/* 복사 */}
|
||||
<ContextMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotContextMenu;
|
||||
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DrillDownModal 컴포넌트
|
||||
* 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotCellData, PivotFieldConfig } from "../types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface DrillDownModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cellData: PivotCellData | null;
|
||||
data: any[]; // 전체 원본 데이터
|
||||
fields: PivotFieldConfig[];
|
||||
rowFields: PivotFieldConfig[];
|
||||
columnFields: PivotFieldConfig[];
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const DrillDownModal: React.FC<DrillDownModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
cellData,
|
||||
data,
|
||||
fields,
|
||||
rowFields,
|
||||
columnFields,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
|
||||
|
||||
// 드릴다운 데이터 필터링
|
||||
const filteredData = useMemo(() => {
|
||||
if (!cellData || !data) return [];
|
||||
|
||||
// 행/열 경로에 해당하는 데이터 필터링
|
||||
let result = data.filter((row) => {
|
||||
// 행 경로 매칭
|
||||
for (let i = 0; i < cellData.rowPath.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (field && String(row[field.field]) !== cellData.rowPath[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 열 경로 매칭
|
||||
for (let i = 0; i < cellData.columnPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (field && String(row[field.field]) !== cellData.columnPath[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 검색 필터
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((row) =>
|
||||
Object.values(row).some((val) =>
|
||||
String(val).toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (sortConfig) {
|
||||
result = [...result].sort((a, b) => {
|
||||
const aVal = a[sortConfig.field];
|
||||
const bVal = b[sortConfig.field];
|
||||
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
let comparison = 0;
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
comparison = aVal - bVal;
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return sortConfig.direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, currentPage, pageSize]);
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
const displayColumns = useMemo(() => {
|
||||
// 모든 필드의 field명 수집
|
||||
const fieldNames = new Set<string>();
|
||||
|
||||
// fields에서 가져오기
|
||||
fields.forEach((f) => fieldNames.add(f.field));
|
||||
|
||||
// 데이터에서 추가 컬럼 가져오기
|
||||
if (data.length > 0) {
|
||||
Object.keys(data[0]).forEach((key) => fieldNames.add(key));
|
||||
}
|
||||
|
||||
return Array.from(fieldNames).map((fieldName) => {
|
||||
const fieldConfig = fields.find((f) => f.field === fieldName);
|
||||
return {
|
||||
field: fieldName,
|
||||
caption: fieldConfig?.caption || fieldName,
|
||||
dataType: fieldConfig?.dataType || "string",
|
||||
};
|
||||
});
|
||||
}, [fields, data]);
|
||||
|
||||
// 정렬 토글
|
||||
const handleSort = (field: string) => {
|
||||
setSortConfig((prev) => {
|
||||
if (!prev || prev.field !== field) {
|
||||
return { field, direction: "asc" };
|
||||
}
|
||||
if (prev.direction === "asc") {
|
||||
return { field, direction: "desc" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
// CSV 내보내기
|
||||
const handleExportCSV = () => {
|
||||
if (filteredData.length === 0) return;
|
||||
|
||||
const headers = displayColumns.map((c) => c.caption);
|
||||
const rows = filteredData.map((row) =>
|
||||
displayColumns.map((c) => {
|
||||
const val = row[c.field];
|
||||
if (val === null || val === undefined) return "";
|
||||
if (typeof val === "string" && val.includes(",")) {
|
||||
return `"${val}"`;
|
||||
}
|
||||
return String(val);
|
||||
})
|
||||
);
|
||||
|
||||
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
|
||||
|
||||
const blob = new Blob(["\uFEFF" + csv], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
// 페이지 변경
|
||||
const goToPage = (page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
};
|
||||
|
||||
// 경로 표시
|
||||
const pathDisplay = cellData
|
||||
? [
|
||||
...(cellData.rowPath.length > 0
|
||||
? [`행: ${cellData.rowPath.join(" > ")}`]
|
||||
: []),
|
||||
...(cellData.columnPath.length > 0
|
||||
? [`열: ${cellData.columnPath.join(" > ")}`]
|
||||
: []),
|
||||
].join(" | ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>상세 데이터</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pathDisplay || "선택한 셀의 원본 데이터"}
|
||||
<span className="ml-2 text-primary font-medium">
|
||||
({filteredData.length}건)
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="flex items-center gap-2 py-2 border-b border-border">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10개씩</SelectItem>
|
||||
<SelectItem value="20">20개씩</SelectItem>
|
||||
<SelectItem value="50">50개씩</SelectItem>
|
||||
<SelectItem value="100">100개씩</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredData.length === 0}
|
||||
className="h-9"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<ScrollArea className="flex-1 -mx-6">
|
||||
<div className="px-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{displayColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.field}
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort(col.field)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{col.caption}</span>
|
||||
{sortConfig?.field === col.field ? (
|
||||
sortConfig.direction === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={displayColumns.length}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{displayColumns.map((col) => (
|
||||
<TableCell
|
||||
key={col.field}
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
col.dataType === "number" && "text-right tabular-nums"
|
||||
)}
|
||||
>
|
||||
{formatCellValue(row[col.field], col.dataType)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(currentPage - 1) * pageSize + 1} -{" "}
|
||||
{Math.min(currentPage * pageSize, filteredData.length)} /{" "}
|
||||
{filteredData.length}건
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="px-3 text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => goToPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function formatCellValue(value: any, dataType: string): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
if (dataType === "number") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return String(value);
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
if (dataType === "date") {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toLocaleDateString("ko-KR");
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 원본 반환
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default DrillDownModal;
|
||||
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* FieldChooser 컴포넌트
|
||||
* 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Columns,
|
||||
Rows,
|
||||
BarChart3,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Minus,
|
||||
Type,
|
||||
Hash,
|
||||
Calendar,
|
||||
ToggleLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface AvailableField {
|
||||
field: string;
|
||||
caption: string;
|
||||
dataType: "string" | "number" | "date" | "boolean";
|
||||
isSelected: boolean;
|
||||
currentArea?: PivotAreaType;
|
||||
}
|
||||
|
||||
interface FieldChooserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
availableFields: AvailableField[];
|
||||
selectedFields: PivotFieldConfig[];
|
||||
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||
}
|
||||
|
||||
// ==================== 영역 설정 ====================
|
||||
|
||||
const AREA_OPTIONS: {
|
||||
value: PivotAreaType | "none";
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{ value: "none", label: "사용 안함", icon: <Minus className="h-3.5 w-3.5" /> },
|
||||
{ value: "filter", label: "필터", icon: <Filter className="h-3.5 w-3.5" /> },
|
||||
{ value: "row", label: "행", icon: <Rows className="h-3.5 w-3.5" /> },
|
||||
{ value: "column", label: "열", icon: <Columns className="h-3.5 w-3.5" /> },
|
||||
{ value: "data", label: "데이터", icon: <BarChart3 className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [
|
||||
{ value: "sum", label: "합계" },
|
||||
{ value: "count", label: "개수" },
|
||||
{ value: "avg", label: "평균" },
|
||||
{ value: "min", label: "최소" },
|
||||
{ value: "max", label: "최대" },
|
||||
{ value: "countDistinct", label: "고유 개수" },
|
||||
];
|
||||
|
||||
const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
||||
{ value: "absoluteValue", label: "절대값" },
|
||||
{ value: "percentOfRowTotal", label: "행 총계 %" },
|
||||
{ value: "percentOfColumnTotal", label: "열 총계 %" },
|
||||
{ value: "percentOfGrandTotal", label: "전체 총계 %" },
|
||||
{ value: "runningTotalByRow", label: "행 누계" },
|
||||
{ value: "runningTotalByColumn", label: "열 누계" },
|
||||
{ value: "differenceFromPrevious", label: "이전 대비 차이" },
|
||||
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
||||
];
|
||||
|
||||
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "none", label: "그룹 없음" },
|
||||
{ value: "year", label: "년" },
|
||||
{ value: "quarter", label: "분기" },
|
||||
{ value: "month", label: "월" },
|
||||
{ value: "week", label: "주" },
|
||||
{ value: "day", label: "일" },
|
||||
];
|
||||
|
||||
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
string: <Type className="h-3.5 w-3.5" />,
|
||||
number: <Hash className="h-3.5 w-3.5" />,
|
||||
date: <Calendar className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeft className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
// ==================== 필드 아이템 ====================
|
||||
|
||||
interface FieldItemProps {
|
||||
field: AvailableField;
|
||||
config?: PivotFieldConfig;
|
||||
onAreaChange: (area: PivotAreaType | "none") => void;
|
||||
onSummaryChange?: (summary: AggregationType) => void;
|
||||
onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void;
|
||||
}
|
||||
|
||||
const FieldItem: React.FC<FieldItemProps> = ({
|
||||
field,
|
||||
config,
|
||||
onAreaChange,
|
||||
onSummaryChange,
|
||||
onDisplayModeChange,
|
||||
}) => {
|
||||
const currentArea = config?.area || "none";
|
||||
const isSelected = currentArea !== "none";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-md border",
|
||||
"transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 border-primary/30"
|
||||
: "bg-background border-border hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{/* 데이터 타입 아이콘 */}
|
||||
<div className="text-muted-foreground">
|
||||
{DATA_TYPE_ICONS[field.dataType] || <Type className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* 필드명 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{field.caption}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{field.field}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영역 선택 */}
|
||||
<Select
|
||||
value={currentArea}
|
||||
onValueChange={(value) => onAreaChange(value as PivotAreaType | "none")}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AREA_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 집계 함수 선택 (데이터 영역인 경우) */}
|
||||
{currentArea === "data" && onSummaryChange && (
|
||||
<Select
|
||||
value={config?.summaryType || "sum"}
|
||||
onValueChange={(value) => onSummaryChange(value as AggregationType)}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUMMARY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 표시 모드 선택 (데이터 영역인 경우) */}
|
||||
{currentArea === "data" && onDisplayModeChange && (
|
||||
<Select
|
||||
value={config?.summaryDisplayMode || "absoluteValue"}
|
||||
onValueChange={(value) => onDisplayModeChange(value as SummaryDisplayMode)}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISPLAY_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FieldChooser: React.FC<FieldChooserProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
availableFields,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">(
|
||||
"all"
|
||||
);
|
||||
|
||||
// 필터링된 필드 목록
|
||||
const filteredFields = useMemo(() => {
|
||||
let result = availableFields;
|
||||
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(f) =>
|
||||
f.caption.toLowerCase().includes(query) ||
|
||||
f.field.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// 선택 상태 필터
|
||||
if (filterType === "selected") {
|
||||
result = result.filter((f) =>
|
||||
selectedFields.some((sf) => sf.field === f.field && sf.visible !== false)
|
||||
);
|
||||
} else if (filterType === "unselected") {
|
||||
result = result.filter(
|
||||
(f) =>
|
||||
!selectedFields.some(
|
||||
(sf) => sf.field === f.field && sf.visible !== false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [availableFields, selectedFields, searchQuery, filterType]);
|
||||
|
||||
// 필드 영역 변경
|
||||
const handleAreaChange = (
|
||||
field: AvailableField,
|
||||
area: PivotAreaType | "none"
|
||||
) => {
|
||||
const existingConfig = selectedFields.find((f) => f.field === field.field);
|
||||
|
||||
if (area === "none") {
|
||||
// 필드 제거 또는 숨기기
|
||||
if (existingConfig) {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, visible: false } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
} else {
|
||||
// 필드 추가 또는 영역 변경
|
||||
if (existingConfig) {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field
|
||||
? { ...f, area, visible: true }
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
} else {
|
||||
// 새 필드 추가
|
||||
const newField: PivotFieldConfig = {
|
||||
field: field.field,
|
||||
caption: field.caption,
|
||||
area,
|
||||
dataType: field.dataType,
|
||||
visible: true,
|
||||
summaryType: area === "data" ? "sum" : undefined,
|
||||
areaIndex: selectedFields.filter((f) => f.area === area).length,
|
||||
};
|
||||
onFieldsChange([...selectedFields, newField]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 집계 함수 변경
|
||||
const handleSummaryChange = (
|
||||
field: AvailableField,
|
||||
summaryType: AggregationType
|
||||
) => {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, summaryType } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 표시 모드 변경
|
||||
const handleDisplayModeChange = (
|
||||
field: AvailableField,
|
||||
displayMode: SummaryDisplayMode
|
||||
) => {
|
||||
const newFields = selectedFields.map((f) =>
|
||||
f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 모든 필드 선택 해제
|
||||
const handleClearAll = () => {
|
||||
const newFields = selectedFields.map((f) => ({ ...f, visible: false }));
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => {
|
||||
const visible = selectedFields.filter((f) => f.visible !== false);
|
||||
return {
|
||||
total: availableFields.length,
|
||||
selected: visible.length,
|
||||
filter: visible.filter((f) => f.area === "filter").length,
|
||||
row: visible.filter((f) => f.area === "row").length,
|
||||
column: visible.filter((f) => f.area === "column").length,
|
||||
data: visible.filter((f) => f.area === "data").length,
|
||||
};
|
||||
}, [availableFields, selectedFields]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>필드 선택기</DialogTitle>
|
||||
<DialogDescription>
|
||||
피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="flex items-center gap-4 py-2 px-1 text-xs text-muted-foreground border-b border-border">
|
||||
<span>전체: {stats.total}</span>
|
||||
<span className="text-primary font-medium">
|
||||
선택됨: {stats.selected}
|
||||
</span>
|
||||
<span>필터: {stats.filter}</span>
|
||||
<span>행: {stats.row}</span>
|
||||
<span>열: {stats.column}</span>
|
||||
<span>데이터: {stats.data}</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filterType}
|
||||
onValueChange={(v) =>
|
||||
setFilterType(v as "all" | "selected" | "unselected")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="selected">선택됨</SelectItem>
|
||||
<SelectItem value="unselected">미선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-9"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<ScrollArea className="flex-1 -mx-6 px-6 max-h-[40vh] overflow-y-auto">
|
||||
<div className="space-y-2 py-2">
|
||||
{filteredFields.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
filteredFields.map((field) => {
|
||||
const config = selectedFields.find(
|
||||
(f) => f.field === field.field && f.visible !== false
|
||||
);
|
||||
return (
|
||||
<FieldItem
|
||||
key={field.field}
|
||||
field={field}
|
||||
config={config}
|
||||
onAreaChange={(area) => handleAreaChange(field, area)}
|
||||
onSummaryChange={
|
||||
config?.area === "data"
|
||||
? (summary) => handleSummaryChange(field, summary)
|
||||
: undefined
|
||||
}
|
||||
onDisplayModeChange={
|
||||
config?.area === "data"
|
||||
? (mode) => handleDisplayModeChange(field, mode)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldChooser;
|
||||
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* FieldPanel 컴포넌트
|
||||
* 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터)
|
||||
* 드래그 앤 드롭으로 필드 재배치 가능
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig, PivotAreaType } from "../types";
|
||||
import {
|
||||
X,
|
||||
Filter,
|
||||
Columns,
|
||||
Rows,
|
||||
BarChart3,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface FieldPanelProps {
|
||||
fields: PivotFieldConfig[];
|
||||
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||
onFieldRemove?: (field: PivotFieldConfig) => void;
|
||||
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
interface FieldChipProps {
|
||||
field: PivotFieldConfig;
|
||||
onRemove: () => void;
|
||||
onSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
}
|
||||
|
||||
interface DroppableAreaProps {
|
||||
area: PivotAreaType;
|
||||
fields: PivotFieldConfig[];
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
onFieldRemove: (field: PivotFieldConfig) => void;
|
||||
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 영역 설정 ====================
|
||||
|
||||
const AREA_CONFIG: Record<
|
||||
PivotAreaType,
|
||||
{ title: string; icon: React.ReactNode; color: string }
|
||||
> = {
|
||||
filter: {
|
||||
title: "필터",
|
||||
icon: <Filter className="h-3.5 w-3.5" />,
|
||||
color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800",
|
||||
},
|
||||
column: {
|
||||
title: "열",
|
||||
icon: <Columns className="h-3.5 w-3.5" />,
|
||||
color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800",
|
||||
},
|
||||
row: {
|
||||
title: "행",
|
||||
icon: <Rows className="h-3.5 w-3.5" />,
|
||||
color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800",
|
||||
},
|
||||
data: {
|
||||
title: "데이터",
|
||||
icon: <BarChart3 className="h-3.5 w-3.5" />,
|
||||
color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800",
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 필드 칩 (드래그 가능) ====================
|
||||
|
||||
const SortableFieldChip: React.FC<FieldChipProps> = ({
|
||||
field,
|
||||
onRemove,
|
||||
onSettingsChange,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `${field.area}-${field.field}` });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
"bg-background border border-border shadow-sm",
|
||||
"hover:bg-accent/50 transition-colors",
|
||||
isDragging && "opacity-50 shadow-lg"
|
||||
)}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{/* 필드 라벨 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 hover:text-primary">
|
||||
<span className="font-medium">{field.caption}</span>
|
||||
{field.area === "data" && field.summaryType && (
|
||||
<span className="text-muted-foreground">
|
||||
({getSummaryLabel(field.summaryType)})
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{field.area === "data" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "sum" })
|
||||
}
|
||||
>
|
||||
합계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "count" })
|
||||
}
|
||||
>
|
||||
개수
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "avg" })
|
||||
}
|
||||
>
|
||||
평균
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "min" })
|
||||
}
|
||||
>
|
||||
최소
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({ ...field, summaryType: "max" })
|
||||
}
|
||||
>
|
||||
최대
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSettingsChange?.({
|
||||
...field,
|
||||
sortOrder: field.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
>
|
||||
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSettingsChange?.({ ...field, visible: false })}
|
||||
>
|
||||
필드 숨기기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 드롭 영역 ====================
|
||||
|
||||
const DroppableArea: React.FC<DroppableAreaProps> = ({
|
||||
area,
|
||||
fields,
|
||||
title,
|
||||
icon,
|
||||
onFieldRemove,
|
||||
onFieldSettingsChange,
|
||||
isOver,
|
||||
}) => {
|
||||
const config = AREA_CONFIG[area];
|
||||
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
|
||||
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-[44px] rounded border border-dashed p-1.5",
|
||||
"transition-colors duration-200",
|
||||
config.color,
|
||||
isOver && "border-primary bg-primary/5"
|
||||
)}
|
||||
data-area={area}
|
||||
>
|
||||
{/* 영역 헤더 */}
|
||||
<div className="flex items-center gap-1 mb-1 text-[11px] font-medium text-muted-foreground">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{areaFields.length > 0 && (
|
||||
<span className="text-[10px] bg-muted px-1 rounded">
|
||||
{areaFields.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-1 min-h-[22px]">
|
||||
{areaFields.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground/50 italic">
|
||||
필드를 여기로 드래그
|
||||
</span>
|
||||
) : (
|
||||
areaFields.map((field) => (
|
||||
<SortableFieldChip
|
||||
key={`${area}-${field.field}`}
|
||||
field={field}
|
||||
onRemove={() => onFieldRemove(field)}
|
||||
onSettingsChange={onFieldSettingsChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function getSummaryLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
sum: "합계",
|
||||
count: "개수",
|
||||
avg: "평균",
|
||||
min: "최소",
|
||||
max: "최대",
|
||||
countDistinct: "고유",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onFieldRemove,
|
||||
onFieldSettingsChange,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [overArea, setOverArea] = useState<PivotAreaType | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { over } = event;
|
||||
if (!over) {
|
||||
setOverArea(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 드롭 영역 감지
|
||||
const overId = over.id as string;
|
||||
const targetArea = overId.split("-")[0] as PivotAreaType;
|
||||
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||
setOverArea(targetArea);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
setOverArea(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// 필드 정보 파싱
|
||||
const [sourceArea, sourceField] = activeId.split("-") as [
|
||||
PivotAreaType,
|
||||
string
|
||||
];
|
||||
const [targetArea] = overId.split("-") as [PivotAreaType, string];
|
||||
|
||||
// 같은 영역 내 정렬
|
||||
if (sourceArea === targetArea) {
|
||||
const areaFields = fields.filter((f) => f.area === sourceArea);
|
||||
const sourceIndex = areaFields.findIndex((f) => f.field === sourceField);
|
||||
const targetIndex = areaFields.findIndex(
|
||||
(f) => `${f.area}-${f.field}` === overId
|
||||
);
|
||||
|
||||
if (sourceIndex !== targetIndex && targetIndex >= 0) {
|
||||
// 순서 변경
|
||||
const newFields = [...fields];
|
||||
const fieldToMove = newFields.find(
|
||||
(f) => f.field === sourceField && f.area === sourceArea
|
||||
);
|
||||
if (fieldToMove) {
|
||||
fieldToMove.areaIndex = targetIndex;
|
||||
// 다른 필드들 인덱스 조정
|
||||
newFields
|
||||
.filter((f) => f.area === sourceArea && f.field !== sourceField)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
|
||||
.forEach((f, idx) => {
|
||||
f.areaIndex = idx >= targetIndex ? idx + 1 : idx;
|
||||
});
|
||||
}
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 영역으로 이동
|
||||
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||
const newFields = fields.map((f) => {
|
||||
if (f.field === sourceField && f.area === sourceArea) {
|
||||
return {
|
||||
...f,
|
||||
area: targetArea as PivotAreaType,
|
||||
areaIndex: fields.filter((ff) => ff.area === targetArea).length,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const handleFieldRemove = (field: PivotFieldConfig) => {
|
||||
if (onFieldRemove) {
|
||||
onFieldRemove(field);
|
||||
} else {
|
||||
// 기본 동작: visible을 false로 설정
|
||||
const newFields = fields.map((f) =>
|
||||
f.field === field.field && f.area === field.area
|
||||
? { ...f, visible: false }
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 설정 변경
|
||||
const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => {
|
||||
if (onFieldSettingsChange) {
|
||||
onFieldSettingsChange(updatedField);
|
||||
}
|
||||
const newFields = fields.map((f) =>
|
||||
f.field === updatedField.field && f.area === updatedField.area
|
||||
? updatedField
|
||||
: f
|
||||
);
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 활성 필드 찾기 (드래그 중인 필드)
|
||||
const activeField = activeId
|
||||
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
||||
: null;
|
||||
|
||||
// 각 영역의 필드 수 계산
|
||||
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
|
||||
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
|
||||
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
|
||||
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{filterCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Filter className="h-3 w-3" />
|
||||
필터 {filterCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Columns className="h-3 w-3" />
|
||||
열 {columnCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Rows className="h-3 w-3" />
|
||||
행 {rowCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
데이터 {dataCount}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
필드 설정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="border-b border-border bg-muted/20 p-2">
|
||||
{/* 4개 영역 배치: 2x2 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 필터 영역 */}
|
||||
<DroppableArea
|
||||
area="filter"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.filter.title}
|
||||
icon={AREA_CONFIG.filter.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "filter"}
|
||||
/>
|
||||
|
||||
{/* 열 영역 */}
|
||||
<DroppableArea
|
||||
area="column"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.column.title}
|
||||
icon={AREA_CONFIG.column.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "column"}
|
||||
/>
|
||||
|
||||
{/* 행 영역 */}
|
||||
<DroppableArea
|
||||
area="row"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.row.title}
|
||||
icon={AREA_CONFIG.row.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "row"}
|
||||
/>
|
||||
|
||||
{/* 데이터 영역 */}
|
||||
<DroppableArea
|
||||
area="data"
|
||||
fields={fields}
|
||||
title={AREA_CONFIG.data.title}
|
||||
icon={AREA_CONFIG.data.icon}
|
||||
onFieldRemove={handleFieldRemove}
|
||||
onFieldSettingsChange={handleFieldSettingsChange}
|
||||
isOver={overArea === "data"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 접기 버튼 */}
|
||||
{onToggleCollapse && (
|
||||
<div className="flex justify-center mt-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-5 px-2"
|
||||
>
|
||||
필드 패널 접기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 드래그 오버레이 */}
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
"bg-background border border-primary shadow-lg"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium">{activeField.caption}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldPanel;
|
||||
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* FilterPopup 컴포넌트
|
||||
* 피벗 필드의 값을 필터링하는 팝업
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotFieldConfig } from "../types";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Check,
|
||||
X,
|
||||
CheckSquare,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface FilterPopupProps {
|
||||
field: PivotFieldConfig;
|
||||
data: any[];
|
||||
onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const FilterPopup: React.FC<FilterPopupProps> = ({
|
||||
field,
|
||||
data,
|
||||
onFilterChange,
|
||||
trigger,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedValues, setSelectedValues] = useState<Set<any>>(
|
||||
new Set(field.filterValues || [])
|
||||
);
|
||||
const [filterType, setFilterType] = useState<"include" | "exclude">(
|
||||
field.filterType || "include"
|
||||
);
|
||||
|
||||
// 고유 값 추출
|
||||
const uniqueValues = useMemo(() => {
|
||||
const values = new Set<any>();
|
||||
data.forEach((row) => {
|
||||
const value = row[field.field];
|
||||
if (value !== null && value !== undefined) {
|
||||
values.add(value);
|
||||
}
|
||||
});
|
||||
return Array.from(values).sort((a, b) => {
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
return String(a).localeCompare(String(b), "ko");
|
||||
});
|
||||
}, [data, field.field]);
|
||||
|
||||
// 필터링된 값 목록
|
||||
const filteredValues = useMemo(() => {
|
||||
if (!searchQuery) return uniqueValues;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return uniqueValues.filter((val) =>
|
||||
String(val).toLowerCase().includes(query)
|
||||
);
|
||||
}, [uniqueValues, searchQuery]);
|
||||
|
||||
// 값 토글
|
||||
const handleValueToggle = (value: any) => {
|
||||
const newSelected = new Set(selectedValues);
|
||||
if (newSelected.has(value)) {
|
||||
newSelected.delete(value);
|
||||
} else {
|
||||
newSelected.add(value);
|
||||
}
|
||||
setSelectedValues(newSelected);
|
||||
};
|
||||
|
||||
// 모두 선택
|
||||
const handleSelectAll = () => {
|
||||
setSelectedValues(new Set(filteredValues));
|
||||
};
|
||||
|
||||
// 모두 해제
|
||||
const handleClearAll = () => {
|
||||
setSelectedValues(new Set());
|
||||
};
|
||||
|
||||
// 적용
|
||||
const handleApply = () => {
|
||||
onFilterChange(field, Array.from(selectedValues), filterType);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setSelectedValues(new Set());
|
||||
setFilterType("include");
|
||||
onFilterChange(field, [], "include");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// 필터 활성 상태
|
||||
const isFilterActive = field.filterValues && field.filterValues.length > 0;
|
||||
|
||||
// 선택된 항목 수
|
||||
const selectedCount = selectedValues.size;
|
||||
const totalCount = uniqueValues.length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger || (
|
||||
<button
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-accent",
|
||||
isFilterActive && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{field.caption} 필터</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setFilterType("include")}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded",
|
||||
filterType === "include"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
포함
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType("exclude")}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded",
|
||||
filterType === "exclude"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
제외
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전체 선택/해제 */}
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{selectedCount} / {totalCount} 선택됨
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
전체 선택
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
전체 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 값 목록 */}
|
||||
<ScrollArea className="h-48">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredValues.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredValues.map((value) => (
|
||||
<label
|
||||
key={String(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer",
|
||||
"hover:bg-muted text-sm"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.has(value)}
|
||||
onCheckedChange={() => handleValueToggle(value)}
|
||||
/>
|
||||
<span className="truncate">{String(value)}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
({data.filter((r) => r[field.field] === value).length})
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-between p-2 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPopup;
|
||||
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotChart 컴포넌트
|
||||
* 피벗 데이터를 차트로 시각화
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types";
|
||||
import { pathToKey } from "../utils/pivotEngine";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface PivotChartProps {
|
||||
pivotResult: PivotResult;
|
||||
config: PivotChartConfig;
|
||||
dataFields: PivotFieldConfig[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ==================== 색상 ====================
|
||||
|
||||
const COLORS = [
|
||||
"#4472C4", // 파랑
|
||||
"#ED7D31", // 주황
|
||||
"#A5A5A5", // 회색
|
||||
"#FFC000", // 노랑
|
||||
"#5B9BD5", // 하늘
|
||||
"#70AD47", // 초록
|
||||
"#264478", // 진한 파랑
|
||||
"#9E480E", // 진한 주황
|
||||
"#636363", // 진한 회색
|
||||
"#997300", // 진한 노랑
|
||||
];
|
||||
|
||||
// ==================== 데이터 변환 ====================
|
||||
|
||||
function transformDataForChart(
|
||||
pivotResult: PivotResult,
|
||||
dataFields: PivotFieldConfig[]
|
||||
): any[] {
|
||||
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
||||
|
||||
// 행 기준 차트 데이터 생성
|
||||
return flatRows.map((row) => {
|
||||
const dataPoint: any = {
|
||||
name: row.caption,
|
||||
path: row.path,
|
||||
};
|
||||
|
||||
// 각 열에 대한 데이터 추가
|
||||
flatColumns.forEach((col) => {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = dataMatrix.get(cellKey);
|
||||
|
||||
if (values && values.length > 0) {
|
||||
const columnName = col.caption || "전체";
|
||||
dataPoint[columnName] = values[0].value;
|
||||
}
|
||||
});
|
||||
|
||||
// 총계 추가
|
||||
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||
if (rowTotal && rowTotal.length > 0) {
|
||||
dataPoint["총계"] = rowTotal[0].value;
|
||||
}
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
}
|
||||
|
||||
function transformDataForPie(
|
||||
pivotResult: PivotResult,
|
||||
dataFields: PivotFieldConfig[]
|
||||
): any[] {
|
||||
const { flatRows, grandTotals } = pivotResult;
|
||||
|
||||
return flatRows.map((row, idx) => {
|
||||
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||
return {
|
||||
name: row.caption,
|
||||
value: rowTotal?.[0]?.value || 0,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 차트 컴포넌트 ====================
|
||||
|
||||
const CustomTooltip: React.FC<any> = ({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-2">
|
||||
<p className="text-sm font-medium mb-1">{label}</p>
|
||||
{payload.map((entry: any, idx: number) => (
|
||||
<p key={idx} className="text-xs" style={{ color: entry.color }}>
|
||||
{entry.name}: {entry.value?.toLocaleString()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 막대 차트
|
||||
const PivotBarChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
stacked?: boolean;
|
||||
}> = ({ data, columns, height, showLegend, stacked }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="square"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Bar
|
||||
key={col}
|
||||
dataKey={col}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
radius={stacked ? 0 : [4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 선 차트
|
||||
const PivotLineChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, columns, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="line"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Line
|
||||
key={col}
|
||||
type="monotone"
|
||||
dataKey={col}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: COLORS[idx % COLORS.length] }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 영역 차트
|
||||
const PivotAreaChart: React.FC<{
|
||||
data: any[];
|
||||
columns: string[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, columns, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e5e5" }}
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="square"
|
||||
/>
|
||||
)}
|
||||
{columns.map((col, idx) => (
|
||||
<Area
|
||||
key={col}
|
||||
type="monotone"
|
||||
dataKey={col}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 파이 차트
|
||||
const PivotPieChart: React.FC<{
|
||||
data: any[];
|
||||
height: number;
|
||||
showLegend: boolean;
|
||||
}> = ({ data, height, showLegend }) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={height / 3}
|
||||
label={({ name, percent }) =>
|
||||
`${name} (${(percent * 100).toFixed(1)}%)`
|
||||
}
|
||||
labelLine
|
||||
>
|
||||
{data.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.color || COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||
iconType="circle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const PivotChart: React.FC<PivotChartProps> = ({
|
||||
pivotResult,
|
||||
config,
|
||||
dataFields,
|
||||
className,
|
||||
}) => {
|
||||
// 차트 데이터 변환
|
||||
const chartData = useMemo(() => {
|
||||
if (config.type === "pie") {
|
||||
return transformDataForPie(pivotResult, dataFields);
|
||||
}
|
||||
return transformDataForChart(pivotResult, dataFields);
|
||||
}, [pivotResult, dataFields, config.type]);
|
||||
|
||||
// 열 이름 목록 (파이 차트 제외)
|
||||
const columns = useMemo(() => {
|
||||
if (config.type === "pie" || chartData.length === 0) return [];
|
||||
|
||||
const firstItem = chartData[0];
|
||||
return Object.keys(firstItem).filter(
|
||||
(key) => key !== "name" && key !== "path"
|
||||
);
|
||||
}, [chartData, config.type]);
|
||||
|
||||
const height = config.height || 300;
|
||||
const showLegend = config.showLegend !== false;
|
||||
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t border-border bg-background p-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 차트 렌더링 */}
|
||||
{config.type === "bar" && (
|
||||
<PivotBarChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "stackedBar" && (
|
||||
<PivotBarChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
stacked
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "line" && (
|
||||
<PivotLineChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "area" && (
|
||||
<PivotAreaChart
|
||||
data={chartData}
|
||||
columns={columns}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.type === "pie" && (
|
||||
<PivotPieChart
|
||||
data={chartData}
|
||||
height={height}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotChart;
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* PivotGrid 서브 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export { FieldPanel } from "./FieldPanel";
|
||||
export { FieldChooser } from "./FieldChooser";
|
||||
export { DrillDownModal } from "./DrillDownModal";
|
||||
export { FilterPopup } from "./FilterPopup";
|
||||
export { PivotChart } from "./PivotChart";
|
||||
export { PivotContextMenu } from "./ContextMenu";
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* PivotGrid 커스텀 훅 내보내기
|
||||
*/
|
||||
|
||||
export {
|
||||
useVirtualScroll,
|
||||
useVirtualColumnScroll,
|
||||
useVirtual2DScroll,
|
||||
} from "./useVirtualScroll";
|
||||
|
||||
export type {
|
||||
VirtualScrollOptions,
|
||||
VirtualScrollResult,
|
||||
VirtualColumnScrollOptions,
|
||||
VirtualColumnScrollResult,
|
||||
Virtual2DScrollOptions,
|
||||
Virtual2DScrollResult,
|
||||
} from "./useVirtualScroll";
|
||||
|
||||
export { usePivotState } from "./usePivotState";
|
||||
|
||||
export type {
|
||||
PivotStateConfig,
|
||||
SavedPivotState,
|
||||
UsePivotStateResult,
|
||||
} from "./usePivotState";
|
||||
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotState 훅
|
||||
* 피벗 그리드 상태 저장/복원 관리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { PivotFieldConfig, PivotGridState } from "../types";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface PivotStateConfig {
|
||||
enabled: boolean;
|
||||
storageKey?: string;
|
||||
storageType?: "localStorage" | "sessionStorage";
|
||||
}
|
||||
|
||||
export interface SavedPivotState {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
fields: PivotFieldConfig[];
|
||||
expandedRowPaths: string[][];
|
||||
expandedColumnPaths: string[][];
|
||||
filterConfig: Record<string, any[]>;
|
||||
sortConfig: {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UsePivotStateResult {
|
||||
// 상태
|
||||
fields: PivotFieldConfig[];
|
||||
pivotState: PivotGridState;
|
||||
|
||||
// 상태 변경
|
||||
setFields: (fields: PivotFieldConfig[]) => void;
|
||||
setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void;
|
||||
|
||||
// 저장/복원
|
||||
saveState: () => void;
|
||||
loadState: () => boolean;
|
||||
clearState: () => void;
|
||||
hasStoredState: () => boolean;
|
||||
|
||||
// 상태 정보
|
||||
lastSaved: Date | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
// ==================== 상수 ====================
|
||||
|
||||
const STATE_VERSION = "1.0.0";
|
||||
const DEFAULT_STORAGE_KEY = "pivot-grid-state";
|
||||
|
||||
// ==================== 훅 ====================
|
||||
|
||||
export function usePivotState(
|
||||
initialFields: PivotFieldConfig[],
|
||||
config: PivotStateConfig
|
||||
): UsePivotStateResult {
|
||||
const {
|
||||
enabled,
|
||||
storageKey = DEFAULT_STORAGE_KEY,
|
||||
storageType = "localStorage",
|
||||
} = config;
|
||||
|
||||
// 상태
|
||||
const [fields, setFieldsInternal] = useState<PivotFieldConfig[]>(initialFields);
|
||||
const [pivotState, setPivotStateInternal] = useState<PivotGridState>({
|
||||
expandedRowPaths: [],
|
||||
expandedColumnPaths: [],
|
||||
sortConfig: null,
|
||||
filterConfig: {},
|
||||
});
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialStateLoaded, setInitialStateLoaded] = useState(false);
|
||||
|
||||
// 스토리지 가져오기
|
||||
const getStorage = useCallback(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return storageType === "localStorage" ? localStorage : sessionStorage;
|
||||
}, [storageType]);
|
||||
|
||||
// 저장된 상태 확인
|
||||
const hasStoredState = useCallback((): boolean => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return false;
|
||||
return storage.getItem(storageKey) !== null;
|
||||
}, [getStorage, storageKey]);
|
||||
|
||||
// 상태 저장
|
||||
const saveState = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
const stateToSave: SavedPivotState = {
|
||||
version: STATE_VERSION,
|
||||
timestamp: Date.now(),
|
||||
fields,
|
||||
expandedRowPaths: pivotState.expandedRowPaths,
|
||||
expandedColumnPaths: pivotState.expandedColumnPaths,
|
||||
filterConfig: pivotState.filterConfig,
|
||||
sortConfig: pivotState.sortConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(stateToSave));
|
||||
setLastSaved(new Date());
|
||||
setIsDirty(false);
|
||||
console.log("✅ 피벗 상태 저장됨:", storageKey);
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 저장 실패:", error);
|
||||
}
|
||||
}, [enabled, getStorage, storageKey, fields, pivotState]);
|
||||
|
||||
// 상태 불러오기
|
||||
const loadState = useCallback((): boolean => {
|
||||
if (!enabled) return false;
|
||||
|
||||
const storage = getStorage();
|
||||
if (!storage) return false;
|
||||
|
||||
try {
|
||||
const saved = storage.getItem(storageKey);
|
||||
if (!saved) return false;
|
||||
|
||||
const parsedState: SavedPivotState = JSON.parse(saved);
|
||||
|
||||
// 버전 체크
|
||||
if (parsedState.version !== STATE_VERSION) {
|
||||
console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 복원
|
||||
setFieldsInternal(parsedState.fields);
|
||||
setPivotStateInternal({
|
||||
expandedRowPaths: parsedState.expandedRowPaths,
|
||||
expandedColumnPaths: parsedState.expandedColumnPaths,
|
||||
sortConfig: parsedState.sortConfig,
|
||||
filterConfig: parsedState.filterConfig,
|
||||
});
|
||||
setLastSaved(new Date(parsedState.timestamp));
|
||||
setIsDirty(false);
|
||||
|
||||
console.log("✅ 피벗 상태 복원됨:", storageKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 복원 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}, [enabled, getStorage, storageKey]);
|
||||
|
||||
// 상태 초기화
|
||||
const clearState = useCallback(() => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
storage.removeItem(storageKey);
|
||||
setLastSaved(null);
|
||||
console.log("🗑️ 피벗 상태 삭제됨:", storageKey);
|
||||
} catch (error) {
|
||||
console.error("❌ 피벗 상태 삭제 실패:", error);
|
||||
}
|
||||
}, [getStorage, storageKey]);
|
||||
|
||||
// 필드 변경 (dirty 플래그 설정)
|
||||
const setFields = useCallback((newFields: PivotFieldConfig[]) => {
|
||||
setFieldsInternal(newFields);
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
// 피벗 상태 변경 (dirty 플래그 설정)
|
||||
const setPivotState = useCallback(
|
||||
(newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => {
|
||||
setPivotStateInternal(newState);
|
||||
setIsDirty(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (!initialStateLoaded && enabled && hasStoredState()) {
|
||||
loadState();
|
||||
setInitialStateLoaded(true);
|
||||
}
|
||||
}, [enabled, hasStoredState, loadState, initialStateLoaded]);
|
||||
|
||||
// 초기 필드 동기화 (저장된 상태가 없을 때)
|
||||
useEffect(() => {
|
||||
if (initialStateLoaded) return;
|
||||
if (!hasStoredState() && initialFields.length > 0) {
|
||||
setFieldsInternal(initialFields);
|
||||
setInitialStateLoaded(true);
|
||||
}
|
||||
}, [initialFields, hasStoredState, initialStateLoaded]);
|
||||
|
||||
// 자동 저장 (변경 시)
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDirty) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
saveState();
|
||||
}, 1000); // 1초 디바운스
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [enabled, isDirty, saveState]);
|
||||
|
||||
return {
|
||||
fields,
|
||||
pivotState,
|
||||
setFields,
|
||||
setPivotState,
|
||||
saveState,
|
||||
loadState,
|
||||
clearState,
|
||||
hasStoredState,
|
||||
lastSaved,
|
||||
isDirty,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePivotState;
|
||||
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Virtual Scroll 훅
|
||||
* 대용량 피벗 데이터의 가상 스크롤 처리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemCount: number; // 전체 아이템 수
|
||||
itemHeight: number; // 각 아이템 높이 (px)
|
||||
containerHeight: number; // 컨테이너 높이 (px)
|
||||
overscan?: number; // 버퍼 아이템 수 (기본: 5)
|
||||
}
|
||||
|
||||
export interface VirtualScrollResult {
|
||||
// 현재 보여야 할 아이템 범위
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
|
||||
// 가상 스크롤 관련 값
|
||||
totalHeight: number; // 전체 높이
|
||||
offsetTop: number; // 상단 오프셋
|
||||
|
||||
// 보여지는 아이템 목록
|
||||
visibleItems: number[];
|
||||
|
||||
// 이벤트 핸들러
|
||||
onScroll: (scrollTop: number) => void;
|
||||
|
||||
// 컨테이너 ref
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// ==================== 훅 ====================
|
||||
|
||||
export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult {
|
||||
const {
|
||||
itemCount,
|
||||
itemHeight,
|
||||
containerHeight,
|
||||
overscan = 5,
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
|
||||
// 보이는 아이템 수
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
||||
|
||||
// 시작/끝 인덱스 계산
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||
const end = Math.min(
|
||||
itemCount - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
|
||||
);
|
||||
return { startIndex: start, endIndex: end };
|
||||
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
|
||||
|
||||
// 전체 높이
|
||||
const totalHeight = itemCount * itemHeight;
|
||||
|
||||
// 상단 오프셋
|
||||
const offsetTop = startIndex * itemHeight;
|
||||
|
||||
// 보이는 아이템 인덱스 배열
|
||||
const visibleItems = useMemo(() => {
|
||||
const items: number[] = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
return items;
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
// 스크롤 핸들러
|
||||
const onScroll = useCallback((newScrollTop: number) => {
|
||||
setScrollTop(newScrollTop);
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollTop(container.scrollTop);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
visibleItems,
|
||||
onScroll,
|
||||
containerRef,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 열 가상 스크롤 ====================
|
||||
|
||||
export interface VirtualColumnScrollOptions {
|
||||
columnCount: number; // 전체 열 수
|
||||
columnWidth: number; // 각 열 너비 (px)
|
||||
containerWidth: number; // 컨테이너 너비 (px)
|
||||
overscan?: number;
|
||||
}
|
||||
|
||||
export interface VirtualColumnScrollResult {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalWidth: number;
|
||||
offsetLeft: number;
|
||||
visibleColumns: number[];
|
||||
onScroll: (scrollLeft: number) => void;
|
||||
}
|
||||
|
||||
export function useVirtualColumnScroll(
|
||||
options: VirtualColumnScrollOptions
|
||||
): VirtualColumnScrollResult {
|
||||
const {
|
||||
columnCount,
|
||||
columnWidth,
|
||||
containerWidth,
|
||||
overscan = 3,
|
||||
} = options;
|
||||
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan);
|
||||
const end = Math.min(
|
||||
columnCount - 1,
|
||||
Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan
|
||||
);
|
||||
return { startIndex: start, endIndex: end };
|
||||
}, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]);
|
||||
|
||||
const totalWidth = columnCount * columnWidth;
|
||||
const offsetLeft = startIndex * columnWidth;
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
const cols: number[] = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
cols.push(i);
|
||||
}
|
||||
return cols;
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
const onScroll = useCallback((newScrollLeft: number) => {
|
||||
setScrollLeft(newScrollLeft);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalWidth,
|
||||
offsetLeft,
|
||||
visibleColumns,
|
||||
onScroll,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 2D 가상 스크롤 (행 + 열) ====================
|
||||
|
||||
export interface Virtual2DScrollOptions {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
rowHeight: number;
|
||||
columnWidth: number;
|
||||
containerHeight: number;
|
||||
containerWidth: number;
|
||||
rowOverscan?: number;
|
||||
columnOverscan?: number;
|
||||
}
|
||||
|
||||
export interface Virtual2DScrollResult {
|
||||
// 행 범위
|
||||
rowStartIndex: number;
|
||||
rowEndIndex: number;
|
||||
totalHeight: number;
|
||||
offsetTop: number;
|
||||
visibleRows: number[];
|
||||
|
||||
// 열 범위
|
||||
columnStartIndex: number;
|
||||
columnEndIndex: number;
|
||||
totalWidth: number;
|
||||
offsetLeft: number;
|
||||
visibleColumns: number[];
|
||||
|
||||
// 스크롤 핸들러
|
||||
onScroll: (scrollTop: number, scrollLeft: number) => void;
|
||||
|
||||
// 컨테이너 ref
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useVirtual2DScroll(
|
||||
options: Virtual2DScrollOptions
|
||||
): Virtual2DScrollResult {
|
||||
const {
|
||||
rowCount,
|
||||
columnCount,
|
||||
rowHeight,
|
||||
columnWidth,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
rowOverscan = 5,
|
||||
columnOverscan = 3,
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
// 행 계산
|
||||
const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan);
|
||||
const end = Math.min(
|
||||
rowCount - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan
|
||||
);
|
||||
|
||||
const rows: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
rows.push(i);
|
||||
}
|
||||
|
||||
return {
|
||||
rowStartIndex: start,
|
||||
rowEndIndex: end,
|
||||
visibleRows: rows,
|
||||
};
|
||||
}, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]);
|
||||
|
||||
// 열 계산
|
||||
const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan);
|
||||
const end = Math.min(
|
||||
columnCount - 1,
|
||||
Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan
|
||||
);
|
||||
|
||||
const cols: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
cols.push(i);
|
||||
}
|
||||
|
||||
return {
|
||||
columnStartIndex: start,
|
||||
columnEndIndex: end,
|
||||
visibleColumns: cols,
|
||||
};
|
||||
}, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]);
|
||||
|
||||
const totalHeight = rowCount * rowHeight;
|
||||
const totalWidth = columnCount * columnWidth;
|
||||
const offsetTop = rowStartIndex * rowHeight;
|
||||
const offsetLeft = columnStartIndex * columnWidth;
|
||||
|
||||
const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => {
|
||||
setScrollTop(newScrollTop);
|
||||
setScrollLeft(newScrollLeft);
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollTop(container.scrollTop);
|
||||
setScrollLeft(container.scrollLeft);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rowStartIndex,
|
||||
rowEndIndex,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
visibleRows,
|
||||
columnStartIndex,
|
||||
columnEndIndex,
|
||||
totalWidth,
|
||||
offsetLeft,
|
||||
visibleColumns,
|
||||
onScroll,
|
||||
containerRef,
|
||||
};
|
||||
}
|
||||
|
||||
export default useVirtualScroll;
|
||||
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* PivotGrid 컴포넌트 모듈
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||
*/
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
// 기본 타입
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
SummaryDisplayMode,
|
||||
SortDirection,
|
||||
DateGroupInterval,
|
||||
FieldDataType,
|
||||
DataSourceType,
|
||||
// 필드 설정
|
||||
PivotFieldFormat,
|
||||
PivotFieldConfig,
|
||||
// 데이터 소스
|
||||
PivotFilterCondition,
|
||||
PivotJoinConfig,
|
||||
PivotDataSourceConfig,
|
||||
// 표시 설정
|
||||
PivotTotalsConfig,
|
||||
FieldChooserConfig,
|
||||
PivotChartConfig,
|
||||
PivotStyleConfig,
|
||||
PivotExportConfig,
|
||||
// Props
|
||||
PivotGridProps,
|
||||
// 결과 데이터
|
||||
PivotCellData,
|
||||
PivotHeaderNode,
|
||||
PivotCellValue,
|
||||
PivotResult,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
// 상태
|
||||
PivotGridState,
|
||||
// Config
|
||||
PivotGridComponentConfig,
|
||||
} from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { PivotGridComponent } from "./PivotGridComponent";
|
||||
export { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
||||
|
||||
// 유틸리티
|
||||
export {
|
||||
aggregate,
|
||||
sum,
|
||||
count,
|
||||
avg,
|
||||
min,
|
||||
max,
|
||||
countDistinct,
|
||||
formatNumber,
|
||||
formatDate,
|
||||
getAggregationLabel,
|
||||
} from "./utils/aggregation";
|
||||
|
||||
export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine";
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
/**
|
||||
* PivotGrid 컴포넌트 타입 정의
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
// ==================== 기본 타입 ====================
|
||||
|
||||
// 필드 영역 타입
|
||||
export type PivotAreaType = "row" | "column" | "data" | "filter";
|
||||
|
||||
// 집계 함수 타입
|
||||
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
|
||||
|
||||
// 요약 표시 모드
|
||||
export type SummaryDisplayMode =
|
||||
| "absoluteValue" // 절대값 (기본)
|
||||
| "percentOfColumnTotal" // 열 총계 대비 %
|
||||
| "percentOfRowTotal" // 행 총계 대비 %
|
||||
| "percentOfGrandTotal" // 전체 총계 대비 %
|
||||
| "percentOfColumnGrandTotal" // 열 대총계 대비 %
|
||||
| "percentOfRowGrandTotal" // 행 대총계 대비 %
|
||||
| "runningTotalByRow" // 행 방향 누계
|
||||
| "runningTotalByColumn" // 열 방향 누계
|
||||
| "differenceFromPrevious" // 이전 대비 차이
|
||||
| "percentDifferenceFromPrevious"; // 이전 대비 % 차이
|
||||
|
||||
// 정렬 방향
|
||||
export type SortDirection = "asc" | "desc" | "none";
|
||||
|
||||
// 날짜 그룹 간격
|
||||
export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day";
|
||||
|
||||
// 필드 데이터 타입
|
||||
export type FieldDataType = "string" | "number" | "date" | "boolean";
|
||||
|
||||
// 데이터 소스 타입
|
||||
export type DataSourceType = "table" | "api" | "static";
|
||||
|
||||
// ==================== 필드 설정 ====================
|
||||
|
||||
// 필드 포맷 설정
|
||||
export interface PivotFieldFormat {
|
||||
type: "number" | "currency" | "percent" | "date" | "text";
|
||||
precision?: number; // 소수점 자릿수
|
||||
thousandSeparator?: boolean; // 천단위 구분자
|
||||
prefix?: string; // 접두사 (예: "$", "₩")
|
||||
suffix?: string; // 접미사 (예: "%", "원")
|
||||
dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD")
|
||||
}
|
||||
|
||||
// 필드 설정
|
||||
export interface PivotFieldConfig {
|
||||
// 기본 정보
|
||||
field: string; // 데이터 필드명
|
||||
caption: string; // 표시 라벨
|
||||
area: PivotAreaType; // 배치 영역
|
||||
areaIndex?: number; // 영역 내 순서
|
||||
|
||||
// 데이터 타입
|
||||
dataType?: FieldDataType; // 데이터 타입
|
||||
|
||||
// 집계 설정 (data 영역용)
|
||||
summaryType?: AggregationType; // 집계 함수
|
||||
summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드
|
||||
showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭)
|
||||
|
||||
// 정렬 설정
|
||||
sortBy?: "value" | "caption"; // 정렬 기준
|
||||
sortOrder?: SortDirection; // 정렬 방향
|
||||
sortBySummary?: string; // 요약값 기준 정렬 (data 필드명)
|
||||
|
||||
// 날짜 그룹화 설정
|
||||
groupInterval?: DateGroupInterval; // 날짜 그룹 간격
|
||||
groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성)
|
||||
|
||||
// 표시 설정
|
||||
visible?: boolean; // 표시 여부
|
||||
width?: number; // 컬럼 너비
|
||||
expanded?: boolean; // 기본 확장 상태
|
||||
|
||||
// 포맷 설정
|
||||
format?: PivotFieldFormat; // 값 포맷
|
||||
|
||||
// 필터 설정
|
||||
filterValues?: any[]; // 선택된 필터 값
|
||||
filterType?: "include" | "exclude"; // 필터 타입
|
||||
allowFiltering?: boolean; // 필터링 허용
|
||||
allowSorting?: boolean; // 정렬 허용
|
||||
|
||||
// 계층 관련
|
||||
displayFolder?: string; // 필드 선택기에서 폴더 구조
|
||||
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
|
||||
|
||||
// 계산 필드
|
||||
isCalculated?: boolean; // 계산 필드 여부
|
||||
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
|
||||
}
|
||||
|
||||
// ==================== 데이터 소스 설정 ====================
|
||||
|
||||
// 필터 조건
|
||||
export interface PivotFilterCondition {
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||
value?: any;
|
||||
valueFromField?: string; // formData에서 값 가져오기
|
||||
}
|
||||
|
||||
// 조인 설정
|
||||
export interface PivotJoinConfig {
|
||||
joinType: "INNER" | "LEFT" | "RIGHT";
|
||||
targetTable: string;
|
||||
sourceColumn: string;
|
||||
targetColumn: string;
|
||||
columns: string[]; // 가져올 컬럼들
|
||||
}
|
||||
|
||||
// 데이터 소스 설정
|
||||
export interface PivotDataSourceConfig {
|
||||
type: DataSourceType;
|
||||
|
||||
// 테이블 기반
|
||||
tableName?: string; // 테이블명
|
||||
|
||||
// API 기반
|
||||
apiEndpoint?: string; // API 엔드포인트
|
||||
apiMethod?: "GET" | "POST"; // HTTP 메서드
|
||||
|
||||
// 정적 데이터
|
||||
staticData?: any[]; // 정적 데이터
|
||||
|
||||
// 필터 조건
|
||||
filterConditions?: PivotFilterCondition[];
|
||||
|
||||
// 조인 설정
|
||||
joinConfigs?: PivotJoinConfig[];
|
||||
}
|
||||
|
||||
// ==================== 표시 설정 ====================
|
||||
|
||||
// 총합계 표시 설정
|
||||
export interface PivotTotalsConfig {
|
||||
// 행 총합계
|
||||
showRowGrandTotals?: boolean; // 행 총합계 표시
|
||||
showRowTotals?: boolean; // 행 소계 표시
|
||||
rowTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
|
||||
|
||||
// 열 총합계
|
||||
showColumnGrandTotals?: boolean; // 열 총합계 표시
|
||||
showColumnTotals?: boolean; // 열 소계 표시
|
||||
columnTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
|
||||
}
|
||||
|
||||
// 필드 선택기 설정
|
||||
export interface FieldChooserConfig {
|
||||
enabled: boolean; // 활성화 여부
|
||||
allowSearch?: boolean; // 검색 허용
|
||||
layout?: "default" | "simplified"; // 레이아웃
|
||||
height?: number; // 높이
|
||||
applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점
|
||||
}
|
||||
|
||||
// 차트 연동 설정
|
||||
export interface PivotChartConfig {
|
||||
enabled: boolean; // 차트 표시 여부
|
||||
type: "bar" | "line" | "area" | "pie" | "stackedBar";
|
||||
position: "top" | "bottom" | "left" | "right";
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
// 조건부 서식 규칙
|
||||
export interface ConditionalFormatRule {
|
||||
id: string;
|
||||
type: "colorScale" | "dataBar" | "iconSet" | "cellValue";
|
||||
field?: string; // 적용할 데이터 필드 (없으면 전체)
|
||||
|
||||
// colorScale: 값 범위에 따른 색상 그라데이션
|
||||
colorScale?: {
|
||||
minColor: string; // 최소값 색상 (예: "#ff0000")
|
||||
midColor?: string; // 중간값 색상 (선택)
|
||||
maxColor: string; // 최대값 색상 (예: "#00ff00")
|
||||
};
|
||||
|
||||
// dataBar: 값에 따른 막대 표시
|
||||
dataBar?: {
|
||||
color: string; // 막대 색상
|
||||
showValue?: boolean; // 값 표시 여부
|
||||
minValue?: number; // 최소값 (없으면 자동)
|
||||
maxValue?: number; // 최대값 (없으면 자동)
|
||||
};
|
||||
|
||||
// iconSet: 값에 따른 아이콘 표시
|
||||
iconSet?: {
|
||||
type: "arrows" | "traffic" | "rating" | "flags";
|
||||
thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100)
|
||||
reverse?: boolean; // 아이콘 순서 반전
|
||||
};
|
||||
|
||||
// cellValue: 조건에 따른 스타일
|
||||
cellValue?: {
|
||||
operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between";
|
||||
value1: number;
|
||||
value2?: number; // between 연산자용
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
bold?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 스타일 설정
|
||||
export interface PivotStyleConfig {
|
||||
theme: "default" | "compact" | "modern";
|
||||
headerStyle: "default" | "dark" | "light";
|
||||
cellPadding: "compact" | "normal" | "comfortable";
|
||||
borderStyle: "none" | "light" | "heavy";
|
||||
alternateRowColors?: boolean;
|
||||
highlightTotals?: boolean; // 총합계 강조
|
||||
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||
mergeCells?: boolean; // 같은 값 셀 병합
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
||||
export interface PivotExportConfig {
|
||||
excel?: boolean;
|
||||
pdf?: boolean;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// ==================== 메인 Props ====================
|
||||
|
||||
export interface PivotGridProps {
|
||||
// 기본 설정
|
||||
id?: string;
|
||||
title?: string;
|
||||
|
||||
// 데이터 소스
|
||||
dataSource?: PivotDataSourceConfig;
|
||||
|
||||
// 필드 설정
|
||||
fields?: PivotFieldConfig[];
|
||||
|
||||
// 표시 설정
|
||||
totals?: PivotTotalsConfig;
|
||||
style?: PivotStyleConfig;
|
||||
|
||||
// 필드 선택기
|
||||
fieldChooser?: FieldChooserConfig;
|
||||
|
||||
// 차트 연동
|
||||
chart?: PivotChartConfig;
|
||||
|
||||
// 기능 설정
|
||||
allowSortingBySummary?: boolean; // 요약값 기준 정렬
|
||||
allowFiltering?: boolean; // 필터링 허용
|
||||
allowExpandAll?: boolean; // 전체 확장/축소 허용
|
||||
wordWrapEnabled?: boolean; // 텍스트 줄바꿈
|
||||
|
||||
// 크기 설정
|
||||
height?: string | number;
|
||||
maxHeight?: string;
|
||||
|
||||
// 상태 저장
|
||||
stateStoring?: {
|
||||
enabled: boolean;
|
||||
storageKey?: string; // localStorage 키
|
||||
};
|
||||
|
||||
// 내보내기
|
||||
exportConfig?: PivotExportConfig;
|
||||
|
||||
// 데이터 (외부 주입용)
|
||||
data?: any[];
|
||||
|
||||
// 이벤트
|
||||
onCellClick?: (cellData: PivotCellData) => void;
|
||||
onCellDoubleClick?: (cellData: PivotCellData) => void;
|
||||
onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void;
|
||||
onExpandChange?: (expandedPaths: string[][]) => void;
|
||||
onDataChange?: (data: any[]) => void;
|
||||
}
|
||||
|
||||
// ==================== 결과 데이터 구조 ====================
|
||||
|
||||
// 셀 데이터
|
||||
export interface PivotCellData {
|
||||
value: any; // 셀 값
|
||||
rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"])
|
||||
columnPath: string[]; // 열 경로 (예: ["2024", "Q1"])
|
||||
field?: string; // 데이터 필드명
|
||||
aggregationType?: AggregationType;
|
||||
isTotal?: boolean; // 총합계 여부
|
||||
isGrandTotal?: boolean; // 대총합 여부
|
||||
}
|
||||
|
||||
// 헤더 노드 (트리 구조)
|
||||
export interface PivotHeaderNode {
|
||||
value: any; // 원본 값
|
||||
caption: string; // 표시 텍스트
|
||||
level: number; // 깊이
|
||||
children?: PivotHeaderNode[]; // 자식 노드
|
||||
isExpanded: boolean; // 확장 상태
|
||||
path: string[]; // 경로 (드릴다운용)
|
||||
subtotal?: PivotCellValue[]; // 소계
|
||||
span?: number; // colspan/rowspan
|
||||
}
|
||||
|
||||
// 셀 값
|
||||
export interface PivotCellValue {
|
||||
field: string; // 데이터 필드
|
||||
value: number | null; // 집계 값
|
||||
formattedValue: string; // 포맷된 값
|
||||
}
|
||||
|
||||
// 피벗 결과 데이터 구조
|
||||
export interface PivotResult {
|
||||
// 행 헤더 트리
|
||||
rowHeaders: PivotHeaderNode[];
|
||||
|
||||
// 열 헤더 트리
|
||||
columnHeaders: PivotHeaderNode[];
|
||||
|
||||
// 데이터 매트릭스 (rowPath + columnPath → values)
|
||||
dataMatrix: Map<string, PivotCellValue[]>;
|
||||
|
||||
// 플랫 행 목록 (렌더링용)
|
||||
flatRows: PivotFlatRow[];
|
||||
|
||||
// 플랫 열 목록 (렌더링용)
|
||||
flatColumns: PivotFlatColumn[];
|
||||
|
||||
// 총합계
|
||||
grandTotals: {
|
||||
row: Map<string, PivotCellValue[]>; // 행별 총합
|
||||
column: Map<string, PivotCellValue[]>; // 열별 총합
|
||||
grand: PivotCellValue[]; // 대총합
|
||||
};
|
||||
}
|
||||
|
||||
// 플랫 행 (렌더링용)
|
||||
export interface PivotFlatRow {
|
||||
path: string[];
|
||||
level: number;
|
||||
caption: string;
|
||||
isExpanded: boolean;
|
||||
hasChildren: boolean;
|
||||
isTotal?: boolean;
|
||||
}
|
||||
|
||||
// 플랫 열 (렌더링용)
|
||||
export interface PivotFlatColumn {
|
||||
path: string[];
|
||||
level: number;
|
||||
caption: string;
|
||||
span: number;
|
||||
isTotal?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
|
||||
export interface PivotGridState {
|
||||
expandedRowPaths: string[][]; // 확장된 행 경로들
|
||||
expandedColumnPaths: string[][]; // 확장된 열 경로들
|
||||
sortConfig: {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
} | null;
|
||||
filterConfig: Record<string, any[]>; // 필드별 필터값
|
||||
}
|
||||
|
||||
// ==================== 컴포넌트 Config (화면관리용) ====================
|
||||
|
||||
export interface PivotGridComponentConfig {
|
||||
// 데이터 소스
|
||||
dataSource?: PivotDataSourceConfig;
|
||||
|
||||
// 필드 설정
|
||||
fields?: PivotFieldConfig[];
|
||||
|
||||
// 표시 설정
|
||||
totals?: PivotTotalsConfig;
|
||||
style?: PivotStyleConfig;
|
||||
|
||||
// 필드 선택기
|
||||
fieldChooser?: FieldChooserConfig;
|
||||
|
||||
// 차트 연동
|
||||
chart?: PivotChartConfig;
|
||||
|
||||
// 기능 설정
|
||||
allowSortingBySummary?: boolean;
|
||||
allowFiltering?: boolean;
|
||||
allowExpandAll?: boolean;
|
||||
wordWrapEnabled?: boolean;
|
||||
|
||||
// 크기 설정
|
||||
height?: string | number;
|
||||
maxHeight?: string;
|
||||
|
||||
// 내보내기
|
||||
exportConfig?: PivotExportConfig;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* PivotGrid 집계 함수 유틸리티
|
||||
* 다양한 집계 연산을 수행합니다.
|
||||
*/
|
||||
|
||||
import { AggregationType, PivotFieldFormat } from "../types";
|
||||
|
||||
// ==================== 집계 함수 ====================
|
||||
|
||||
/**
|
||||
* 합계 계산
|
||||
*/
|
||||
export function sum(values: number[]): number {
|
||||
return values.reduce((acc, val) => acc + (val || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개수 계산
|
||||
*/
|
||||
export function count(values: any[]): number {
|
||||
return values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 평균 계산
|
||||
*/
|
||||
export function avg(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return sum(values) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최소값 계산
|
||||
*/
|
||||
export function min(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return Math.min(...values.filter((v) => v !== null && v !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대값 계산
|
||||
*/
|
||||
export function max(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return Math.max(...values.filter((v) => v !== null && v !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고유값 개수 계산
|
||||
*/
|
||||
export function countDistinct(values: any[]): number {
|
||||
return new Set(values.filter((v) => v !== null && v !== undefined)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 타입에 따른 집계 수행
|
||||
*/
|
||||
export function aggregate(
|
||||
values: any[],
|
||||
type: AggregationType = "sum"
|
||||
): number {
|
||||
const numericValues = values
|
||||
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
|
||||
.filter((v) => !isNaN(v));
|
||||
|
||||
switch (type) {
|
||||
case "sum":
|
||||
return sum(numericValues);
|
||||
case "count":
|
||||
return count(values);
|
||||
case "avg":
|
||||
return avg(numericValues);
|
||||
case "min":
|
||||
return min(numericValues);
|
||||
case "max":
|
||||
return max(numericValues);
|
||||
case "countDistinct":
|
||||
return countDistinct(values);
|
||||
default:
|
||||
return sum(numericValues);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 포맷 함수 ====================
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number | null | undefined,
|
||||
format?: PivotFieldFormat
|
||||
): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
const {
|
||||
type = "number",
|
||||
precision = 0,
|
||||
thousandSeparator = true,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
} = format || {};
|
||||
|
||||
let formatted: string;
|
||||
|
||||
switch (type) {
|
||||
case "currency":
|
||||
formatted = value.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "percent":
|
||||
formatted = (value * 100).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "number":
|
||||
default:
|
||||
if (thousandSeparator) {
|
||||
formatted = value.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
} else {
|
||||
formatted = value.toFixed(precision);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return `${prefix}${formatted}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
*/
|
||||
export function formatDate(
|
||||
value: Date | string | null | undefined,
|
||||
format: string = "YYYY-MM-DD"
|
||||
): string {
|
||||
if (!value) return "-";
|
||||
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
|
||||
if (isNaN(date.getTime())) return "-";
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const quarter = Math.ceil((date.getMonth() + 1) / 3);
|
||||
|
||||
return format
|
||||
.replace("YYYY", String(year))
|
||||
.replace("MM", month)
|
||||
.replace("DD", day)
|
||||
.replace("Q", `Q${quarter}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 타입 라벨 반환
|
||||
*/
|
||||
export function getAggregationLabel(type: AggregationType): string {
|
||||
const labels: Record<AggregationType, string> = {
|
||||
sum: "합계",
|
||||
count: "개수",
|
||||
avg: "평균",
|
||||
min: "최소",
|
||||
max: "최대",
|
||||
countDistinct: "고유값",
|
||||
};
|
||||
return labels[type] || "합계";
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* 조건부 서식 유틸리티
|
||||
* 셀 값에 따른 스타일 계산
|
||||
*/
|
||||
|
||||
import { ConditionalFormatRule } from "../types";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface CellFormatStyle {
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
fontWeight?: string;
|
||||
dataBarWidth?: number; // 0-100%
|
||||
dataBarColor?: string;
|
||||
icon?: string; // 이모지 또는 아이콘 이름
|
||||
}
|
||||
|
||||
// ==================== 색상 유틸리티 ====================
|
||||
|
||||
/**
|
||||
* HEX 색상을 RGB로 변환
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RGB를 HEX로 변환
|
||||
*/
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
"#" +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = Math.round(x).toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 색상 사이의 보간
|
||||
*/
|
||||
function interpolateColor(
|
||||
color1: string,
|
||||
color2: string,
|
||||
factor: number
|
||||
): string {
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return color1;
|
||||
|
||||
const r = rgb1.r + (rgb2.r - rgb1.r) * factor;
|
||||
const g = rgb1.g + (rgb2.g - rgb1.g) * factor;
|
||||
const b = rgb1.b + (rgb2.b - rgb1.b) * factor;
|
||||
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// ==================== 조건부 서식 계산 ====================
|
||||
|
||||
/**
|
||||
* Color Scale 스타일 계산
|
||||
*/
|
||||
function applyColorScale(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.colorScale) return {};
|
||||
|
||||
const { minColor, midColor, maxColor } = rule.colorScale;
|
||||
const range = maxValue - minValue;
|
||||
|
||||
if (range === 0) {
|
||||
return { backgroundColor: minColor };
|
||||
}
|
||||
|
||||
const normalizedValue = (value - minValue) / range;
|
||||
|
||||
let backgroundColor: string;
|
||||
|
||||
if (midColor) {
|
||||
// 3색 그라데이션
|
||||
if (normalizedValue <= 0.5) {
|
||||
backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2);
|
||||
} else {
|
||||
backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2);
|
||||
}
|
||||
} else {
|
||||
// 2색 그라데이션
|
||||
backgroundColor = interpolateColor(minColor, maxColor, normalizedValue);
|
||||
}
|
||||
|
||||
// 배경색에 따른 텍스트 색상 결정
|
||||
const rgb = hexToRgb(backgroundColor);
|
||||
const textColor =
|
||||
rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
|
||||
return { backgroundColor, textColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Bar 스타일 계산
|
||||
*/
|
||||
function applyDataBar(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.dataBar) return {};
|
||||
|
||||
const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar;
|
||||
|
||||
const min = ruleMin ?? minValue;
|
||||
const max = ruleMax ?? maxValue;
|
||||
const range = max - min;
|
||||
|
||||
if (range === 0) {
|
||||
return { dataBarWidth: 100, dataBarColor: color };
|
||||
}
|
||||
|
||||
const width = Math.max(0, Math.min(100, ((value - min) / range) * 100));
|
||||
|
||||
return {
|
||||
dataBarWidth: width,
|
||||
dataBarColor: color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon Set 스타일 계산
|
||||
*/
|
||||
function applyIconSet(
|
||||
value: number,
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.iconSet) return {};
|
||||
|
||||
const { type, thresholds, reverse } = rule.iconSet;
|
||||
const range = maxValue - minValue;
|
||||
const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100;
|
||||
|
||||
// 아이콘 정의
|
||||
const iconSets: Record<string, string[]> = {
|
||||
arrows: ["↓", "→", "↑"],
|
||||
traffic: ["🔴", "🟡", "🟢"],
|
||||
rating: ["⭐", "⭐⭐", "⭐⭐⭐"],
|
||||
flags: ["🚩", "🏳️", "🏁"],
|
||||
};
|
||||
|
||||
const icons = iconSets[type] || iconSets.arrows;
|
||||
const sortedIcons = reverse ? [...icons].reverse() : icons;
|
||||
|
||||
// 임계값에 따른 아이콘 선택
|
||||
let iconIndex = 0;
|
||||
for (let i = 0; i < thresholds.length; i++) {
|
||||
if (percentage >= thresholds[i]) {
|
||||
iconIndex = i + 1;
|
||||
}
|
||||
}
|
||||
iconIndex = Math.min(iconIndex, sortedIcons.length - 1);
|
||||
|
||||
return {
|
||||
icon: sortedIcons[iconIndex],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell Value 조건 스타일 계산
|
||||
*/
|
||||
function applyCellValue(
|
||||
value: number,
|
||||
rule: ConditionalFormatRule
|
||||
): CellFormatStyle {
|
||||
if (!rule.cellValue) return {};
|
||||
|
||||
const { operator, value1, value2, backgroundColor, textColor, bold } =
|
||||
rule.cellValue;
|
||||
|
||||
let matches = false;
|
||||
|
||||
switch (operator) {
|
||||
case ">":
|
||||
matches = value > value1;
|
||||
break;
|
||||
case ">=":
|
||||
matches = value >= value1;
|
||||
break;
|
||||
case "<":
|
||||
matches = value < value1;
|
||||
break;
|
||||
case "<=":
|
||||
matches = value <= value1;
|
||||
break;
|
||||
case "=":
|
||||
matches = value === value1;
|
||||
break;
|
||||
case "!=":
|
||||
matches = value !== value1;
|
||||
break;
|
||||
case "between":
|
||||
matches = value2 !== undefined && value >= value1 && value <= value2;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!matches) return {};
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
textColor,
|
||||
fontWeight: bold ? "bold" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 조건부 서식 적용
|
||||
*/
|
||||
export function getConditionalStyle(
|
||||
value: number | null | undefined,
|
||||
field: string,
|
||||
rules: ConditionalFormatRule[],
|
||||
allValues: number[] // 해당 필드의 모든 값 (min/max 계산용)
|
||||
): CellFormatStyle {
|
||||
if (value === null || value === undefined || isNaN(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!rules || rules.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// min/max 계산
|
||||
const numericValues = allValues.filter((v) => !isNaN(v));
|
||||
const minValue = Math.min(...numericValues);
|
||||
const maxValue = Math.max(...numericValues);
|
||||
|
||||
let resultStyle: CellFormatStyle = {};
|
||||
|
||||
// 해당 필드에 적용되는 규칙 필터링 및 적용
|
||||
for (const rule of rules) {
|
||||
// 필드 필터 확인
|
||||
if (rule.field && rule.field !== field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ruleStyle: CellFormatStyle = {};
|
||||
|
||||
switch (rule.type) {
|
||||
case "colorScale":
|
||||
ruleStyle = applyColorScale(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "dataBar":
|
||||
ruleStyle = applyDataBar(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "iconSet":
|
||||
ruleStyle = applyIconSet(value, minValue, maxValue, rule);
|
||||
break;
|
||||
case "cellValue":
|
||||
ruleStyle = applyCellValue(value, rule);
|
||||
break;
|
||||
}
|
||||
|
||||
// 스타일 병합 (나중 규칙이 우선)
|
||||
resultStyle = { ...resultStyle, ...ruleStyle };
|
||||
}
|
||||
|
||||
return resultStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 서식 스타일을 React 스타일 객체로 변환
|
||||
*/
|
||||
export function formatStyleToReact(
|
||||
style: CellFormatStyle
|
||||
): React.CSSProperties {
|
||||
const result: React.CSSProperties = {};
|
||||
|
||||
if (style.backgroundColor) {
|
||||
result.backgroundColor = style.backgroundColor;
|
||||
}
|
||||
if (style.textColor) {
|
||||
result.color = style.textColor;
|
||||
}
|
||||
if (style.fontWeight) {
|
||||
result.fontWeight = style.fontWeight as any;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default getConditionalStyle;
|
||||
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* Excel 내보내기 유틸리티
|
||||
* 피벗 테이블 데이터를 Excel 파일로 내보내기
|
||||
* xlsx 라이브러리 사용 (브라우저 호환)
|
||||
*/
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
PivotResult,
|
||||
PivotFieldConfig,
|
||||
PivotTotalsConfig,
|
||||
} from "../types";
|
||||
import { pathToKey } from "./pivotEngine";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
export interface ExportOptions {
|
||||
fileName?: string;
|
||||
sheetName?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
includeHeaders?: boolean;
|
||||
includeTotals?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 피벗 데이터를 Excel로 내보내기
|
||||
*/
|
||||
export async function exportPivotToExcel(
|
||||
pivotResult: PivotResult,
|
||||
fields: PivotFieldConfig[],
|
||||
totals: PivotTotalsConfig,
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
fileName = "pivot_export",
|
||||
sheetName = "Pivot",
|
||||
title,
|
||||
includeHeaders = true,
|
||||
includeTotals = true,
|
||||
} = options;
|
||||
|
||||
// 필드 분류
|
||||
const rowFields = fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
// 데이터 배열 생성
|
||||
const data: any[][] = [];
|
||||
|
||||
// 제목 추가
|
||||
if (title) {
|
||||
data.push([title]);
|
||||
data.push([]); // 빈 행
|
||||
}
|
||||
|
||||
// 헤더 행
|
||||
if (includeHeaders) {
|
||||
const headerRow: any[] = [
|
||||
rowFields.map((f) => f.caption).join(" / ") || "항목",
|
||||
];
|
||||
|
||||
// 열 헤더
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
headerRow.push(col.caption || "(전체)");
|
||||
}
|
||||
|
||||
// 총계 헤더
|
||||
if (totals?.showRowGrandTotals && includeTotals) {
|
||||
headerRow.push("총계");
|
||||
}
|
||||
|
||||
data.push(headerRow);
|
||||
}
|
||||
|
||||
// 데이터 행
|
||||
for (const row of pivotResult.flatRows) {
|
||||
const excelRow: any[] = [];
|
||||
|
||||
// 행 헤더 (들여쓰기 포함)
|
||||
const indent = " ".repeat(row.level);
|
||||
excelRow.push(indent + row.caption);
|
||||
|
||||
// 데이터 셀
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = pivotResult.dataMatrix.get(cellKey);
|
||||
|
||||
if (values && values.length > 0) {
|
||||
excelRow.push(values[0].value);
|
||||
} else {
|
||||
excelRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// 행 총계
|
||||
if (totals?.showRowGrandTotals && includeTotals) {
|
||||
const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path));
|
||||
if (rowTotal && rowTotal.length > 0) {
|
||||
excelRow.push(rowTotal[0].value);
|
||||
} else {
|
||||
excelRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
data.push(excelRow);
|
||||
}
|
||||
|
||||
// 열 총계 행
|
||||
if (totals?.showColumnGrandTotals && includeTotals) {
|
||||
const totalRow: any[] = ["총계"];
|
||||
|
||||
for (const col of pivotResult.flatColumns) {
|
||||
const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path));
|
||||
if (colTotal && colTotal.length > 0) {
|
||||
totalRow.push(colTotal[0].value);
|
||||
} else {
|
||||
totalRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// 대총합
|
||||
if (totals?.showRowGrandTotals) {
|
||||
const grandTotal = pivotResult.grandTotals.grand;
|
||||
if (grandTotal && grandTotal.length > 0) {
|
||||
totalRow.push(grandTotal[0].value);
|
||||
} else {
|
||||
totalRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
data.push(totalRow);
|
||||
}
|
||||
|
||||
// 워크시트 생성
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
||||
|
||||
// 컬럼 너비 설정
|
||||
const colWidths: XLSX.ColInfo[] = [];
|
||||
const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
for (let i = 0; i < maxCols; i++) {
|
||||
colWidths.push({ wch: i === 0 ? 25 : 15 });
|
||||
}
|
||||
worksheet["!cols"] = colWidths;
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill Down 데이터를 Excel로 내보내기
|
||||
*/
|
||||
export async function exportDrillDownToExcel(
|
||||
data: any[],
|
||||
columns: { field: string; caption: string }[],
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
fileName = "drilldown_export",
|
||||
sheetName = "Data",
|
||||
title,
|
||||
} = options;
|
||||
|
||||
// 데이터 배열 생성
|
||||
const sheetData: any[][] = [];
|
||||
|
||||
// 제목
|
||||
if (title) {
|
||||
sheetData.push([title]);
|
||||
sheetData.push([]); // 빈 행
|
||||
}
|
||||
|
||||
// 헤더
|
||||
const headerRow = columns.map((col) => col.caption);
|
||||
sheetData.push(headerRow);
|
||||
|
||||
// 데이터
|
||||
for (const row of data) {
|
||||
const dataRow = columns.map((col) => row[col.field] ?? "");
|
||||
sheetData.push(dataRow);
|
||||
}
|
||||
|
||||
// 워크시트 생성
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(sheetData);
|
||||
|
||||
// 컬럼 너비 설정
|
||||
const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 }));
|
||||
worksheet["!cols"] = colWidths;
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from "./aggregation";
|
||||
export * from "./pivotEngine";
|
||||
export * from "./exportExcel";
|
||||
export * from "./conditionalFormat";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,812 @@
|
|||
/**
|
||||
* PivotGrid 데이터 처리 엔진
|
||||
* 원시 데이터를 피벗 구조로 변환합니다.
|
||||
*/
|
||||
|
||||
import {
|
||||
PivotFieldConfig,
|
||||
PivotResult,
|
||||
PivotHeaderNode,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
PivotCellValue,
|
||||
DateGroupInterval,
|
||||
AggregationType,
|
||||
SummaryDisplayMode,
|
||||
} from "../types";
|
||||
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
||||
|
||||
// ==================== 헬퍼 함수 ====================
|
||||
|
||||
/**
|
||||
* 필드 값 추출 (날짜 그룹핑 포함)
|
||||
*/
|
||||
function getFieldValue(
|
||||
row: Record<string, any>,
|
||||
field: PivotFieldConfig
|
||||
): string {
|
||||
const rawValue = row[field.field];
|
||||
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return "(빈 값)";
|
||||
}
|
||||
|
||||
// 날짜 그룹핑 처리
|
||||
if (field.groupInterval && field.dataType === "date") {
|
||||
const date = new Date(rawValue);
|
||||
if (isNaN(date.getTime())) return String(rawValue);
|
||||
|
||||
switch (field.groupInterval) {
|
||||
case "year":
|
||||
return String(date.getFullYear());
|
||||
case "quarter":
|
||||
return `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
|
||||
case "month":
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
case "week":
|
||||
const weekNum = getWeekNumber(date);
|
||||
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
||||
case "day":
|
||||
return formatDate(date, "YYYY-MM-DD");
|
||||
default:
|
||||
return String(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
return String(rawValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주차 계산
|
||||
*/
|
||||
function getWeekNumber(date: Date): number {
|
||||
const d = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
);
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로를 키로 변환
|
||||
*/
|
||||
export function pathToKey(path: string[]): string {
|
||||
return path.join("||");
|
||||
}
|
||||
|
||||
/**
|
||||
* 키를 경로로 변환
|
||||
*/
|
||||
export function keyToPath(key: string): string[] {
|
||||
return key.split("||");
|
||||
}
|
||||
|
||||
// ==================== 헤더 생성 ====================
|
||||
|
||||
/**
|
||||
* 계층적 헤더 노드 생성
|
||||
*/
|
||||
function buildHeaderTree(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedPaths: Set<string>
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
// 첫 번째 필드로 그룹화
|
||||
const firstField = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, firstField);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
// 정렬
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (firstField.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
// 노드 생성
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: 0,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
// 자식 노드 생성 (확장된 경우만)
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
1
|
||||
);
|
||||
// span 계산
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 노드 재귀 생성
|
||||
*/
|
||||
function buildChildNodes(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
parentPath: string[],
|
||||
expandedPaths: Set<string>,
|
||||
level: number
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
const field = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, field);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (field.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [...parentPath, key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: level,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
level + 1
|
||||
);
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* span 계산 (colspan/rowspan)
|
||||
*/
|
||||
function calculateSpan(children?: PivotHeaderNode[]): number {
|
||||
if (!children || children.length === 0) return 1;
|
||||
return children.reduce((sum, child) => sum + child.span, 0);
|
||||
}
|
||||
|
||||
// ==================== 플랫 구조 변환 ====================
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 행으로 변환
|
||||
*/
|
||||
function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
|
||||
const result: PivotFlatRow[] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
result.push({
|
||||
path: node.path,
|
||||
level: node.level,
|
||||
caption: node.caption,
|
||||
isExpanded: node.isExpanded,
|
||||
hasChildren: !!(node.children && node.children.length > 0),
|
||||
});
|
||||
|
||||
if (node.isExpanded && node.children) {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 열로 변환 (각 레벨별)
|
||||
*/
|
||||
function flattenColumns(
|
||||
nodes: PivotHeaderNode[],
|
||||
maxLevel: number
|
||||
): PivotFlatColumn[][] {
|
||||
const levels: PivotFlatColumn[][] = Array.from(
|
||||
{ length: maxLevel + 1 },
|
||||
() => []
|
||||
);
|
||||
|
||||
function traverse(node: PivotHeaderNode, currentLevel: number) {
|
||||
levels[currentLevel].push({
|
||||
path: node.path,
|
||||
level: currentLevel,
|
||||
caption: node.caption,
|
||||
span: node.span,
|
||||
});
|
||||
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, currentLevel + 1);
|
||||
}
|
||||
} else if (currentLevel < maxLevel) {
|
||||
// 확장되지 않은 노드는 다음 레벨들에서 span으로 처리
|
||||
for (let i = currentLevel + 1; i <= maxLevel; i++) {
|
||||
levels[i].push({
|
||||
path: node.path,
|
||||
level: i,
|
||||
caption: "",
|
||||
span: node.span,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 헤더의 최대 깊이 계산
|
||||
*/
|
||||
function getMaxColumnLevel(
|
||||
nodes: PivotHeaderNode[],
|
||||
totalFields: number
|
||||
): number {
|
||||
let maxLevel = 0;
|
||||
|
||||
function traverse(node: PivotHeaderNode, level: number) {
|
||||
maxLevel = Math.max(maxLevel, level);
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return Math.min(maxLevel, totalFields - 1);
|
||||
}
|
||||
|
||||
// ==================== 데이터 매트릭스 생성 ====================
|
||||
|
||||
/**
|
||||
* 데이터 매트릭스 생성
|
||||
*/
|
||||
function buildDataMatrix(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): Map<string, PivotCellValue[]> {
|
||||
const matrix = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 각 셀에 대해 해당하는 데이터 집계
|
||||
for (const row of flatRows) {
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||
|
||||
// 해당 행/열 경로에 맞는 데이터 필터링
|
||||
const filteredData = data.filter((record) => {
|
||||
// 행 조건 확인
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
|
||||
// 열 조건 확인
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 데이터 필드별 집계
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(
|
||||
values,
|
||||
dataField.summaryType || "sum"
|
||||
);
|
||||
const formattedValue = formatNumber(
|
||||
aggregatedValue,
|
||||
dataField.format
|
||||
);
|
||||
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue,
|
||||
};
|
||||
});
|
||||
|
||||
matrix.set(cellKey, cellValues);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 leaf 노드 경로 추출
|
||||
*/
|
||||
function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
|
||||
const leaves: string[][] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
if (!node.isExpanded || !node.children || node.children.length === 0) {
|
||||
leaves.push(node.path);
|
||||
} else {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
// 열 필드가 없을 경우 빈 경로 추가
|
||||
if (leaves.length === 0) {
|
||||
leaves.push([]);
|
||||
}
|
||||
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// ==================== Summary Display Mode 적용 ====================
|
||||
|
||||
/**
|
||||
* Summary Display Mode에 따른 값 변환
|
||||
*/
|
||||
function applyDisplayMode(
|
||||
value: number,
|
||||
displayMode: SummaryDisplayMode | undefined,
|
||||
rowTotal: number,
|
||||
columnTotal: number,
|
||||
grandTotal: number,
|
||||
prevValue: number | null,
|
||||
runningTotal: number,
|
||||
format?: PivotFieldConfig["format"]
|
||||
): { value: number; formattedValue: string } {
|
||||
if (!displayMode || displayMode === "absoluteValue") {
|
||||
return {
|
||||
value,
|
||||
formattedValue: formatNumber(value, format),
|
||||
};
|
||||
}
|
||||
|
||||
let resultValue: number;
|
||||
let formatOverride: PivotFieldConfig["format"] | undefined;
|
||||
|
||||
switch (displayMode) {
|
||||
case "percentOfRowTotal":
|
||||
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfColumnTotal":
|
||||
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfGrandTotal":
|
||||
resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfRowGrandTotal":
|
||||
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "percentOfColumnGrandTotal":
|
||||
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
case "runningTotalByRow":
|
||||
case "runningTotalByColumn":
|
||||
resultValue = runningTotal;
|
||||
break;
|
||||
|
||||
case "differenceFromPrevious":
|
||||
resultValue = prevValue === null ? 0 : value - prevValue;
|
||||
break;
|
||||
|
||||
case "percentDifferenceFromPrevious":
|
||||
resultValue = prevValue === null || prevValue === 0
|
||||
? 0
|
||||
: ((value - prevValue) / Math.abs(prevValue)) * 100;
|
||||
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||
break;
|
||||
|
||||
default:
|
||||
resultValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
value: resultValue,
|
||||
formattedValue: formatNumber(resultValue, formatOverride || format),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 매트릭스에 Summary Display Mode 적용
|
||||
*/
|
||||
function applyDisplayModeToMatrix(
|
||||
matrix: Map<string, PivotCellValue[]>,
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][],
|
||||
rowTotals: Map<string, PivotCellValue[]>,
|
||||
columnTotals: Map<string, PivotCellValue[]>,
|
||||
grandTotals: PivotCellValue[]
|
||||
): Map<string, PivotCellValue[]> {
|
||||
// displayMode가 있는 데이터 필드가 있는지 확인
|
||||
const hasDisplayMode = dataFields.some(
|
||||
(df) => df.summaryDisplayMode || df.showValuesAs
|
||||
);
|
||||
if (!hasDisplayMode) return matrix;
|
||||
|
||||
const newMatrix = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 누계를 위한 추적 (행별, 열별)
|
||||
const rowRunningTotals: Map<string, number[]> = new Map(); // fieldIndex -> 누계
|
||||
const colRunningTotals: Map<string, Map<number, number>> = new Map(); // colKey -> fieldIndex -> 누계
|
||||
|
||||
// 행 순서대로 처리
|
||||
for (const row of flatRows) {
|
||||
// 이전 열 값 추적 (차이 계산용)
|
||||
const prevColValues: (number | null)[] = dataFields.map(() => null);
|
||||
|
||||
for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) {
|
||||
const colPath = flatColumnLeaves[colIdx];
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||
const values = matrix.get(cellKey);
|
||||
|
||||
if (!values) {
|
||||
newMatrix.set(cellKey, []);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowKey = pathToKey(row.path);
|
||||
const colKey = pathToKey(colPath);
|
||||
|
||||
// 총합 가져오기
|
||||
const rowTotal = rowTotals.get(rowKey);
|
||||
const colTotal = columnTotals.get(colKey);
|
||||
|
||||
const newValues: PivotCellValue[] = values.map((val, fieldIdx) => {
|
||||
const dataField = dataFields[fieldIdx];
|
||||
const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs;
|
||||
|
||||
if (!displayMode || displayMode === "absoluteValue") {
|
||||
prevColValues[fieldIdx] = val.value;
|
||||
return val;
|
||||
}
|
||||
|
||||
// 누계 계산
|
||||
// 행 방향 누계
|
||||
if (!rowRunningTotals.has(rowKey)) {
|
||||
rowRunningTotals.set(rowKey, dataFields.map(() => 0));
|
||||
}
|
||||
const rowRunning = rowRunningTotals.get(rowKey)!;
|
||||
rowRunning[fieldIdx] += val.value || 0;
|
||||
|
||||
// 열 방향 누계
|
||||
if (!colRunningTotals.has(colKey)) {
|
||||
colRunningTotals.set(colKey, new Map());
|
||||
}
|
||||
const colRunning = colRunningTotals.get(colKey)!;
|
||||
if (!colRunning.has(fieldIdx)) {
|
||||
colRunning.set(fieldIdx, 0);
|
||||
}
|
||||
colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0));
|
||||
|
||||
const result = applyDisplayMode(
|
||||
val.value || 0,
|
||||
displayMode,
|
||||
rowTotal?.[fieldIdx]?.value || 0,
|
||||
colTotal?.[fieldIdx]?.value || 0,
|
||||
grandTotals[fieldIdx]?.value || 0,
|
||||
prevColValues[fieldIdx],
|
||||
displayMode === "runningTotalByRow"
|
||||
? rowRunning[fieldIdx]
|
||||
: colRunning.get(fieldIdx) || 0,
|
||||
dataField.format
|
||||
);
|
||||
|
||||
prevColValues[fieldIdx] = val.value;
|
||||
|
||||
return {
|
||||
field: val.field,
|
||||
value: result.value,
|
||||
formattedValue: result.formattedValue,
|
||||
};
|
||||
});
|
||||
|
||||
newMatrix.set(cellKey, newValues);
|
||||
}
|
||||
}
|
||||
|
||||
return newMatrix;
|
||||
}
|
||||
|
||||
// ==================== 총합계 계산 ====================
|
||||
|
||||
/**
|
||||
* 총합계 계산
|
||||
*/
|
||||
function calculateGrandTotals(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): {
|
||||
row: Map<string, PivotCellValue[]>;
|
||||
column: Map<string, PivotCellValue[]>;
|
||||
grand: PivotCellValue[];
|
||||
} {
|
||||
const rowTotals = new Map<string, PivotCellValue[]>();
|
||||
const columnTotals = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 행별 총합 (각 행의 모든 열 합계)
|
||||
for (const row of flatRows) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
rowTotals.set(pathToKey(row.path), cellValues);
|
||||
}
|
||||
|
||||
// 열별 총합 (각 열의 모든 행 합계)
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
columnTotals.set(pathToKey(colPath), cellValues);
|
||||
}
|
||||
|
||||
// 대총합
|
||||
const grandValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = data.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
row: rowTotals,
|
||||
column: columnTotals,
|
||||
grand: grandValues,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 피벗 데이터 처리
|
||||
*/
|
||||
export function processPivotData(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedRowPaths: string[][] = [],
|
||||
expandedColumnPaths: string[][] = []
|
||||
): PivotResult {
|
||||
// 영역별 필드 분리
|
||||
const rowFields = fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const columnFields = fields
|
||||
.filter((f) => f.area === "column" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const dataFields = fields
|
||||
.filter((f) => f.area === "data" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const filterFields = fields.filter(
|
||||
(f) => f.area === "filter" && f.visible !== false
|
||||
);
|
||||
|
||||
// 필터 적용
|
||||
let filteredData = data;
|
||||
for (const filterField of filterFields) {
|
||||
if (filterField.filterValues && filterField.filterValues.length > 0) {
|
||||
filteredData = filteredData.filter((row) => {
|
||||
const value = getFieldValue(row, filterField);
|
||||
if (filterField.filterType === "exclude") {
|
||||
return !filterField.filterValues!.includes(value);
|
||||
}
|
||||
return filterField.filterValues!.includes(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 확장 경로 Set 변환
|
||||
const expandedRowSet = new Set(expandedRowPaths.map(pathToKey));
|
||||
const expandedColSet = new Set(expandedColumnPaths.map(pathToKey));
|
||||
|
||||
// 기본 확장: 첫 번째 레벨 모두 확장
|
||||
if (expandedRowPaths.length === 0 && rowFields.length > 0) {
|
||||
const firstField = rowFields[0];
|
||||
const uniqueValues = new Set(
|
||||
filteredData.map((row) => getFieldValue(row, firstField))
|
||||
);
|
||||
uniqueValues.forEach((val) => expandedRowSet.add(val));
|
||||
}
|
||||
|
||||
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
|
||||
const firstField = columnFields[0];
|
||||
const uniqueValues = new Set(
|
||||
filteredData.map((row) => getFieldValue(row, firstField))
|
||||
);
|
||||
uniqueValues.forEach((val) => expandedColSet.add(val));
|
||||
}
|
||||
|
||||
// 헤더 트리 생성
|
||||
const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet);
|
||||
const columnHeaders = buildHeaderTree(
|
||||
filteredData,
|
||||
columnFields,
|
||||
expandedColSet
|
||||
);
|
||||
|
||||
// 플랫 구조 변환
|
||||
const flatRows = flattenRows(rowHeaders);
|
||||
const flatColumnLeaves = getColumnLeaves(columnHeaders);
|
||||
const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length);
|
||||
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
||||
|
||||
// 데이터 매트릭스 생성
|
||||
let dataMatrix = buildDataMatrix(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// 총합계 계산
|
||||
const grandTotals = calculateGrandTotals(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// Summary Display Mode 적용
|
||||
dataMatrix = applyDisplayModeToMatrix(
|
||||
dataMatrix,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves,
|
||||
grandTotals.row,
|
||||
grandTotals.column,
|
||||
grandTotals.grand
|
||||
);
|
||||
|
||||
return {
|
||||
rowHeaders,
|
||||
columnHeaders,
|
||||
dataMatrix,
|
||||
flatRows,
|
||||
flatColumns: flatColumnLeaves.map((path, idx) => ({
|
||||
path,
|
||||
level: path.length - 1,
|
||||
caption: path[path.length - 1] || "",
|
||||
span: 1,
|
||||
})),
|
||||
grandTotals,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
# 렉 구조 설정 컴포넌트 (Rack Structure Config)
|
||||
|
||||
창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트입니다.
|
||||
|
||||
## 핵심 개념
|
||||
|
||||
이 컴포넌트는 **상위 폼의 필드 값을 읽어서** 위치 코드를 생성합니다.
|
||||
|
||||
### 작동 방식
|
||||
|
||||
1. 사용자가 화면관리에서 테이블 컬럼(창고코드, 층, 구역 등)을 드래그하여 폼에 배치
|
||||
2. 렉 구조 컴포넌트 설정에서 **필드 매핑** 설정 (어떤 폼 필드가 창고/층/구역인지)
|
||||
3. 런타임에 사용자가 폼 필드에 값을 입력하면, 렉 구조 컴포넌트가 해당 값을 읽어서 사용
|
||||
|
||||
## 기능
|
||||
|
||||
### 1. 렉 라인 구조 설정
|
||||
|
||||
- 조건 추가/삭제
|
||||
- 각 조건: 열 범위(시작~종료) + 단 수
|
||||
- 자동 위치 수 계산 (예: 1열~3열 x 3단 = 9개)
|
||||
- 템플릿 저장/불러오기
|
||||
|
||||
### 2. 등록 미리보기
|
||||
|
||||
- 통계 카드 (총 위치, 열 수, 최대 단)
|
||||
- 미리보기 생성 버튼
|
||||
- 생성될 위치 목록 테이블
|
||||
|
||||
## 설정 방법
|
||||
|
||||
### 1. 화면관리에서 배치
|
||||
|
||||
1. 상위에 테이블 컬럼들을 배치 (창고코드, 층, 구역, 위치유형, 사용여부)
|
||||
2. 컴포넌트 팔레트에서 "렉 구조 설정" 선택
|
||||
3. 캔버스에 드래그하여 배치
|
||||
|
||||
### 2. 필드 매핑 설정
|
||||
|
||||
설정 패널에서 상위 폼의 어떤 필드를 사용할지 매핑합니다:
|
||||
|
||||
| 매핑 항목 | 설명 |
|
||||
| -------------- | ------------------------------------- |
|
||||
| 창고 코드 필드 | 위치 코드 생성에 사용할 창고 코드 |
|
||||
| 층 필드 | 위치 코드 생성에 사용할 층 |
|
||||
| 구역 필드 | 위치 코드 생성에 사용할 구역 |
|
||||
| 위치 유형 필드 | 미리보기 테이블에 표시할 위치 유형 |
|
||||
| 사용 여부 필드 | 미리보기 테이블에 표시할 사용 여부 |
|
||||
|
||||
### 예시
|
||||
|
||||
상위 폼에 다음 필드가 배치되어 있다면:
|
||||
- `창고코드(조인)` → 필드명: `warehouse_code`
|
||||
- `층` → 필드명: `floor`
|
||||
- `구역` → 필드명: `zone`
|
||||
|
||||
설정 패널에서:
|
||||
- 창고 코드 필드: `warehouse_code` 선택
|
||||
- 층 필드: `floor` 선택
|
||||
- 구역 필드: `zone` 선택
|
||||
|
||||
## 위치 코드 생성 규칙
|
||||
|
||||
기본 패턴: `{창고코드}-{층}{구역}-{열:2자리}-{단}`
|
||||
|
||||
예시 (창고: WH001, 층: 1, 구역: A):
|
||||
|
||||
- WH001-1A-01-1 (01열, 1단)
|
||||
- WH001-1A-01-2 (01열, 2단)
|
||||
- WH001-1A-02-1 (02열, 1단)
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
| -------------- | ------- | ------ | ---------------- |
|
||||
| maxConditions | number | 10 | 최대 조건 수 |
|
||||
| maxRows | number | 99 | 최대 열 수 |
|
||||
| maxLevels | number | 20 | 최대 단 수 |
|
||||
| showTemplates | boolean | true | 템플릿 기능 표시 |
|
||||
| showPreview | boolean | true | 미리보기 표시 |
|
||||
| showStatistics | boolean | true | 통계 카드 표시 |
|
||||
| readonly | boolean | false | 읽기 전용 |
|
||||
|
||||
## 출력 데이터
|
||||
|
||||
`onChange` 콜백으로 생성된 위치 데이터 배열을 반환합니다:
|
||||
|
||||
```typescript
|
||||
interface GeneratedLocation {
|
||||
rowNum: number; // 열 번호
|
||||
levelNum: number; // 단 번호
|
||||
locationCode: string; // 위치 코드
|
||||
locationName: string; // 위치명
|
||||
locationType?: string; // 위치 유형
|
||||
status?: string; // 사용 여부
|
||||
warehouseCode?: string; // 창고 코드 (매핑된 값)
|
||||
floor?: string; // 층 (매핑된 값)
|
||||
zone?: string; // 구역 (매핑된 값)
|
||||
}
|
||||
```
|
||||
|
||||
## 연동 테이블
|
||||
|
||||
`warehouse_location` 테이블과 연동됩니다:
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
| ------------- | --------- |
|
||||
| warehouse_id | 창고 ID |
|
||||
| floor | 층 |
|
||||
| zone | 구역 |
|
||||
| row_num | 열 번호 |
|
||||
| level_num | 단 번호 |
|
||||
| location_code | 위치 코드 |
|
||||
| location_name | 위치명 |
|
||||
| location_type | 위치 유형 |
|
||||
| status | 사용 여부 |
|
||||
|
||||
## 예시 시나리오
|
||||
|
||||
### 시나리오: A구역에 1~3열은 3단, 4~6열은 5단 렉 생성
|
||||
|
||||
1. **상위 폼에서 기본 정보 입력**
|
||||
- 창고: 제1창고 (WH001) - 드래그해서 배치한 필드
|
||||
- 층: 1 - 드래그해서 배치한 필드
|
||||
- 구역: A - 드래그해서 배치한 필드
|
||||
- 위치 유형: 선반 - 드래그해서 배치한 필드
|
||||
- 사용 여부: 사용 - 드래그해서 배치한 필드
|
||||
|
||||
2. **렉 구조 컴포넌트에서 조건 추가**
|
||||
- 조건 1: 1~3열, 3단 → 9개
|
||||
- 조건 2: 4~6열, 5단 → 15개
|
||||
|
||||
3. **미리보기 생성**
|
||||
- 총 위치: 24개
|
||||
- 열 수: 6개
|
||||
- 최대 단: 5단
|
||||
|
||||
4. **저장**
|
||||
- 24개의 위치 데이터가 warehouse_location 테이블에 저장됨
|
||||
|
||||
## 필수 필드 검증
|
||||
|
||||
미리보기 생성 시 다음 필드가 입력되어 있어야 합니다:
|
||||
- 창고 코드
|
||||
- 층
|
||||
- 구역
|
||||
|
||||
필드가 비어있으면 경고 메시지가 표시됩니다.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||
|
||||
interface RackStructureConfigPanelProps {
|
||||
config: RackStructureComponentConfig;
|
||||
onChange: (config: RackStructureComponentConfig) => void;
|
||||
// 화면관리에서 전달하는 테이블 컬럼 정보
|
||||
tables?: Array<{
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
columns: Array<{
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
tables = [],
|
||||
}) => {
|
||||
// 사용 가능한 컬럼 목록 추출
|
||||
const [availableColumns, setAvailableColumns] = useState<
|
||||
Array<{ value: string; label: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// 모든 테이블의 컬럼을 플랫하게 추출
|
||||
const columns: Array<{ value: string; label: string }> = [];
|
||||
tables.forEach((table) => {
|
||||
table.columns.forEach((col) => {
|
||||
columns.push({
|
||||
value: col.columnName,
|
||||
label: col.columnLabel || col.columnName,
|
||||
});
|
||||
});
|
||||
});
|
||||
setAvailableColumns(columns);
|
||||
}, [tables]);
|
||||
|
||||
const handleChange = (key: keyof RackStructureComponentConfig, value: any) => {
|
||||
onChange({ ...config, [key]: value });
|
||||
};
|
||||
|
||||
const handleFieldMappingChange = (field: keyof FieldMapping, value: string) => {
|
||||
const currentMapping = config.fieldMapping || {};
|
||||
onChange({
|
||||
...config,
|
||||
fieldMapping: {
|
||||
...currentMapping,
|
||||
[field]: value === "__none__" ? undefined : value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const fieldMapping = config.fieldMapping || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필드 매핑 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">필드 매핑</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요
|
||||
</p>
|
||||
|
||||
{/* 창고 코드 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">창고 코드 필드</Label>
|
||||
<Select
|
||||
value={fieldMapping.warehouseCodeField || "__none__"}
|
||||
onValueChange={(v) => handleFieldMappingChange("warehouseCodeField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.value} value={col.value}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 창고명 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">창고명 필드</Label>
|
||||
<Select
|
||||
value={fieldMapping.warehouseNameField || "__none__"}
|
||||
onValueChange={(v) => handleFieldMappingChange("warehouseNameField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.value} value={col.value}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 층 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">층 필드</Label>
|
||||
<Select
|
||||
value={fieldMapping.floorField || "__none__"}
|
||||
onValueChange={(v) => handleFieldMappingChange("floorField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.value} value={col.value}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 구역 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">구역 필드</Label>
|
||||
<Select
|
||||
value={fieldMapping.zoneField || "__none__"}
|
||||
onValueChange={(v) => handleFieldMappingChange("zoneField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.value} value={col.value}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 위치 유형 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치 유형 필드</Label>
|
||||
<Select
|
||||
value={fieldMapping.locationTypeField || "__none__"}
|
||||
onValueChange={(v) => handleFieldMappingChange("locationTypeField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.value} value={col.value}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 사용 여부 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">사용 여부 필드</Label>
|
||||
<Select
|
||||
value={fieldMapping.statusField || "__none__"}
|
||||
onValueChange={(v) => handleFieldMappingChange("statusField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.value} value={col.value}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제한 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">제한 설정</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">최대 조건 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={config.maxConditions || 10}
|
||||
onChange={(e) => handleChange("maxConditions", parseInt(e.target.value) || 10)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">최대 열 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={config.maxRows || 99}
|
||||
onChange={(e) => handleChange("maxRows", parseInt(e.target.value) || 99)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">최대 단 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
value={config.maxLevels || 20}
|
||||
onChange={(e) => handleChange("maxLevels", parseInt(e.target.value) || 20)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">UI 설정</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">템플릿 기능</Label>
|
||||
<Switch
|
||||
checked={config.showTemplates ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showTemplates", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">미리보기 표시</Label>
|
||||
<Switch
|
||||
checked={config.showPreview ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showPreview", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">통계 카드 표시</Label>
|
||||
<Switch
|
||||
checked={config.showStatistics ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showStatistics", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">읽기 전용</Label>
|
||||
<Switch
|
||||
checked={config.readonly ?? false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2RackStructureDefinition } from "./index";
|
||||
import { RackStructureComponent } from "./RackStructureComponent";
|
||||
import { GeneratedLocation } from "./types";
|
||||
|
||||
/**
|
||||
* 렉 구조 설정 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class RackStructureRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2RackStructureDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { formData, isPreview, config, tableName, onFormDataChange } = this.props as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<RackStructureComponent
|
||||
config={(config as object) || {}}
|
||||
formData={formData as Record<string, unknown>}
|
||||
tableName={tableName as string}
|
||||
onChange={(locations) =>
|
||||
this.handleLocationsChange(
|
||||
locations,
|
||||
onFormDataChange as ((fieldName: string, value: unknown) => void) | undefined,
|
||||
)
|
||||
}
|
||||
isPreview={isPreview as boolean}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성된 위치 데이터 변경 핸들러
|
||||
* formData에 _rackStructureLocations 키로 저장하여 저장 액션에서 감지
|
||||
*/
|
||||
protected handleLocationsChange = (
|
||||
locations: GeneratedLocation[],
|
||||
onFormDataChange?: (fieldName: string, value: unknown) => void,
|
||||
) => {
|
||||
// 생성된 위치 데이터를 컴포넌트에 저장
|
||||
this.updateComponent({ generatedLocations: locations });
|
||||
|
||||
// formData에도 저장하여 저장 액션에서 감지할 수 있도록 함
|
||||
if (onFormDataChange) {
|
||||
console.log("📦 [RackStructure] 미리보기 데이터를 formData에 저장:", locations.length, "개");
|
||||
onFormDataChange("_rackStructureLocations", locations);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
RackStructureRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
RackStructureRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* 렉 구조 컴포넌트 기본 설정
|
||||
*/
|
||||
|
||||
import { RackStructureComponentConfig } from "./types";
|
||||
|
||||
export const defaultConfig: RackStructureComponentConfig = {
|
||||
// 기본 제한
|
||||
maxConditions: 10,
|
||||
maxRows: 99,
|
||||
maxLevels: 20,
|
||||
|
||||
// 기본 코드 패턴
|
||||
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
|
||||
namePattern: "{zone}구역-{row:02d}열-{level}단",
|
||||
|
||||
// UI 설정
|
||||
showTemplates: true,
|
||||
showPreview: true,
|
||||
showStatistics: true,
|
||||
readonly: false,
|
||||
|
||||
// 초기 조건 없음
|
||||
initialConditions: [],
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { RackStructureWrapper } from "./RackStructureComponent";
|
||||
import { RackStructureConfigPanel } from "./RackStructureConfigPanel";
|
||||
import { defaultConfig } from "./config";
|
||||
|
||||
/**
|
||||
* 렉 구조 컴포넌트 정의
|
||||
* 창고 렉 위치를 일괄 생성하기 위한 구조 설정 컴포넌트
|
||||
*/
|
||||
export const V2RackStructureDefinition = createComponentDefinition({
|
||||
id: "v2-rack-structure",
|
||||
name: "렉 구조 설정",
|
||||
nameEng: "Rack Structure Config",
|
||||
description: "창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "component",
|
||||
component: RackStructureWrapper,
|
||||
defaultConfig: defaultConfig,
|
||||
defaultSize: {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
gridColumnSpan: "12",
|
||||
},
|
||||
configPanel: RackStructureConfigPanel,
|
||||
icon: "LayoutGrid",
|
||||
tags: ["창고", "렉", "위치", "구조", "일괄생성", "WMS"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: `
|
||||
창고 렉 위치를 일괄 생성하기 위한 구조 설정 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
- 조건별 열 범위 및 단 수 설정
|
||||
- 자동 위치 코드/이름 생성
|
||||
- 미리보기 및 통계 표시
|
||||
- 템플릿 저장/불러오기
|
||||
|
||||
## 사용 방법
|
||||
1. 상위 폼에서 창고, 층, 구역 정보 선택
|
||||
2. 조건 추가 버튼으로 렉 라인 조건 생성
|
||||
3. 각 조건의 열 범위와 단 수 입력
|
||||
4. 미리보기 생성으로 결과 확인
|
||||
5. 저장 시 생성된 위치 데이터가 함께 저장됨
|
||||
|
||||
## 컨텍스트 데이터
|
||||
formData에서 다음 필드를 자동으로 읽어옵니다:
|
||||
- warehouse_id / warehouseId: 창고 ID
|
||||
- warehouse_code / warehouseCode: 창고 코드
|
||||
- floor: 층
|
||||
- zone: 구역
|
||||
- location_type / locationType: 위치 유형
|
||||
- status: 사용 여부
|
||||
`,
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
RackStructureComponentConfig,
|
||||
RackStructureContext,
|
||||
RackLineCondition,
|
||||
RackStructureTemplate,
|
||||
GeneratedLocation,
|
||||
} from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { RackStructureComponent, RackStructureWrapper } from "./RackStructureComponent";
|
||||
export { RackStructureRenderer } from "./RackStructureRenderer";
|
||||
export { RackStructureConfigPanel } from "./RackStructureConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 렉 구조 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
// 렉 라인 조건 (열 범위 + 단 수)
|
||||
export interface RackLineCondition {
|
||||
id: string;
|
||||
startRow: number; // 시작 열
|
||||
endRow: number; // 종료 열
|
||||
levels: number; // 단 수
|
||||
}
|
||||
|
||||
// 렉 구조 템플릿
|
||||
export interface RackStructureTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
conditions: RackLineCondition[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// 생성될 위치 데이터 (테이블 컬럼명과 동일하게 매핑)
|
||||
export interface GeneratedLocation {
|
||||
row_num: string; // 열 번호 (varchar)
|
||||
level_num: string; // 단 번호 (varchar)
|
||||
location_code: string; // 위치 코드 (예: WH001-1A-01-1)
|
||||
location_name: string; // 위치명 (예: A구역-01열-1단)
|
||||
location_type?: string; // 위치 유형
|
||||
status?: string; // 사용 여부
|
||||
// 추가 필드 (상위 폼에서 매핑된 값)
|
||||
warehouse_code?: string; // 창고 코드 (DB 컬럼명과 동일)
|
||||
warehouse_name?: string; // 창고명
|
||||
floor?: string; // 층
|
||||
zone?: string; // 구역
|
||||
}
|
||||
|
||||
// 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지)
|
||||
export interface FieldMapping {
|
||||
warehouseCodeField?: string; // 창고 코드로 사용할 폼 필드명
|
||||
warehouseNameField?: string; // 창고명으로 사용할 폼 필드명
|
||||
floorField?: string; // 층으로 사용할 폼 필드명
|
||||
zoneField?: string; // 구역으로 사용할 폼 필드명
|
||||
locationTypeField?: string; // 위치 유형으로 사용할 폼 필드명
|
||||
statusField?: string; // 사용 여부로 사용할 폼 필드명
|
||||
}
|
||||
|
||||
// 컴포넌트 설정
|
||||
export interface RackStructureComponentConfig {
|
||||
// 기본 설정
|
||||
maxConditions?: number; // 최대 조건 수 (기본: 10)
|
||||
maxRows?: number; // 최대 열 수 (기본: 99)
|
||||
maxLevels?: number; // 최대 단 수 (기본: 20)
|
||||
|
||||
// 필드 매핑 (상위 폼의 필드와 연결)
|
||||
fieldMapping?: FieldMapping;
|
||||
|
||||
// 위치 코드 생성 규칙
|
||||
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
|
||||
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
|
||||
|
||||
// UI 설정
|
||||
showTemplates?: boolean; // 템플릿 기능 표시
|
||||
showPreview?: boolean; // 미리보기 표시
|
||||
showStatistics?: boolean; // 통계 카드 표시
|
||||
readonly?: boolean; // 읽기 전용
|
||||
|
||||
// 초기값
|
||||
initialConditions?: RackLineCondition[];
|
||||
}
|
||||
|
||||
// 상위 폼에서 전달받는 컨텍스트 데이터
|
||||
export interface RackStructureContext {
|
||||
warehouseId?: string; // 창고 ID
|
||||
warehouseCode?: string; // 창고 코드 (예: WH001)
|
||||
warehouseName?: string; // 창고명 (예: 제1창고)
|
||||
floor?: string; // 층 라벨 (예: 1층) - 화면 표시용
|
||||
zone?: string; // 구역 라벨 (예: A구역) - 화면 표시용
|
||||
locationType?: string; // 위치 유형 라벨 (예: 선반)
|
||||
status?: string; // 사용 여부 라벨 (예: 사용)
|
||||
// 카테고리 코드 (DB 저장/쿼리용)
|
||||
floorCode?: string; // 층 카테고리 코드 (예: CATEGORY_767659DCUF)
|
||||
zoneCode?: string; // 구역 카테고리 코드 (예: CATEGORY_82925656Q8)
|
||||
locationTypeCode?: string; // 위치 유형 카테고리 코드
|
||||
statusCode?: string; // 사용 여부 카테고리 코드
|
||||
}
|
||||
|
||||
// 컴포넌트 Props
|
||||
export interface RackStructureComponentProps {
|
||||
config: RackStructureComponentConfig;
|
||||
context?: RackStructureContext;
|
||||
formData?: Record<string, any>; // 상위 폼 데이터 (필드 매핑에 사용)
|
||||
onChange?: (locations: GeneratedLocation[]) => void;
|
||||
onConditionsChange?: (conditions: RackLineCondition[]) => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,709 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { RepeatContainerConfig, RepeatItemContext, SlotComponentConfig } from "./types";
|
||||
import { Repeat, Package, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DynamicComponentRenderer from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
interface RepeatContainerComponentProps extends ComponentRendererProps {
|
||||
config?: RepeatContainerConfig;
|
||||
// 외부에서 데이터를 직접 전달받을 수 있음
|
||||
externalData?: any[];
|
||||
// 내부 컴포넌트를 렌더링하는 슬롯 (children 대용)
|
||||
renderItem?: (context: RepeatItemContext) => React.ReactNode;
|
||||
// formData 접근
|
||||
formData?: Record<string, any>;
|
||||
// formData 변경 콜백
|
||||
onFormDataChange?: (key: string, value: any) => void;
|
||||
// 선택 변경 콜백
|
||||
onSelectionChange?: (selectedData: any[]) => void;
|
||||
// 사용자 정보
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
companyCode?: string;
|
||||
// 화면 정보
|
||||
screenId?: number;
|
||||
screenTableName?: string;
|
||||
// 컴포넌트 업데이트 콜백 (디자인 모드에서 드래그앤드롭용)
|
||||
onUpdateComponent?: (updates: Partial<RepeatContainerConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 컴포넌트
|
||||
* 데이터 수만큼 내부 컨텐츠를 반복 렌더링하는 컨테이너
|
||||
*/
|
||||
export function RepeatContainerComponent({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
config: propsConfig,
|
||||
externalData,
|
||||
renderItem,
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
onSelectionChange,
|
||||
userId,
|
||||
userName,
|
||||
companyCode,
|
||||
screenId,
|
||||
screenTableName,
|
||||
onUpdateComponent,
|
||||
}: RepeatContainerComponentProps) {
|
||||
const componentConfig: RepeatContainerConfig = {
|
||||
dataSourceType: "manual",
|
||||
layout: "vertical",
|
||||
gridColumns: 2,
|
||||
gap: "16px",
|
||||
showBorder: true,
|
||||
showShadow: false,
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "16px",
|
||||
showItemTitle: false,
|
||||
itemTitleTemplate: "",
|
||||
titleColumn: "",
|
||||
descriptionColumn: "",
|
||||
titleFontSize: "14px",
|
||||
titleColor: "#374151",
|
||||
titleFontWeight: "600",
|
||||
descriptionFontSize: "12px",
|
||||
descriptionColor: "#6b7280",
|
||||
emptyMessage: "데이터가 없습니다",
|
||||
usePaging: false,
|
||||
pageSize: 10,
|
||||
clickable: false,
|
||||
showSelectedState: true,
|
||||
selectionMode: "single",
|
||||
...propsConfig,
|
||||
...component?.config,
|
||||
...component?.componentConfig,
|
||||
};
|
||||
|
||||
const {
|
||||
dataSourceType,
|
||||
dataSourceComponentId,
|
||||
tableName,
|
||||
customTableName,
|
||||
useCustomTable,
|
||||
layout,
|
||||
gridColumns,
|
||||
gap,
|
||||
itemMinWidth,
|
||||
itemMaxWidth,
|
||||
itemHeight,
|
||||
showBorder,
|
||||
showShadow,
|
||||
borderRadius,
|
||||
backgroundColor,
|
||||
padding,
|
||||
showItemTitle,
|
||||
itemTitleTemplate,
|
||||
titleColumn,
|
||||
descriptionColumn,
|
||||
titleFontSize,
|
||||
titleColor,
|
||||
titleFontWeight,
|
||||
descriptionFontSize,
|
||||
descriptionColor,
|
||||
filterField,
|
||||
filterColumn,
|
||||
useGrouping,
|
||||
groupByField,
|
||||
children: slotChildren,
|
||||
emptyMessage,
|
||||
usePaging,
|
||||
pageSize,
|
||||
clickable,
|
||||
showSelectedState,
|
||||
selectionMode,
|
||||
} = componentConfig;
|
||||
|
||||
// 데이터 상태
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 실제 사용할 테이블명
|
||||
const effectiveTableName = useCustomTable ? customTableName : tableName;
|
||||
|
||||
// 외부 데이터가 있으면 사용
|
||||
useEffect(() => {
|
||||
if (externalData && Array.isArray(externalData)) {
|
||||
setData(externalData);
|
||||
}
|
||||
}, [externalData]);
|
||||
|
||||
// 컴포넌트 데이터 변경 이벤트 리스닝 (componentId 또는 tableName으로 매칭)
|
||||
useEffect(() => {
|
||||
if (isDesignMode) return;
|
||||
|
||||
console.log("🔄 리피터 컨테이너 이벤트 리스너 등록:", {
|
||||
componentId: component?.id,
|
||||
dataSourceType,
|
||||
dataSourceComponentId,
|
||||
effectiveTableName,
|
||||
});
|
||||
|
||||
// dataSourceComponentId가 없어도 테이블명으로 매칭 가능
|
||||
const handleDataChange = (event: CustomEvent) => {
|
||||
const { componentId, tableName: eventTableName, data: eventData } = event.detail || {};
|
||||
|
||||
console.log("📩 리피터 컨테이너 이벤트 수신:", {
|
||||
eventType: event.type,
|
||||
fromComponentId: componentId,
|
||||
fromTableName: eventTableName,
|
||||
dataCount: Array.isArray(eventData) ? eventData.length : 0,
|
||||
myDataSourceComponentId: dataSourceComponentId,
|
||||
myEffectiveTableName: effectiveTableName,
|
||||
});
|
||||
|
||||
// 1. 명시적으로 dataSourceComponentId가 설정된 경우 해당 컴포넌트만 매칭
|
||||
if (dataSourceComponentId) {
|
||||
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
|
||||
console.log("✅ 리피터: 컴포넌트 ID로 데이터 수신 성공", { componentId, count: eventData.length });
|
||||
setData(eventData);
|
||||
setCurrentPage(1);
|
||||
setSelectedIndices([]);
|
||||
} else {
|
||||
console.log("⚠️ 리피터: 컴포넌트 ID 불일치로 무시", { expected: dataSourceComponentId, received: componentId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. dataSourceComponentId가 없으면 테이블명으로 매칭
|
||||
if (effectiveTableName && eventTableName === effectiveTableName && Array.isArray(eventData)) {
|
||||
console.log("✅ 리피터: 테이블명으로 데이터 수신 성공", { tableName: eventTableName, count: eventData.length });
|
||||
setData(eventData);
|
||||
setCurrentPage(1);
|
||||
setSelectedIndices([]);
|
||||
} else if (effectiveTableName) {
|
||||
console.log("⚠️ 리피터: 테이블명 불일치로 무시", { expected: effectiveTableName, received: eventTableName });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
window.addEventListener("tableListDataChange" as any, handleDataChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
window.removeEventListener("tableListDataChange" as any, handleDataChange);
|
||||
};
|
||||
}, [component?.id, dataSourceType, dataSourceComponentId, effectiveTableName, isDesignMode]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = useMemo(() => {
|
||||
if (!filterField || !filterColumn) return data;
|
||||
|
||||
const filterValue = formData[filterField];
|
||||
if (filterValue === undefined || filterValue === null) return data;
|
||||
|
||||
if (Array.isArray(filterValue)) {
|
||||
return data.filter((row) => filterValue.includes(row[filterColumn]));
|
||||
}
|
||||
|
||||
return data.filter((row) => row[filterColumn] === filterValue);
|
||||
}, [data, filterField, filterColumn, formData]);
|
||||
|
||||
// 그룹핑된 데이터
|
||||
const groupedData = useMemo(() => {
|
||||
if (!useGrouping || !groupByField) return null;
|
||||
|
||||
const groups: Record<string, any[]> = {};
|
||||
filteredData.forEach((row) => {
|
||||
const key = String(row[groupByField] ?? "기타");
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(row);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredData, useGrouping, groupByField]);
|
||||
|
||||
// 페이징된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!usePaging || !pageSize) return filteredData;
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredData, usePaging, pageSize, currentPage]);
|
||||
|
||||
// 총 페이지 수
|
||||
const totalPages = useMemo(() => {
|
||||
if (!usePaging || !pageSize || filteredData.length === 0) return 1;
|
||||
return Math.ceil(filteredData.length / pageSize);
|
||||
}, [filteredData.length, usePaging, pageSize]);
|
||||
|
||||
// 아이템 제목 생성 (titleColumn 우선, 없으면 itemTitleTemplate 사용)
|
||||
const generateTitle = useCallback(
|
||||
(rowData: Record<string, any>, index: number): string => {
|
||||
if (!showItemTitle) return "";
|
||||
|
||||
// titleColumn이 설정된 경우 해당 컬럼 값 사용
|
||||
if (titleColumn) {
|
||||
return String(rowData[titleColumn] ?? "");
|
||||
}
|
||||
|
||||
// 레거시: itemTitleTemplate 사용
|
||||
if (itemTitleTemplate) {
|
||||
return itemTitleTemplate.replace(/\{([^}]+)\}/g, (match, field) => {
|
||||
return String(rowData[field] ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
return `아이템 ${index + 1}`;
|
||||
},
|
||||
[showItemTitle, titleColumn, itemTitleTemplate]
|
||||
);
|
||||
|
||||
// 아이템 설명 생성
|
||||
const generateDescription = useCallback(
|
||||
(rowData: Record<string, any>): string => {
|
||||
if (!showItemTitle || !descriptionColumn) return "";
|
||||
return String(rowData[descriptionColumn] ?? "");
|
||||
},
|
||||
[showItemTitle, descriptionColumn]
|
||||
);
|
||||
|
||||
// 아이템 클릭 핸들러
|
||||
const handleItemClick = useCallback(
|
||||
(index: number, rowData: any) => {
|
||||
if (!clickable) return;
|
||||
|
||||
let newSelectedIndices: number[];
|
||||
|
||||
if (selectionMode === "multiple") {
|
||||
if (selectedIndices.includes(index)) {
|
||||
newSelectedIndices = selectedIndices.filter((i) => i !== index);
|
||||
} else {
|
||||
newSelectedIndices = [...selectedIndices, index];
|
||||
}
|
||||
} else {
|
||||
newSelectedIndices = selectedIndices.includes(index) ? [] : [index];
|
||||
}
|
||||
|
||||
setSelectedIndices(newSelectedIndices);
|
||||
|
||||
if (onSelectionChange) {
|
||||
const selectedData = newSelectedIndices.map((i) => paginatedData[i]);
|
||||
onSelectionChange(selectedData);
|
||||
}
|
||||
},
|
||||
[clickable, selectionMode, selectedIndices, paginatedData, onSelectionChange]
|
||||
);
|
||||
|
||||
// 레이아웃 스타일 계산
|
||||
const layoutStyle = useMemo(() => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
gap: gap || "16px",
|
||||
};
|
||||
|
||||
switch (layout) {
|
||||
case "horizontal":
|
||||
return {
|
||||
...baseStyle,
|
||||
display: "flex",
|
||||
flexDirection: "row" as const,
|
||||
flexWrap: "wrap" as const,
|
||||
};
|
||||
case "grid":
|
||||
return {
|
||||
...baseStyle,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${gridColumns || 2}, 1fr)`,
|
||||
};
|
||||
case "vertical":
|
||||
default:
|
||||
return {
|
||||
...baseStyle,
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
};
|
||||
}
|
||||
}, [layout, gap, gridColumns]);
|
||||
|
||||
// 아이템 스타일 계산
|
||||
const itemStyle = useMemo((): React.CSSProperties => {
|
||||
return {
|
||||
minWidth: itemMinWidth,
|
||||
maxWidth: itemMaxWidth,
|
||||
// height 대신 minHeight 사용 - 내부 컨텐츠가 커지면 자동으로 높이 확장
|
||||
minHeight: itemHeight || "auto",
|
||||
height: "auto", // 고정 높이 대신 auto로 변경
|
||||
backgroundColor: backgroundColor || "#ffffff",
|
||||
borderRadius: borderRadius || "8px",
|
||||
padding: padding || "16px",
|
||||
border: showBorder ? "1px solid #e5e7eb" : "none",
|
||||
boxShadow: showShadow ? "0 1px 3px rgba(0,0,0,0.1)" : "none",
|
||||
overflow: "visible", // 내부 컨텐츠가 튀어나가지 않도록
|
||||
};
|
||||
}, [itemMinWidth, itemMaxWidth, itemHeight, backgroundColor, borderRadius, padding, showBorder, showShadow]);
|
||||
|
||||
// 슬롯 자식 컴포넌트들을 렌더링
|
||||
const renderSlotChildren = useCallback(
|
||||
(context: RepeatItemContext) => {
|
||||
// renderItem prop이 있으면 우선 사용
|
||||
if (renderItem) {
|
||||
return renderItem(context);
|
||||
}
|
||||
|
||||
// 슬롯에 배치된 자식 컴포넌트가 없으면 기본 메시지
|
||||
if (!slotChildren || slotChildren.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
반복 아이템 #{context.index + 1}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 아이템 데이터를 formData로 전달
|
||||
const itemFormData = {
|
||||
...formData,
|
||||
...context.data,
|
||||
_repeatIndex: context.index,
|
||||
_repeatTotal: context.totalCount,
|
||||
_isFirst: context.isFirst,
|
||||
_isLast: context.isLast,
|
||||
};
|
||||
|
||||
// 슬롯에 배치된 컴포넌트들을 렌더링 (Flow 레이아웃으로 변경)
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{slotChildren.map((childComp: SlotComponentConfig) => {
|
||||
const { size = { width: "100%", height: "auto" } } = childComp;
|
||||
|
||||
// DynamicComponentRenderer가 기대하는 형식으로 변환
|
||||
const componentData = {
|
||||
id: `${childComp.id}_${context.index}`,
|
||||
componentType: childComp.componentType,
|
||||
label: childComp.label,
|
||||
columnName: childComp.fieldName,
|
||||
position: { x: 0, y: 0, z: 1 },
|
||||
size: {
|
||||
width: typeof size.width === "number" ? size.width : undefined,
|
||||
height: typeof size.height === "number" ? size.height : undefined,
|
||||
},
|
||||
componentConfig: childComp.componentConfig,
|
||||
style: childComp.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={componentData.id}
|
||||
className="w-full"
|
||||
style={{
|
||||
// 너비는 100%로, 높이는 자동으로
|
||||
minHeight: typeof size.height === "number" ? size.height : "auto",
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={componentData}
|
||||
isInteractive={true}
|
||||
screenId={screenId}
|
||||
tableName={screenTableName || effectiveTableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
formData={itemFormData}
|
||||
onFormDataChange={(key, value) => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(`_repeat_${context.index}_${key}`, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
renderItem,
|
||||
slotChildren,
|
||||
formData,
|
||||
screenId,
|
||||
screenTableName,
|
||||
effectiveTableName,
|
||||
userId,
|
||||
userName,
|
||||
companyCode,
|
||||
onFormDataChange,
|
||||
]
|
||||
);
|
||||
|
||||
// 드래그앤드롭 상태 (시각적 피드백용)
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
// 드래그 오버 핸들러 (시각적 피드백만)
|
||||
// 중요: preventDefault()를 호출해야 드롭 가능 영역으로 인식됨
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
// 드래그 리브 핸들러
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
// 자식 요소로 이동할 때 false가 되지 않도록 체크
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && (e.currentTarget as HTMLElement).contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
// 디자인 모드 미리보기
|
||||
if (isDesignMode) {
|
||||
const previewData = [
|
||||
{ id: 1, name: "아이템 1", value: 100 },
|
||||
{ id: 2, name: "아이템 2", value: 200 },
|
||||
{ id: 3, name: "아이템 3", value: 300 },
|
||||
];
|
||||
|
||||
const hasChildren = slotChildren && slotChildren.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-repeat-container="true"
|
||||
data-component-id={component?.id}
|
||||
className={cn(
|
||||
"rounded-md border border-dashed p-3 transition-colors",
|
||||
isDragOver
|
||||
? "border-green-500 bg-green-50/70"
|
||||
: "border-blue-300 bg-blue-50/50"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => {
|
||||
// 시각적 상태만 리셋, 드롭 로직은 ScreenDesigner에서 처리
|
||||
setIsDragOver(false);
|
||||
// 중요: preventDefault()를 호출하지 않아야 이벤트가 버블링됨
|
||||
// 하지만 필요하다면 호출해도 됨 - 버블링과 무관
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-700">
|
||||
<Repeat className="h-4 w-4" />
|
||||
<span className="font-medium">리피터 컨테이너</span>
|
||||
<span className="text-blue-500">({previewData.length}개 미리보기)</span>
|
||||
</div>
|
||||
{isDragOver ? (
|
||||
<div className="flex items-center gap-1 rounded bg-green-100 px-2 py-1 text-xs text-green-700">
|
||||
<Plus className="h-3 w-3" />
|
||||
여기에 놓으세요
|
||||
</div>
|
||||
) : !hasChildren ? (
|
||||
<div className="flex items-center gap-1 rounded bg-amber-100 px-2 py-1 text-xs text-amber-700">
|
||||
<Plus className="h-3 w-3" />
|
||||
컴포넌트를 드래그하세요
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={layoutStyle}>
|
||||
{previewData.map((row, index) => {
|
||||
const context: RepeatItemContext = {
|
||||
index,
|
||||
data: row,
|
||||
totalCount: previewData.length,
|
||||
isFirst: index === 0,
|
||||
isLast: index === previewData.length - 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id || index}
|
||||
style={itemStyle}
|
||||
className={cn(
|
||||
"relative transition-all",
|
||||
clickable && "cursor-pointer hover:shadow-md",
|
||||
showSelectedState &&
|
||||
selectedIndices.includes(index) &&
|
||||
"ring-2 ring-blue-500"
|
||||
)}
|
||||
>
|
||||
{showItemTitle && (titleColumn || itemTitleTemplate) && (
|
||||
<div className="mb-2 border-b pb-2">
|
||||
<div
|
||||
className="font-medium"
|
||||
style={{
|
||||
fontSize: titleFontSize,
|
||||
color: titleColor,
|
||||
fontWeight: titleFontWeight,
|
||||
}}
|
||||
>
|
||||
{generateTitle(row, index)}
|
||||
</div>
|
||||
{descriptionColumn && generateDescription(row) && (
|
||||
<div
|
||||
className="mt-1"
|
||||
style={{
|
||||
fontSize: descriptionFontSize,
|
||||
color: descriptionColor,
|
||||
}}
|
||||
>
|
||||
{generateDescription(row)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<div className="space-y-2">
|
||||
{/* 디자인 모드: 배치된 자식 컴포넌트들을 시각적으로 표시 */}
|
||||
{slotChildren!.map((child: SlotComponentConfig, childIdx: number) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center gap-2 rounded border border-dashed border-green-300 bg-green-50/50 px-2 py-1.5"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded bg-green-100 text-xs font-medium text-green-700">
|
||||
{childIdx + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-green-700">
|
||||
{child.label || child.componentType}
|
||||
</div>
|
||||
{child.fieldName && (
|
||||
<div className="truncate text-[10px] text-green-500">
|
||||
{child.fieldName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-green-400">
|
||||
{child.componentType}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center text-[10px] text-slate-400">
|
||||
아이템 #{index + 1} - 실행 시 데이터 바인딩
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-xs text-slate-500">
|
||||
반복 아이템 #{index + 1}
|
||||
</div>
|
||||
<div className="mt-1 text-slate-400">
|
||||
컴포넌트를 드래그하여 배치하세요
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태
|
||||
if (paginatedData.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed bg-slate-50 py-8 text-center">
|
||||
<Package className="mb-2 h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-md border bg-slate-50 py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 렌더링
|
||||
return (
|
||||
<div className="repeat-container">
|
||||
<div style={layoutStyle}>
|
||||
{paginatedData.map((row, index) => {
|
||||
const context: RepeatItemContext = {
|
||||
index,
|
||||
data: row,
|
||||
totalCount: filteredData.length,
|
||||
isFirst: index === 0,
|
||||
isLast: index === paginatedData.length - 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id || row._id || index}
|
||||
style={itemStyle}
|
||||
className={cn(
|
||||
"repeat-container-item relative transition-all",
|
||||
clickable && "cursor-pointer hover:shadow-md",
|
||||
showSelectedState &&
|
||||
selectedIndices.includes(index) &&
|
||||
"ring-2 ring-blue-500"
|
||||
)}
|
||||
onClick={() => handleItemClick(index, row)}
|
||||
>
|
||||
{showItemTitle && (titleColumn || itemTitleTemplate) && (
|
||||
<div className="mb-2 border-b pb-2">
|
||||
<div
|
||||
className="font-medium"
|
||||
style={{
|
||||
fontSize: titleFontSize,
|
||||
color: titleColor,
|
||||
fontWeight: titleFontWeight,
|
||||
}}
|
||||
>
|
||||
{generateTitle(row, index)}
|
||||
</div>
|
||||
{descriptionColumn && generateDescription(row) && (
|
||||
<div
|
||||
className="mt-1"
|
||||
style={{
|
||||
fontSize: descriptionFontSize,
|
||||
color: descriptionColor,
|
||||
}}
|
||||
>
|
||||
{generateDescription(row)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderSlotChildren(context)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{usePaging && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="px-3 text-sm text-muted-foreground">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RepeatContainerWrapper = RepeatContainerComponent;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { V2RepeatContainerDefinition } from "./index";
|
||||
|
||||
// v2 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(V2RepeatContainerDefinition);
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { RepeatContainerWrapper } from "./RepeatContainerComponent";
|
||||
import { RepeatContainerConfigPanel } from "./RepeatContainerConfigPanel";
|
||||
import type { RepeatContainerConfig } from "./types";
|
||||
|
||||
/**
|
||||
* RepeatContainer 컴포넌트 정의
|
||||
* 데이터 수만큼 내부 컨텐츠를 반복 렌더링하는 컨테이너
|
||||
*/
|
||||
export const V2RepeatContainerDefinition = createComponentDefinition({
|
||||
id: "v2-repeat-container",
|
||||
name: "리피터 컨테이너",
|
||||
nameEng: "Repeat Container",
|
||||
description: "데이터 수만큼 내부 컴포넌트를 반복 렌더링하는 컨테이너",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "text",
|
||||
component: RepeatContainerWrapper,
|
||||
defaultConfig: {
|
||||
dataSourceType: "manual",
|
||||
layout: "vertical",
|
||||
gridColumns: 2,
|
||||
gap: "16px",
|
||||
showBorder: true,
|
||||
showShadow: false,
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "16px",
|
||||
showItemTitle: false,
|
||||
itemTitleTemplate: "",
|
||||
titleFontSize: "14px",
|
||||
titleColor: "#374151",
|
||||
titleFontWeight: "600",
|
||||
emptyMessage: "데이터가 없습니다",
|
||||
usePaging: false,
|
||||
pageSize: 10,
|
||||
clickable: false,
|
||||
showSelectedState: true,
|
||||
selectionMode: "single",
|
||||
} as Partial<RepeatContainerConfig>,
|
||||
defaultSize: { width: 600, height: 300 },
|
||||
configPanel: RepeatContainerConfigPanel,
|
||||
icon: "Repeat",
|
||||
tags: ["리피터", "반복", "컨테이너", "데이터", "레이아웃", "그리드"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
RepeatContainerConfig,
|
||||
SlotComponentConfig,
|
||||
RepeatItemContext,
|
||||
RepeatContainerValue,
|
||||
DataSourceType,
|
||||
LayoutType,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 데이터 소스 타입
|
||||
*/
|
||||
export type DataSourceType = "table-list" | "unified-repeater" | "externalData" | "manual";
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 레이아웃 타입
|
||||
*/
|
||||
export type LayoutType = "vertical" | "horizontal" | "grid";
|
||||
|
||||
/**
|
||||
* 슬롯에 배치된 컴포넌트 설정
|
||||
* 화면 디자이너에서 리피터 컨테이너 내부에 배치한 컴포넌트 정보
|
||||
*/
|
||||
export interface SlotComponentConfig {
|
||||
id: string;
|
||||
/** 컴포넌트 타입 (예: "text-input", "text-display") */
|
||||
componentType: string;
|
||||
/** 컴포넌트 라벨 */
|
||||
label?: string;
|
||||
/** 바인딩할 데이터 필드명 */
|
||||
fieldName?: string;
|
||||
/** 컴포넌트 위치 (슬롯 내부 상대 좌표) */
|
||||
position?: { x: number; y: number };
|
||||
/** 컴포넌트 크기 */
|
||||
size?: { width: number; height: number };
|
||||
/** 컴포넌트 상세 설정 */
|
||||
componentConfig?: Record<string, any>;
|
||||
/** 스타일 설정 */
|
||||
style?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 설정
|
||||
* 데이터 수만큼 내부 컴포넌트 또는 컨텐츠를 반복 렌더링하는 컨테이너
|
||||
*/
|
||||
export interface RepeatContainerConfig extends ComponentConfig {
|
||||
// ========================
|
||||
// 1. 데이터 소스 설정
|
||||
// ========================
|
||||
/** 데이터 소스 타입 */
|
||||
dataSourceType: DataSourceType;
|
||||
/** 연결할 테이블 리스트 또는 리피터 컴포넌트 ID */
|
||||
dataSourceComponentId?: string;
|
||||
|
||||
// 컴포넌트별 테이블 설정 (개발 가이드 준수)
|
||||
/** 사용할 테이블명 */
|
||||
tableName?: string;
|
||||
/** 커스텀 테이블명 */
|
||||
customTableName?: string;
|
||||
/** true: customTableName 사용 */
|
||||
useCustomTable?: boolean;
|
||||
/** true: 조회만, 저장 안 함 */
|
||||
isReadOnly?: boolean;
|
||||
|
||||
// ========================
|
||||
// 2. 레이아웃 설정
|
||||
// ========================
|
||||
/** 배치 방향 */
|
||||
layout: LayoutType;
|
||||
/** grid일 때 컬럼 수 */
|
||||
gridColumns?: number;
|
||||
/** 아이템 간 간격 */
|
||||
gap?: string;
|
||||
/** 아이템 최소 너비 */
|
||||
itemMinWidth?: string;
|
||||
/** 아이템 최대 너비 */
|
||||
itemMaxWidth?: string;
|
||||
/** 아이템 높이 */
|
||||
itemHeight?: string;
|
||||
|
||||
// ========================
|
||||
// 3. 아이템 카드 설정
|
||||
// ========================
|
||||
/** 카드 테두리 표시 */
|
||||
showBorder?: boolean;
|
||||
/** 카드 그림자 표시 */
|
||||
showShadow?: boolean;
|
||||
/** 카드 둥근 모서리 */
|
||||
borderRadius?: string;
|
||||
/** 카드 배경색 */
|
||||
backgroundColor?: string;
|
||||
/** 카드 내부 패딩 */
|
||||
padding?: string;
|
||||
|
||||
// ========================
|
||||
// 4. 제목/설명 설정 (각 아이템)
|
||||
// ========================
|
||||
/** 아이템 제목 표시 */
|
||||
showItemTitle?: boolean;
|
||||
/** 아이템 제목 템플릿 (예: "{order_no} - {item_code}") - 레거시 */
|
||||
itemTitleTemplate?: string;
|
||||
/** 제목으로 사용할 컬럼명 */
|
||||
titleColumn?: string;
|
||||
/** 설명으로 사용할 컬럼명 */
|
||||
descriptionColumn?: string;
|
||||
/** 제목 폰트 크기 */
|
||||
titleFontSize?: string;
|
||||
/** 제목 색상 */
|
||||
titleColor?: string;
|
||||
/** 제목 폰트 굵기 */
|
||||
titleFontWeight?: string;
|
||||
/** 설명 폰트 크기 */
|
||||
descriptionFontSize?: string;
|
||||
/** 설명 색상 */
|
||||
descriptionColor?: string;
|
||||
|
||||
// ========================
|
||||
// 5. 데이터 필터링 (선택사항)
|
||||
// ========================
|
||||
/** 필터 필드 (formData에서 가져올 키) */
|
||||
filterField?: string;
|
||||
/** 필터 컬럼 (테이블에서 필터링할 컬럼) */
|
||||
filterColumn?: string;
|
||||
|
||||
// ========================
|
||||
// 6. 그룹핑 설정 (선택사항)
|
||||
// ========================
|
||||
/** 그룹핑 사용 여부 */
|
||||
useGrouping?: boolean;
|
||||
/** 그룹핑 기준 필드 */
|
||||
groupByField?: string;
|
||||
|
||||
// ========================
|
||||
// 7. 슬롯 컨텐츠 설정 (children 직접 배치)
|
||||
// ========================
|
||||
/**
|
||||
* 슬롯에 배치된 자식 컴포넌트들
|
||||
* 화면 디자이너에서 리피터 컨테이너 내부에 드래그앤드롭으로 배치된 컴포넌트들
|
||||
* 각 데이터 아이템마다 이 컴포넌트들이 반복 렌더링됨
|
||||
*/
|
||||
children?: SlotComponentConfig[];
|
||||
|
||||
// ========================
|
||||
// 8. 빈 상태 설정
|
||||
// ========================
|
||||
/** 데이터 없을 때 표시 메시지 */
|
||||
emptyMessage?: string;
|
||||
/** 빈 상태 아이콘 */
|
||||
emptyIcon?: string;
|
||||
|
||||
// ========================
|
||||
// 9. 페이징 설정 (선택사항)
|
||||
// ========================
|
||||
/** 페이징 사용 여부 */
|
||||
usePaging?: boolean;
|
||||
/** 페이지당 아이템 수 */
|
||||
pageSize?: number;
|
||||
|
||||
// ========================
|
||||
// 10. 이벤트 설정
|
||||
// ========================
|
||||
/** 아이템 클릭 이벤트 활성화 */
|
||||
clickable?: boolean;
|
||||
/** 클릭 시 선택 상태 표시 */
|
||||
showSelectedState?: boolean;
|
||||
/** 선택 모드 (단일/다중) */
|
||||
selectionMode?: "single" | "multiple";
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 값 타입
|
||||
* 현재 렌더링 중인 데이터 배열
|
||||
*/
|
||||
export interface RepeatContainerValue {
|
||||
/** 원본 데이터 배열 */
|
||||
data: Record<string, any>[];
|
||||
/** 선택된 아이템 인덱스들 */
|
||||
selectedIndices?: number[];
|
||||
/** 현재 페이지 (페이징 사용 시) */
|
||||
currentPage?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨텍스트 (각 반복 아이템에서 사용)
|
||||
*/
|
||||
export interface RepeatItemContext {
|
||||
/** 현재 아이템 인덱스 */
|
||||
index: number;
|
||||
/** 현재 아이템 데이터 */
|
||||
data: Record<string, any>;
|
||||
/** 전체 데이터 수 */
|
||||
totalCount: number;
|
||||
/** 첫 번째 아이템인지 */
|
||||
isFirst: boolean;
|
||||
/** 마지막 아이템인지 */
|
||||
isLast: boolean;
|
||||
/** 그룹 키 (그룹핑 사용 시) */
|
||||
groupKey?: string;
|
||||
/** 그룹 내 인덱스 (그룹핑 사용 시) */
|
||||
groupIndex?: number;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
# RepeatScreenModal 컴포넌트 v3.1
|
||||
|
||||
## 개요
|
||||
|
||||
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
|
||||
|
||||
## v3.1 주요 변경사항 (2025-11-28)
|
||||
|
||||
### 1. 외부 테이블 데이터 소스
|
||||
|
||||
테이블 행에서 **외부 테이블의 데이터를 조회**하여 표시할 수 있습니다.
|
||||
|
||||
```
|
||||
예시: 수주 관리에서 출하 계획 이력 조회
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 카드: 품목 A │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 1] 헤더: 품목코드, 품목명 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 2] 테이블: shipment_plan 테이블에서 조회 │
|
||||
│ → sales_order_id로 조인하여 출하 계획 이력 표시 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 테이블 행 CRUD
|
||||
|
||||
테이블 행에서 **행 추가/수정/삭제** 기능을 지원합니다.
|
||||
|
||||
- **추가**: 새 행 추가 버튼으로 빈 행 생성
|
||||
- **수정**: 편집 가능한 컬럼 직접 수정
|
||||
- **삭제**: 행 삭제 (확인 팝업 옵션)
|
||||
|
||||
### 3. Footer 버튼 영역
|
||||
|
||||
모달 하단에 **커스터마이징 가능한 버튼 영역**을 제공합니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 카드 내용... │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [초기화] [취소] [저장] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4. 집계 연산식 지원
|
||||
|
||||
집계 행에서 **컬럼 간 사칙연산**을 지원합니다.
|
||||
|
||||
```typescript
|
||||
// 예: 미출하 수량 = 수주수량 - 출하수량
|
||||
{
|
||||
sourceType: "formula",
|
||||
formula: "{order_qty} - {ship_qty}",
|
||||
label: "미출하 수량"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v3 주요 변경사항 (기존)
|
||||
|
||||
### 자유 레이아웃 시스템
|
||||
|
||||
기존의 "simple 모드 / withTable 모드" 구분을 없애고, **행(Row)을 추가하고 각 행마다 타입을 선택**하는 방식으로 변경되었습니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 카드 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 1] 타입: 헤더 → 품목코드, 품목명, 규격 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 2] 타입: 집계 → 총수주잔량, 현재고, 가용재고 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 3] 타입: 테이블 → 수주번호, 거래처, 납기일, 출하계획 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 4] 타입: 테이블 → 또 다른 테이블도 가능! │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 행 타입
|
||||
|
||||
| 타입 | 설명 | 사용 시나리오 |
|
||||
|------|------|---------------|
|
||||
| **헤더 (header)** | 필드들을 가로/세로로 나열 | 품목정보, 거래처정보 표시 |
|
||||
| **필드 (fields)** | 헤더와 동일, 편집 가능 | 폼 입력 영역 |
|
||||
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
|
||||
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
|
||||
|
||||
---
|
||||
|
||||
## 설정 방법
|
||||
|
||||
### 1. 기본 설정 탭
|
||||
|
||||
- **카드 제목 표시**: 카드 상단에 제목을 표시할지 여부
|
||||
- **카드 제목 템플릿**: `{field_name}` 형식으로 동적 제목 생성
|
||||
- **카드 간격**: 카드 사이의 간격 (8px ~ 32px)
|
||||
- **테두리**: 카드 테두리 표시 여부
|
||||
- **저장 모드**: 전체 저장 / 개별 저장
|
||||
|
||||
### 2. 데이터 소스 탭
|
||||
|
||||
- **소스 테이블**: 데이터를 조회할 테이블
|
||||
- **필터 필드**: formData에서 필터링할 필드 (예: selectedIds)
|
||||
|
||||
### 3. 그룹 탭
|
||||
|
||||
- **그룹핑 활성화**: 여러 행을 하나의 카드로 묶을지 여부
|
||||
- **그룹 기준 필드**: 그룹핑할 필드 (예: part_code)
|
||||
- **집계 설정**:
|
||||
- 원본 필드: 합계할 필드 (예: balance_qty)
|
||||
- 집계 타입: sum, count, avg, min, max
|
||||
- 결과 필드명: 집계 결과를 저장할 필드명
|
||||
- 라벨: 표시될 라벨
|
||||
|
||||
### 4. 레이아웃 탭
|
||||
|
||||
#### 행 추가
|
||||
|
||||
4가지 타입의 행을 추가할 수 있습니다:
|
||||
- **헤더**: 필드 정보 표시 (읽기전용)
|
||||
- **집계**: 그룹 집계값 표시
|
||||
- **테이블**: 그룹 내 행들을 테이블로 표시
|
||||
- **필드**: 입력 필드 (편집가능)
|
||||
|
||||
#### 헤더/필드 행 설정
|
||||
|
||||
- **방향**: 가로 / 세로
|
||||
- **배경색**: 없음, 파랑, 초록, 보라, 주황
|
||||
- **컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능, 필수
|
||||
- **소스 설정**: 직접 / 조인 / 수동
|
||||
- **저장 설정**: 저장할 테이블과 컬럼
|
||||
|
||||
#### 집계 행 설정
|
||||
|
||||
- **레이아웃**: 가로 나열 / 그리드
|
||||
- **그리드 컬럼 수**: 2, 3, 4개
|
||||
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
|
||||
- **스타일**: 배경색, 폰트 크기
|
||||
|
||||
#### 테이블 행 설정 (v3.1 확장)
|
||||
|
||||
- **테이블 제목**: 선택사항
|
||||
- **헤더 표시**: 테이블 헤더 표시 여부
|
||||
- **외부 테이블 데이터 소스**: (v3.1 신규)
|
||||
- 소스 테이블: 조회할 외부 테이블
|
||||
- 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키
|
||||
- 정렬: 정렬 컬럼 및 방향
|
||||
- **CRUD 설정**: (v3.1 신규)
|
||||
- 추가: 새 행 추가 허용
|
||||
- 수정: 행 수정 허용
|
||||
- 삭제: 행 삭제 허용 (확인 팝업 옵션)
|
||||
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
|
||||
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
|
||||
|
||||
### 5. Footer 탭 (v3.1 신규)
|
||||
|
||||
- **Footer 사용**: Footer 영역 활성화
|
||||
- **위치**: 컨텐츠 아래 / 하단 고정 (sticky)
|
||||
- **정렬**: 왼쪽 / 가운데 / 오른쪽
|
||||
- **버튼 설정**:
|
||||
- 라벨: 버튼 텍스트
|
||||
- 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀
|
||||
- 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트
|
||||
- 아이콘: 저장 / X / 초기화 / 없음
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
1. formData에서 selectedIds 가져오기
|
||||
↓
|
||||
2. 소스 테이블에서 해당 ID들의 데이터 조회
|
||||
↓
|
||||
3. 그룹핑 활성화 시 groupByField 기준으로 그룹화
|
||||
↓
|
||||
4. 각 그룹에 대해 집계값 계산
|
||||
↓
|
||||
5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1)
|
||||
↓
|
||||
6. 카드 렌더링 (contentRows 기반)
|
||||
↓
|
||||
7. 사용자 편집 (CRUD 포함)
|
||||
↓
|
||||
8. Footer 버튼 또는 기본 저장 버튼으로 저장
|
||||
↓
|
||||
9. 기본 데이터 + 외부 테이블 데이터 일괄 저장
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD)
|
||||
|
||||
```typescript
|
||||
{
|
||||
showCardTitle: true,
|
||||
cardTitle: "{part_code} - {part_name}",
|
||||
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: "수주건수" }
|
||||
]
|
||||
},
|
||||
contentRows: [
|
||||
{
|
||||
id: "row-1",
|
||||
type: "header",
|
||||
columns: [
|
||||
{ id: "c1", field: "part_code", label: "품목코드", type: "text", editable: false },
|
||||
{ id: "c2", field: "part_name", label: "품목명", type: "text", editable: false }
|
||||
],
|
||||
layout: "horizontal"
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
type: "aggregation",
|
||||
aggregationLayout: "horizontal",
|
||||
aggregationFields: [
|
||||
{ sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
|
||||
{ sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "row-3",
|
||||
type: "table",
|
||||
tableTitle: "출하 계획 이력",
|
||||
showTableHeader: true,
|
||||
// 외부 테이블에서 데이터 조회
|
||||
tableDataSource: {
|
||||
enabled: true,
|
||||
sourceTable: "shipment_plan",
|
||||
joinConditions: [
|
||||
{ sourceKey: "sales_order_id", referenceKey: "id" }
|
||||
],
|
||||
orderBy: { column: "created_date", direction: "desc" }
|
||||
},
|
||||
// CRUD 설정
|
||||
tableCrud: {
|
||||
allowCreate: true,
|
||||
allowUpdate: true,
|
||||
allowDelete: true,
|
||||
newRowDefaults: {
|
||||
sales_order_id: "{id}",
|
||||
status: "READY"
|
||||
},
|
||||
deleteConfirm: { enabled: true }
|
||||
},
|
||||
tableColumns: [
|
||||
{ id: "tc1", field: "plan_date", label: "계획일", type: "date", editable: true },
|
||||
{ id: "tc2", field: "plan_qty", label: "계획수량", type: "number", editable: true },
|
||||
{ id: "tc3", field: "status", label: "상태", type: "text", editable: false },
|
||||
{ id: "tc4", field: "memo", label: "비고", type: "text", editable: true }
|
||||
]
|
||||
}
|
||||
],
|
||||
// Footer 설정
|
||||
footerConfig: {
|
||||
enabled: true,
|
||||
position: "sticky",
|
||||
alignment: "right",
|
||||
buttons: [
|
||||
{ id: "btn-cancel", label: "취소", action: "cancel", variant: "outline" },
|
||||
{ id: "btn-save", label: "저장", action: "save", variant: "default", icon: "save" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 타입 정의 (v3.1)
|
||||
|
||||
### TableDataSourceConfig
|
||||
|
||||
```typescript
|
||||
interface TableDataSourceConfig {
|
||||
enabled: boolean; // 외부 데이터 소스 사용 여부
|
||||
sourceTable: string; // 조회할 테이블
|
||||
joinConditions: JoinCondition[]; // 조인 조건
|
||||
orderBy?: {
|
||||
column: string; // 정렬 컬럼
|
||||
direction: "asc" | "desc"; // 정렬 방향
|
||||
};
|
||||
limit?: number; // 최대 행 수
|
||||
}
|
||||
|
||||
interface JoinCondition {
|
||||
sourceKey: string; // 외부 테이블의 조인 키
|
||||
referenceKey: string; // 카드 데이터의 참조 키
|
||||
referenceType?: "card" | "row"; // 참조 소스
|
||||
}
|
||||
```
|
||||
|
||||
### TableCrudConfig
|
||||
|
||||
```typescript
|
||||
interface TableCrudConfig {
|
||||
allowCreate: boolean; // 행 추가 허용
|
||||
allowUpdate: boolean; // 행 수정 허용
|
||||
allowDelete: boolean; // 행 삭제 허용
|
||||
newRowDefaults?: Record<string, string>; // 신규 행 기본값 ({field} 형식 지원)
|
||||
deleteConfirm?: {
|
||||
enabled: boolean; // 삭제 확인 팝업
|
||||
message?: string; // 확인 메시지
|
||||
};
|
||||
targetTable?: string; // 저장 대상 테이블
|
||||
}
|
||||
```
|
||||
|
||||
### FooterConfig
|
||||
|
||||
```typescript
|
||||
interface FooterConfig {
|
||||
enabled: boolean; // Footer 사용 여부
|
||||
buttons?: FooterButtonConfig[];
|
||||
position?: "sticky" | "static";
|
||||
alignment?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
interface FooterButtonConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
action: "save" | "cancel" | "close" | "reset" | "custom";
|
||||
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost";
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
customAction?: {
|
||||
type: string;
|
||||
config?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### AggregationDisplayConfig (v3.1 확장)
|
||||
|
||||
```typescript
|
||||
interface AggregationDisplayConfig {
|
||||
// 값 소스 타입
|
||||
sourceType: "aggregation" | "formula" | "external" | "externalFormula";
|
||||
|
||||
// aggregation: 기존 집계 결과 참조
|
||||
aggregationResultField?: string;
|
||||
|
||||
// formula: 컬럼 간 연산
|
||||
formula?: string; // 예: "{order_qty} - {ship_qty}"
|
||||
|
||||
// external: 외부 테이블 조회 (향후 구현)
|
||||
externalSource?: ExternalValueSource;
|
||||
|
||||
// externalFormula: 외부 테이블 + 연산 (향후 구현)
|
||||
externalSources?: ExternalValueSource[];
|
||||
externalFormula?: string;
|
||||
|
||||
// 표시 설정
|
||||
label: string;
|
||||
icon?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl";
|
||||
format?: "number" | "currency" | "percent";
|
||||
decimalPlaces?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 레거시 호환
|
||||
|
||||
v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다.
|
||||
새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다.
|
||||
2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다.
|
||||
3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다.
|
||||
4. **외부 테이블 CRUD**: 외부 테이블 데이터 소스가 설정된 테이블에서만 CRUD가 동작합니다.
|
||||
5. **연산식**: 사칙연산(+, -, *, /)과 괄호만 지원됩니다. 복잡한 함수는 지원하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
### v3.1 (2025-11-28)
|
||||
- 외부 테이블 데이터 소스 기능 추가
|
||||
- 테이블 행 CRUD (추가/수정/삭제) 기능 추가
|
||||
- Footer 버튼 영역 기능 추가
|
||||
- 집계 연산식 (formula) 지원 추가
|
||||
- 다단계 조인 타입 정의 추가 (향후 구현 예정)
|
||||
|
||||
### v3.0
|
||||
- 자유 레이아웃 시스템 도입
|
||||
- contentRows 기반 행 타입 선택 방식
|
||||
- 헤더/필드/집계/테이블 4가지 행 타입 지원
|
||||
|
||||
### v2.0
|
||||
- simple 모드 / withTable 모드 구분
|
||||
- cardLayout / tableLayout 분리
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { V2RepeatScreenModalDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(V2RepeatScreenModalDefinition);
|
||||
// console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"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,
|
||||
CardContentRowConfig,
|
||||
AggregationDisplayConfig,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* RepeatScreenModal 컴포넌트 정의 v3
|
||||
* 반복 화면 모달 - 선택한 행 개수만큼 카드를 생성하며, 각 카드는 커스터마이징 가능한 레이아웃
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 🆕 v3: 자유 레이아웃 - 행(Row)을 추가하고 각 행마다 타입(헤더/집계/테이블/필드) 선택
|
||||
* - 그룹핑: 특정 필드 기준으로 여러 행을 하나의 카드로 묶기
|
||||
* - 집계: 그룹 내 데이터의 합계/평균/개수 등 자동 계산
|
||||
* - 카드 내 테이블: 그룹 내 각 행을 테이블 형태로 표시
|
||||
* - 유연한 레이아웃: 행 타입 자유 선택, 순서 자유 배치
|
||||
* - 컬럼별 소스 설정: 직접 조회/조인 조회/수동 입력
|
||||
* - 컬럼별 타겟 설정: 어느 테이블의 어느 컬럼에 저장할지 설정
|
||||
* - 다중 테이블 저장: 하나의 카드에서 여러 테이블 동시 저장
|
||||
*
|
||||
* 사용 시나리오:
|
||||
* - 출하계획 동시 등록 (품목별 그룹핑 + 수주별 테이블)
|
||||
* - 구매발주 일괄 등록 (공급업체별 그룹핑 + 품목별 테이블)
|
||||
* - 생산계획 일괄 등록 (제품별 그룹핑 + 작업지시별 테이블)
|
||||
* - 입고검사 일괄 처리 (발주번호별 그룹핑 + 품목별 검사결과)
|
||||
*/
|
||||
export const V2RepeatScreenModalDefinition = createComponentDefinition({
|
||||
id: "v2-repeat-screen-modal",
|
||||
name: "반복 화면 모달",
|
||||
nameEng: "Repeat Screen Modal",
|
||||
description:
|
||||
"선택한 행을 그룹핑하여 카드로 표시하고, 각 카드는 헤더/집계/테이블을 자유롭게 구성 가능한 폼 (출하계획, 구매발주 등)",
|
||||
category: ComponentCategory.DATA,
|
||||
webType: "form",
|
||||
component: RepeatScreenModalComponent,
|
||||
defaultConfig: {
|
||||
// 기본 설정
|
||||
showCardTitle: true,
|
||||
cardTitle: "카드 {index}",
|
||||
cardSpacing: "24px",
|
||||
showCardBorder: true,
|
||||
saveMode: "all",
|
||||
|
||||
// 데이터 소스
|
||||
dataSource: {
|
||||
sourceTable: "",
|
||||
filterField: "selectedIds",
|
||||
},
|
||||
|
||||
// 그룹핑 설정
|
||||
grouping: {
|
||||
enabled: false,
|
||||
groupByField: "",
|
||||
aggregations: [],
|
||||
},
|
||||
|
||||
// 🆕 v3: 자유 레이아웃 (행 추가 후 타입 선택)
|
||||
contentRows: [],
|
||||
|
||||
// (레거시 호환)
|
||||
cardMode: "simple",
|
||||
cardLayout: [],
|
||||
tableLayout: {
|
||||
headerRows: [],
|
||||
tableColumns: [],
|
||||
},
|
||||
} as Partial<RepeatScreenModalProps>,
|
||||
defaultSize: { width: 1000, height: 800 },
|
||||
configPanel: RepeatScreenModalConfigPanel,
|
||||
icon: "LayoutGrid",
|
||||
tags: ["모달", "폼", "반복", "카드", "그룹핑", "집계", "테이블", "편집", "데이터", "출하계획", "일괄등록", "자유레이아웃"],
|
||||
version: "3.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 재 export
|
||||
export type {
|
||||
RepeatScreenModalProps,
|
||||
CardRowConfig,
|
||||
CardColumnConfig,
|
||||
ColumnSourceConfig,
|
||||
ColumnTargetConfig,
|
||||
DataSourceConfig,
|
||||
CardData,
|
||||
GroupingConfig,
|
||||
AggregationConfig,
|
||||
TableLayoutConfig,
|
||||
TableColumnConfig,
|
||||
GroupedCardData,
|
||||
CardRowData,
|
||||
CardContentRowConfig,
|
||||
AggregationDisplayConfig,
|
||||
};
|
||||
|
||||
// 컴포넌트 재 export
|
||||
export { RepeatScreenModalComponent, RepeatScreenModalConfigPanel };
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
import { ComponentRendererProps } from "@/types/component";
|
||||
|
||||
/**
|
||||
* RepeatScreenModal Props
|
||||
* 선택한 행 개수만큼 카드를 생성하며, 각 카드는 커스터마이징 가능한 레이아웃을 가짐
|
||||
*
|
||||
* 🆕 v3: 행(Row) 기반 자유 레이아웃 - 각 행마다 타입(헤더/집계/테이블) 선택 가능
|
||||
*/
|
||||
export interface RepeatScreenModalProps {
|
||||
// === 기본 설정 ===
|
||||
showCardTitle?: boolean; // 카드 제목 표시 여부
|
||||
cardTitle?: string; // 카드 제목 템플릿 (예: "{order_no} - {item_code}")
|
||||
cardSpacing?: string; // 카드 간 간격 (기본: 24px)
|
||||
showCardBorder?: boolean; // 카드 테두리 표시 여부
|
||||
saveMode?: "all" | "individual"; // 저장 모드
|
||||
|
||||
// === 데이터 소스 ===
|
||||
dataSource?: DataSourceConfig; // 데이터 소스 설정
|
||||
|
||||
// === 그룹핑 설정 ===
|
||||
grouping?: GroupingConfig; // 그룹핑 설정
|
||||
|
||||
// === 🆕 v3: 자유 레이아웃 ===
|
||||
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
|
||||
|
||||
// === 🆕 v3.1: Footer 버튼 설정 ===
|
||||
footerConfig?: FooterConfig; // Footer 영역 설정
|
||||
|
||||
// === (레거시 호환) ===
|
||||
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
|
||||
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
|
||||
tableLayout?: TableLayoutConfig; // @deprecated - contentRows 사용 권장
|
||||
|
||||
// === 값 ===
|
||||
value?: any[];
|
||||
onChange?: (newData: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.1: Footer 설정
|
||||
*/
|
||||
export interface FooterConfig {
|
||||
enabled: boolean; // Footer 사용 여부
|
||||
buttons?: FooterButtonConfig[]; // Footer 버튼들
|
||||
position?: "sticky" | "static"; // sticky: 하단 고정, static: 컨텐츠 아래
|
||||
alignment?: "left" | "center" | "right"; // 버튼 정렬
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.1: Footer 버튼 설정
|
||||
*/
|
||||
export interface FooterButtonConfig {
|
||||
id: string; // 버튼 고유 ID
|
||||
label: string; // 버튼 라벨
|
||||
action: "save" | "cancel" | "close" | "reset" | "custom"; // 액션 타입
|
||||
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일
|
||||
icon?: string; // 아이콘 (lucide 아이콘명)
|
||||
disabled?: boolean; // 비활성화 여부
|
||||
|
||||
// custom 액션일 때
|
||||
customAction?: {
|
||||
type: string; // 커스텀 액션 타입
|
||||
config?: Record<string, any>; // 커스텀 설정
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 설정
|
||||
*/
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3: 카드 내부 행 설정
|
||||
* 각 행마다 타입(헤더/집계/테이블)을 선택할 수 있음
|
||||
*/
|
||||
export interface CardContentRowConfig {
|
||||
id: string; // 행 고유 ID
|
||||
type: "header" | "aggregation" | "table" | "fields"; // 행 타입
|
||||
|
||||
// === header/fields 타입일 때 ===
|
||||
columns?: CardColumnConfig[]; // 컬럼 설정
|
||||
layout?: "horizontal" | "vertical"; // 레이아웃 방향
|
||||
gap?: string; // 컬럼 간 간격
|
||||
backgroundColor?: string; // 배경색
|
||||
padding?: string; // 패딩
|
||||
|
||||
// === aggregation 타입일 때 ===
|
||||
aggregationFields?: AggregationDisplayConfig[]; // 표시할 집계 필드들
|
||||
aggregationLayout?: "horizontal" | "grid"; // 집계 레이아웃 (가로 나열 / 그리드)
|
||||
aggregationColumns?: number; // grid일 때 컬럼 수 (기본: 4)
|
||||
|
||||
// === table 타입일 때 ===
|
||||
tableColumns?: TableColumnConfig[]; // 테이블 컬럼 설정
|
||||
tableTitle?: string; // 테이블 제목
|
||||
showTableHeader?: boolean; // 테이블 헤더 표시 여부
|
||||
tableMaxHeight?: string; // 테이블 최대 높이
|
||||
|
||||
// 🆕 v3.1: 테이블 외부 데이터 소스
|
||||
tableDataSource?: TableDataSourceConfig; // 외부 테이블에서 데이터 조회
|
||||
|
||||
// 🆕 v3.1: 테이블 CRUD 설정
|
||||
tableCrud?: TableCrudConfig; // 행 추가/수정/삭제 설정
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.1: 테이블 데이터 소스 설정
|
||||
* 외부 테이블에서 연관 데이터를 조회
|
||||
*/
|
||||
export interface TableDataSourceConfig {
|
||||
enabled: boolean; // 외부 데이터 소스 사용 여부
|
||||
sourceTable: string; // 조회할 테이블 (예: "shipment_plan")
|
||||
|
||||
// 조인 설정
|
||||
joinConditions: JoinCondition[]; // 조인 조건 (복합 키 지원)
|
||||
|
||||
// 🆕 v3.3: 추가 조인 테이블 설정 (소스 테이블에 없는 컬럼 조회)
|
||||
additionalJoins?: AdditionalJoinConfig[];
|
||||
|
||||
// 🆕 v3.4: 필터 조건 설정 (그룹 내 특정 조건으로 필터링)
|
||||
filterConfig?: TableFilterConfig;
|
||||
|
||||
// 정렬 설정
|
||||
orderBy?: {
|
||||
column: string; // 정렬 컬럼
|
||||
direction: "asc" | "desc"; // 정렬 방향
|
||||
};
|
||||
|
||||
// 제한
|
||||
limit?: number; // 최대 행 수
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.4: 테이블 필터 설정
|
||||
* 그룹 내 데이터를 특정 조건으로 필터링
|
||||
*/
|
||||
export interface TableFilterConfig {
|
||||
enabled: boolean; // 필터 사용 여부
|
||||
filterField: string; // 필터링할 필드 (예: "order_no")
|
||||
filterType: "equals" | "notEquals"; // equals: 같은 값만, notEquals: 다른 값만
|
||||
referenceField: string; // 비교 기준 필드 (formData 또는 카드 대표 데이터에서)
|
||||
referenceSource: "formData" | "representativeData"; // 비교 값 소스
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.3: 추가 조인 테이블 설정
|
||||
* 소스 테이블에서 다른 테이블을 조인하여 컬럼 가져오기
|
||||
*/
|
||||
export interface AdditionalJoinConfig {
|
||||
id: string; // 조인 설정 고유 ID
|
||||
joinTable: string; // 조인할 테이블 (예: "sales_order_mng")
|
||||
joinType: "left" | "inner"; // 조인 타입
|
||||
sourceKey: string; // 소스 테이블의 조인 키 (예: "sales_order_id")
|
||||
targetKey: string; // 조인 테이블의 키 (예: "id")
|
||||
alias?: string; // 테이블 별칭 (예: "so")
|
||||
selectColumns?: string[]; // 가져올 컬럼 목록 (비어있으면 전체)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.1: 조인 조건
|
||||
*/
|
||||
export interface JoinCondition {
|
||||
sourceKey: string; // 외부 테이블의 조인 키 (예: "sales_order_id")
|
||||
referenceKey: string; // 현재 카드 데이터의 참조 키 (예: "id")
|
||||
referenceType?: "card" | "row"; // card: 카드 대표 데이터, row: 각 행 데이터 (기본: card)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.1: 테이블 CRUD 설정
|
||||
*/
|
||||
export interface TableCrudConfig {
|
||||
allowCreate: boolean; // 행 추가 허용
|
||||
allowUpdate: boolean; // 행 수정 허용
|
||||
allowDelete: boolean; // 행 삭제 허용
|
||||
|
||||
// 신규 행 기본값
|
||||
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
|
||||
|
||||
// 삭제 확인
|
||||
deleteConfirm?: {
|
||||
enabled: boolean; // 삭제 확인 팝업 표시 여부
|
||||
message?: string; // 확인 메시지
|
||||
};
|
||||
|
||||
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
|
||||
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
|
||||
|
||||
// 🆕 v3.12: 연동 저장 설정 (모달 전체 저장 시 다른 테이블에도 동기화)
|
||||
syncSaves?: SyncSaveConfig[];
|
||||
|
||||
// 🆕 v3.13: 행 추가 시 자동 채번 설정
|
||||
rowNumbering?: RowNumberingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.13: 테이블 행 채번 설정
|
||||
* "추가" 버튼 클릭 시 특정 컬럼에 자동으로 번호를 생성
|
||||
*
|
||||
* 사용 예시:
|
||||
* - 출하계획번호(shipment_plan_no) 자동 생성
|
||||
* - 송장번호(invoice_no) 자동 생성
|
||||
* - 작업지시번호(work_order_no) 자동 생성
|
||||
*
|
||||
* 참고: 채번 후 읽기 전용 여부는 테이블 컬럼의 "수정 가능" 설정으로 제어
|
||||
*/
|
||||
export interface RowNumberingConfig {
|
||||
enabled: boolean; // 채번 사용 여부
|
||||
targetColumn: string; // 채번 결과를 저장할 컬럼 (예: "shipment_plan_no")
|
||||
|
||||
// 채번 규칙 설정 (옵션설정 > 코드설정에서 등록된 채번 규칙)
|
||||
numberingRuleId: string; // 채번 규칙 ID (numbering_rule 테이블)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.12: 연동 저장 설정
|
||||
* 테이블 데이터 저장 시 다른 테이블의 특정 컬럼에 집계 값을 동기화
|
||||
*/
|
||||
export interface SyncSaveConfig {
|
||||
id: string; // 고유 ID
|
||||
enabled: boolean; // 활성화 여부
|
||||
|
||||
// 소스 설정 (이 테이블에서)
|
||||
sourceColumn: string; // 집계할 컬럼 (예: "plan_qty")
|
||||
aggregationType: "sum" | "count" | "avg" | "min" | "max" | "latest"; // 집계 방식
|
||||
|
||||
// 대상 설정 (저장할 테이블)
|
||||
targetTable: string; // 대상 테이블 (예: "sales_order_mng")
|
||||
targetColumn: string; // 대상 컬럼 (예: "plan_ship_qty")
|
||||
|
||||
// 조인 키 (어떤 레코드를 업데이트할지)
|
||||
joinKey: {
|
||||
sourceField: string; // 이 테이블의 조인 키 (예: "sales_order_id")
|
||||
targetField: string; // 대상 테이블의 키 (예: "id")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3: 집계 표시 설정
|
||||
*/
|
||||
export interface AggregationDisplayConfig {
|
||||
// 값 소스 타입
|
||||
sourceType: "aggregation" | "formula" | "external" | "externalFormula";
|
||||
|
||||
// === sourceType: "aggregation" (기존 그룹핑 집계 결과 참조) ===
|
||||
aggregationResultField?: string; // 그룹핑 설정의 resultField 참조
|
||||
|
||||
// === sourceType: "formula" (컬럼 간 연산) ===
|
||||
formula?: string; // 연산식 (예: "{order_qty} - {ship_qty}")
|
||||
|
||||
// === sourceType: "external" (외부 테이블 조회) ===
|
||||
externalSource?: ExternalValueSource;
|
||||
|
||||
// === sourceType: "externalFormula" (외부 테이블 + 연산) ===
|
||||
externalSources?: ExternalValueSource[]; // 여러 외부 소스
|
||||
externalFormula?: string; // 외부 값들을 조합한 연산식 (예: "{inv_qty} + {prod_qty}")
|
||||
|
||||
// 표시 설정
|
||||
label: string; // 표시 라벨
|
||||
icon?: string; // 아이콘 (lucide 아이콘명)
|
||||
backgroundColor?: string; // 배경색
|
||||
textColor?: string; // 텍스트 색상
|
||||
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
|
||||
format?: "number" | "currency" | "percent"; // 숫자 포맷
|
||||
decimalPlaces?: number; // 소수점 자릿수
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.1: 외부 값 소스 설정
|
||||
*/
|
||||
export interface ExternalValueSource {
|
||||
alias: string; // 연산식에서 사용할 별칭 (예: "inv_qty")
|
||||
sourceTable: string; // 조회할 테이블
|
||||
sourceColumn: string; // 조회할 컬럼
|
||||
aggregationType?: "sum" | "count" | "avg" | "min" | "max" | "first"; // 집계 타입 (기본: first)
|
||||
|
||||
// 조인 설정 (다단계 조인 지원)
|
||||
joins: ChainedJoinConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.1: 다단계 조인 설정
|
||||
*/
|
||||
export interface ChainedJoinConfig {
|
||||
step: number; // 조인 순서 (1, 2, 3...)
|
||||
sourceTable: string; // 조인할 테이블
|
||||
joinConditions: {
|
||||
sourceKey: string; // 조인 테이블의 키
|
||||
referenceKey: string; // 참조 키 (이전 단계 결과 또는 카드 데이터)
|
||||
referenceFrom?: "card" | "previousStep"; // 참조 소스 (기본: card, step > 1이면 previousStep)
|
||||
}[];
|
||||
selectColumns?: string[]; // 이 단계에서 선택할 컬럼
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 설정
|
||||
* 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원
|
||||
* 🆕 v3.9: 연관 테이블 저장 기능 추가
|
||||
*/
|
||||
export interface AggregationConfig {
|
||||
// === 집계 소스 타입 ===
|
||||
sourceType: "column" | "formula"; // column: 테이블 컬럼 집계, formula: 연산식 (가상 집계)
|
||||
|
||||
// === sourceType: "column" (테이블 컬럼 집계) ===
|
||||
sourceTable?: string; // 집계할 테이블 (기본: dataSource.sourceTable, 외부 테이블도 가능)
|
||||
sourceField?: string; // 원본 필드 (예: "balance_qty")
|
||||
type?: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
|
||||
|
||||
// === sourceType: "formula" (가상 집계 - 연산식) ===
|
||||
// 연산식 문법:
|
||||
// - {resultField}: 다른 집계 결과 참조 (예: {total_balance})
|
||||
// - {테이블.컬럼}: 테이블의 컬럼 직접 참조 (예: {sales_order_mng.order_qty})
|
||||
// - SUM({컬럼}): 기본 테이블 행들의 합계
|
||||
// - SUM_EXT({컬럼}): 외부 테이블 행들의 합계 (externalTableData)
|
||||
// - 산술 연산: +, -, *, /, ()
|
||||
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
|
||||
|
||||
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
|
||||
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
|
||||
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
|
||||
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
|
||||
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
|
||||
|
||||
// === 공통 ===
|
||||
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
||||
label: string; // 표시 라벨 (예: "총수주잔량")
|
||||
|
||||
// === 🆕 v3.10: 숨김 설정 ===
|
||||
hidden?: boolean; // 레이아웃에서 숨김 (연산에만 사용, 기본: false)
|
||||
|
||||
// === 🆕 v3.9: 저장 설정 ===
|
||||
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3.9: 집계 결과 저장 설정
|
||||
* 집계된 값을 다른 테이블에 동기화
|
||||
*/
|
||||
export interface AggregationSaveConfig {
|
||||
enabled: boolean; // 저장 활성화 여부
|
||||
autoSave: boolean; // 자동 저장 (레이아웃에 없어도 저장)
|
||||
|
||||
// 저장 대상
|
||||
targetTable: string; // 저장할 테이블 (예: "sales_order_mng")
|
||||
targetColumn: string; // 저장할 컬럼 (예: "plan_qty_total")
|
||||
|
||||
// 조인 키 (어떤 레코드를 업데이트할지)
|
||||
joinKey: {
|
||||
sourceField: string; // 현재 카드의 조인 키 (예: "id" 또는 "sales_order_id")
|
||||
targetField: string; // 대상 테이블의 키 (예: "id")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated v3에서는 contentRows 사용 권장
|
||||
* 테이블 포함 레이아웃 설정
|
||||
*/
|
||||
export interface TableLayoutConfig {
|
||||
headerRows: CardRowConfig[];
|
||||
tableColumns: TableColumnConfig[];
|
||||
tableTitle?: string;
|
||||
showTableHeader?: boolean;
|
||||
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; // 필수 입력 여부
|
||||
|
||||
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
|
||||
hidden?: boolean; // 숨김 여부
|
||||
|
||||
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
|
||||
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
|
||||
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
|
||||
|
||||
// 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,178 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export interface SectionCardProps {
|
||||
component?: {
|
||||
id: string;
|
||||
componentConfig?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
showHeader?: boolean;
|
||||
headerPosition?: "top" | "left";
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
backgroundColor?: "default" | "muted" | "transparent";
|
||||
borderStyle?: "solid" | "dashed" | "none";
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
isSelected?: boolean;
|
||||
isDesignMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section Card 컴포넌트
|
||||
* 제목과 테두리가 있는 명확한 그룹화 컨테이너
|
||||
*/
|
||||
export function SectionCardComponent({
|
||||
component,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
isDesignMode = false,
|
||||
}: SectionCardProps) {
|
||||
const config = component?.componentConfig || {};
|
||||
const [isOpen, setIsOpen] = React.useState(config.defaultOpen !== false);
|
||||
|
||||
// 🔄 실시간 업데이트를 위해 config에서 직접 읽기
|
||||
const title = config.title || "";
|
||||
const description = config.description || "";
|
||||
const showHeader = config.showHeader !== false; // 기본값: true
|
||||
const padding = config.padding || "md";
|
||||
const backgroundColor = config.backgroundColor || "default";
|
||||
const borderStyle = config.borderStyle || "solid";
|
||||
const collapsible = config.collapsible || false;
|
||||
|
||||
// 🎯 디버깅: config 값 확인
|
||||
React.useEffect(() => {
|
||||
console.log("✅ Section Card Config:", {
|
||||
title,
|
||||
description,
|
||||
showHeader,
|
||||
fullConfig: config,
|
||||
});
|
||||
}, [config.title, config.description, config.showHeader]);
|
||||
|
||||
// 패딩 매핑
|
||||
const paddingMap = {
|
||||
none: "p-0",
|
||||
sm: "p-3",
|
||||
md: "p-6",
|
||||
lg: "p-8",
|
||||
};
|
||||
|
||||
// 배경색 매핑
|
||||
const backgroundColorMap = {
|
||||
default: "bg-card",
|
||||
muted: "bg-muted/30",
|
||||
transparent: "bg-transparent",
|
||||
};
|
||||
|
||||
// 테두리 스타일 매핑
|
||||
const borderStyleMap = {
|
||||
solid: "border-solid",
|
||||
dashed: "border-dashed",
|
||||
none: "border-none",
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (collapsible) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-all",
|
||||
backgroundColorMap[backgroundColor],
|
||||
borderStyleMap[borderStyle],
|
||||
borderStyle === "none" && "shadow-none",
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
|
||||
isDesignMode && !children && "min-h-[150px]",
|
||||
className
|
||||
)}
|
||||
style={component?.style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
{showHeader && (title || description || isDesignMode) && (
|
||||
<CardHeader
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
collapsible && "hover:bg-accent/50 transition-colors"
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{(title || isDesignMode) && (
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{title || (isDesignMode ? "섹션 제목" : "")}
|
||||
</CardTitle>
|
||||
)}
|
||||
{(description || isDesignMode) && (
|
||||
<CardDescription className="text-sm text-muted-foreground mt-1.5">
|
||||
{description || (isDesignMode ? "섹션 설명 (선택사항)" : "")}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{collapsible && (
|
||||
<div className={cn(
|
||||
"ml-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
{(!collapsible || isOpen) && (
|
||||
<CardContent className={cn(paddingMap[padding])}>
|
||||
{/* 디자인 모드에서 빈 상태 안내 */}
|
||||
{isDesignMode && !children && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<div className="mb-2">🃏 Section Card</div>
|
||||
<div className="text-xs">컴포넌트를 이곳에 배치하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface SectionCardConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
}
|
||||
|
||||
export function SectionCardConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
}: SectionCardConfigPanelProps) {
|
||||
const handleChange = (key: string, value: any) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
[key]: value,
|
||||
};
|
||||
onChange(newConfig);
|
||||
|
||||
// 🎯 실시간 업데이트를 위한 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: newConfig }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Card 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
제목과 테두리가 있는 명확한 그룹화 컨테이너
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={config.showHeader !== false}
|
||||
onCheckedChange={(checked) => handleChange("showHeader", checked)}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
{config.showHeader !== false && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">제목</Label>
|
||||
<Input
|
||||
value={config.title || ""}
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
placeholder="섹션 제목 입력"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설명 */}
|
||||
{config.showHeader !== false && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">설명 (선택)</Label>
|
||||
<Textarea
|
||||
value={config.description || ""}
|
||||
onChange={(e) => handleChange("description", e.target.value)}
|
||||
placeholder="섹션 설명 입력"
|
||||
className="text-xs resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 패딩 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">내부 여백</Label>
|
||||
<Select
|
||||
value={config.padding || "md"}
|
||||
onValueChange={(value) => handleChange("padding", value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게 (12px)</SelectItem>
|
||||
<SelectItem value="md">중간 (24px)</SelectItem>
|
||||
<SelectItem value="lg">크게 (32px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Select
|
||||
value={config.backgroundColor || "default"}
|
||||
onValueChange={(value) => handleChange("backgroundColor", value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">기본 (카드)</SelectItem>
|
||||
<SelectItem value="muted">회색</SelectItem>
|
||||
<SelectItem value="transparent">투명</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테두리 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">테두리 스타일</Label>
|
||||
<Select
|
||||
value={config.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 접기/펼치기 기능 */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="collapsible"
|
||||
checked={config.collapsible || false}
|
||||
onCheckedChange={(checked) => handleChange("collapsible", checked)}
|
||||
/>
|
||||
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
|
||||
접기/펼치기 가능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.collapsible && (
|
||||
<div className="flex items-center space-x-2 ml-6">
|
||||
<Checkbox
|
||||
id="defaultOpen"
|
||||
checked={config.defaultOpen !== false}
|
||||
onCheckedChange={(checked) => handleChange("defaultOpen", checked)}
|
||||
/>
|
||||
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
|
||||
기본으로 펼치기
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2SectionCardDefinition } from "./index";
|
||||
import { SectionCardComponent } from "./SectionCardComponent";
|
||||
|
||||
/**
|
||||
* Section Card 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SectionCardRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2SectionCardDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SectionCardComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SectionCardRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
SectionCardRenderer.enableHotReload();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { SectionCardComponent } from "./SectionCardComponent";
|
||||
import { SectionCardConfigPanel } from "./SectionCardConfigPanel";
|
||||
|
||||
/**
|
||||
* Section Card 컴포넌트 정의
|
||||
* 제목과 테두리가 있는 명확한 그룹화 컨테이너
|
||||
*/
|
||||
export const V2SectionCardDefinition = createComponentDefinition({
|
||||
id: "v2-section-card",
|
||||
name: "Section Card",
|
||||
nameEng: "Section Card",
|
||||
description: "제목과 테두리가 있는 명확한 그룹화 컨테이너",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "custom",
|
||||
component: SectionCardComponent,
|
||||
defaultConfig: {
|
||||
title: "섹션 제목",
|
||||
description: "",
|
||||
showHeader: true,
|
||||
padding: "md",
|
||||
backgroundColor: "default",
|
||||
borderStyle: "solid",
|
||||
collapsible: false,
|
||||
defaultOpen: true,
|
||||
},
|
||||
defaultSize: { width: 800, height: 250 },
|
||||
configPanel: SectionCardConfigPanel,
|
||||
icon: "LayoutPanelTop",
|
||||
tags: ["섹션", "그룹", "카드", "컨테이너", "제목", "card"],
|
||||
version: "1.0.0",
|
||||
author: "WACE",
|
||||
});
|
||||
|
||||
// 컴포넌트는 SectionCardRenderer에서 자동 등록됩니다
|
||||
|
||||
export { SectionCardComponent } from "./SectionCardComponent";
|
||||
export { SectionCardConfigPanel } from "./SectionCardConfigPanel";
|
||||
export { SectionCardRenderer } from "./SectionCardRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SectionPaperProps {
|
||||
component?: {
|
||||
id: string;
|
||||
componentConfig?: {
|
||||
backgroundColor?: "default" | "muted" | "accent" | "primary" | "custom";
|
||||
customColor?: string;
|
||||
showBorder?: boolean;
|
||||
borderStyle?: "none" | "subtle";
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
roundedCorners?: "none" | "sm" | "md" | "lg";
|
||||
shadow?: "none" | "sm" | "md";
|
||||
};
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
isSelected?: boolean;
|
||||
isDesignMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section Paper 컴포넌트
|
||||
* 배경색만 있는 미니멀한 그룹화 컨테이너 (색종이 컨셉)
|
||||
*/
|
||||
export function SectionPaperComponent({
|
||||
component,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
isDesignMode = false,
|
||||
}: SectionPaperProps) {
|
||||
const config = component?.componentConfig || {};
|
||||
|
||||
// 배경색 매핑
|
||||
const backgroundColorMap = {
|
||||
default: "bg-muted/40",
|
||||
muted: "bg-muted/50",
|
||||
accent: "bg-accent/30",
|
||||
primary: "bg-primary/10",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
// 패딩 매핑
|
||||
const paddingMap = {
|
||||
none: "p-0",
|
||||
sm: "p-3",
|
||||
md: "p-4",
|
||||
lg: "p-6",
|
||||
};
|
||||
|
||||
// 둥근 모서리 매핑
|
||||
const roundedMap = {
|
||||
none: "rounded-none",
|
||||
sm: "rounded-sm",
|
||||
md: "rounded-md",
|
||||
lg: "rounded-lg",
|
||||
};
|
||||
|
||||
// 그림자 매핑
|
||||
const shadowMap = {
|
||||
none: "",
|
||||
sm: "shadow-sm",
|
||||
md: "shadow-md",
|
||||
};
|
||||
|
||||
const backgroundColor = config.backgroundColor || "default";
|
||||
const padding = config.padding || "md";
|
||||
const rounded = config.roundedCorners || "md";
|
||||
const shadow = config.shadow || "none";
|
||||
const showBorder = config.showBorder !== undefined ? config.showBorder : true;
|
||||
const borderStyle = config.borderStyle || "subtle";
|
||||
|
||||
// 커스텀 배경색 처리
|
||||
const customBgStyle =
|
||||
backgroundColor === "custom" && config.customColor
|
||||
? { backgroundColor: config.customColor }
|
||||
: {};
|
||||
|
||||
// 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음)
|
||||
const selectionStyle = isDesignMode && isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// 기본 스타일
|
||||
"relative transition-colors",
|
||||
|
||||
// 높이 고정을 위한 overflow 처리
|
||||
"overflow-auto",
|
||||
|
||||
// 배경색
|
||||
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
|
||||
|
||||
// 패딩
|
||||
paddingMap[padding],
|
||||
|
||||
// 둥근 모서리
|
||||
roundedMap[rounded],
|
||||
|
||||
// 그림자
|
||||
shadowMap[shadow],
|
||||
|
||||
// 테두리 (선택 상태가 아닐 때만)
|
||||
!isSelected && showBorder &&
|
||||
borderStyle === "subtle" &&
|
||||
"border border-border/30",
|
||||
|
||||
// 디자인 모드에서 빈 상태 표시 (테두리만, 최소 높이 제거)
|
||||
isDesignMode && !children && "border-2 border-dashed border-muted-foreground/30",
|
||||
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
// 크기를 100%로 설정하여 부모 크기에 맞춤
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box", // padding과 border를 크기에 포함
|
||||
...customBgStyle,
|
||||
...selectionStyle,
|
||||
...component?.style, // 사용자 설정이 최종 우선순위
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children || (isDesignMode && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<div className="mb-1">📄 Section Paper</div>
|
||||
<div className="text-xs">컴포넌트를 이곳에 배치하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface SectionPaperConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
}
|
||||
|
||||
export function SectionPaperConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
}: SectionPaperConfigPanelProps) {
|
||||
const handleChange = (key: string, value: any) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
[key]: value,
|
||||
};
|
||||
onChange(newConfig);
|
||||
|
||||
// 🎯 실시간 업데이트를 위한 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: newConfig }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Paper 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
배경색 기반의 미니멀한 그룹화 컨테이너
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Select
|
||||
value={config.backgroundColor || "default"}
|
||||
onValueChange={(value) => handleChange("backgroundColor", value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">기본 (연한 회색)</SelectItem>
|
||||
<SelectItem value="muted">회색</SelectItem>
|
||||
<SelectItem value="accent">강조 (연한 파랑)</SelectItem>
|
||||
<SelectItem value="primary">브랜드 컬러</SelectItem>
|
||||
<SelectItem value="custom">커스텀</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 커스텀 색상 */}
|
||||
{config.backgroundColor === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">커스텀 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config.customColor || "#f0f0f0"}
|
||||
onChange={(e) => handleChange("customColor", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 패딩 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">내부 여백</Label>
|
||||
<Select
|
||||
value={config.padding || "md"}
|
||||
onValueChange={(value) => handleChange("padding", value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게 (12px)</SelectItem>
|
||||
<SelectItem value="md">중간 (16px)</SelectItem>
|
||||
<SelectItem value="lg">크게 (24px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 둥근 모서리 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">둥근 모서리</Label>
|
||||
<Select
|
||||
value={config.roundedCorners || "md"}
|
||||
onValueChange={(value) => handleChange("roundedCorners", value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게 (2px)</SelectItem>
|
||||
<SelectItem value="md">중간 (6px)</SelectItem>
|
||||
<SelectItem value="lg">크게 (8px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 그림자 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">그림자</Label>
|
||||
<Select
|
||||
value={config.shadow || "none"}
|
||||
onValueChange={(value) => handleChange("shadow", value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게</SelectItem>
|
||||
<SelectItem value="md">중간</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showBorder"
|
||||
checked={config.showBorder || false}
|
||||
onCheckedChange={(checked) => handleChange("showBorder", checked)}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
미묘한 테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2SectionPaperDefinition } from "./index";
|
||||
import { SectionPaperComponent } from "./SectionPaperComponent";
|
||||
|
||||
/**
|
||||
* Section Paper 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SectionPaperRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2SectionPaperDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SectionPaperComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SectionPaperRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
SectionPaperRenderer.enableHotReload();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { SectionPaperComponent } from "./SectionPaperComponent";
|
||||
import { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
|
||||
|
||||
/**
|
||||
* Section Paper 컴포넌트 정의
|
||||
* 배경색 기반의 미니멀한 그룹화 컨테이너 (색종이 컨셉)
|
||||
*/
|
||||
export const V2SectionPaperDefinition = createComponentDefinition({
|
||||
id: "v2-section-paper",
|
||||
name: "Section Paper",
|
||||
nameEng: "Section Paper",
|
||||
description: "배경색 기반의 미니멀한 그룹화 컨테이너 (색종이 컨셉)",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "custom",
|
||||
component: SectionPaperComponent,
|
||||
defaultConfig: {
|
||||
backgroundColor: "default",
|
||||
padding: "md",
|
||||
roundedCorners: "md",
|
||||
shadow: "none",
|
||||
showBorder: false,
|
||||
},
|
||||
defaultSize: { width: 800, height: 200 },
|
||||
configPanel: SectionPaperConfigPanel,
|
||||
icon: "Square",
|
||||
tags: ["섹션", "그룹", "배경", "컨테이너", "색종이", "paper"],
|
||||
version: "1.0.0",
|
||||
author: "WACE",
|
||||
});
|
||||
|
||||
// 컴포넌트는 SectionPaperRenderer에서 자동 등록됩니다
|
||||
|
||||
export { SectionPaperComponent } from "./SectionPaperComponent";
|
||||
export { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
|
||||
export { SectionPaperRenderer } from "./SectionPaperRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# SplitPanelLayout 컴포넌트
|
||||
|
||||
마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트입니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 🔄 **마스터-디테일 패턴**: 좌측에서 항목 선택 시 우측에 상세 정보 표시
|
||||
- 📏 **크기 조절 가능**: 드래그하여 좌우 패널 크기 조정
|
||||
- 🔍 **검색 기능**: 각 패널에 독립적인 검색 기능
|
||||
- 🔗 **관계 설정**: JOIN, DETAIL, CUSTOM 관계 타입 지원
|
||||
- ⚙️ **유연한 설정**: 다양한 옵션으로 커스터마이징 가능
|
||||
|
||||
## 사용 사례
|
||||
|
||||
### 1. 코드 관리
|
||||
|
||||
- 좌측: 코드 카테고리 목록
|
||||
- 우측: 선택된 카테고리의 코드 목록
|
||||
|
||||
### 2. 테이블 조인 설정
|
||||
|
||||
- 좌측: 기본 테이블 목록
|
||||
- 우측: 선택된 테이블의 조인 조건 설정
|
||||
|
||||
### 3. 메뉴 관리
|
||||
|
||||
- 좌측: 메뉴 트리 구조
|
||||
- 우측: 선택된 메뉴의 상세 설정
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### 좌측 패널 (leftPanel)
|
||||
|
||||
- `title`: 패널 제목
|
||||
- `tableName`: 데이터베이스 테이블명
|
||||
- `showSearch`: 검색 기능 표시 여부
|
||||
- `showAdd`: 추가 버튼 표시 여부
|
||||
|
||||
### 우측 패널 (rightPanel)
|
||||
|
||||
- `title`: 패널 제목
|
||||
- `tableName`: 데이터베이스 테이블명
|
||||
- `showSearch`: 검색 기능 표시 여부
|
||||
- `showAdd`: 추가 버튼 표시 여부
|
||||
- `relation`: 좌측 항목과의 관계 설정
|
||||
- `type`: "join" | "detail" | "custom"
|
||||
- `foreignKey`: 외래키 컬럼명
|
||||
|
||||
### 레이아웃 설정
|
||||
|
||||
- `splitRatio`: 좌측 패널 너비 비율 (0-100, 기본 30)
|
||||
- `resizable`: 크기 조절 가능 여부 (기본 true)
|
||||
- `minLeftWidth`: 좌측 최소 너비 (기본 200px)
|
||||
- `minRightWidth`: 우측 최소 너비 (기본 300px)
|
||||
- `autoLoad`: 자동 데이터 로드 (기본 true)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
const config: SplitPanelLayoutConfig = {
|
||||
leftPanel: {
|
||||
title: "코드 카테고리",
|
||||
tableName: "code_category",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "코드 목록",
|
||||
tableName: "code_info",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "category_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
resizable: true,
|
||||
};
|
||||
```
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context 타입 정의
|
||||
* 분할 패널의 드래그 리사이즈 상태를 외부 컴포넌트(버튼 등)와 공유하기 위한 Context
|
||||
*
|
||||
* 주의: contexts/SplitPanelContext.tsx는 데이터 전달용 Context이고,
|
||||
* 이 Context는 드래그 리사이즈 시 버튼 위치 조정을 위한 별도 Context입니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 분할 패널 정보 (컴포넌트 좌표 기준)
|
||||
*/
|
||||
export interface SplitPanelInfo {
|
||||
id: string;
|
||||
// 분할 패널의 좌표 (스크린 캔버스 기준, px)
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// 좌측 패널 비율 (0-100)
|
||||
leftWidthPercent: number;
|
||||
// 초기 좌측 패널 비율 (드래그 시작 시점)
|
||||
initialLeftWidthPercent: number;
|
||||
// 드래그 중 여부
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export interface SplitPanelResizeContextValue {
|
||||
// 등록된 분할 패널들
|
||||
splitPanels: Map<string, SplitPanelInfo>;
|
||||
|
||||
// 분할 패널 등록/해제/업데이트
|
||||
registerSplitPanel: (id: string, info: Omit<SplitPanelInfo, "id">) => void;
|
||||
unregisterSplitPanel: (id: string) => void;
|
||||
updateSplitPanel: (id: string, updates: Partial<SplitPanelInfo>) => void;
|
||||
|
||||
// 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||
// 반환값: { panelId, offsetX } 또는 null
|
||||
getOverlappingSplitPanel: (
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null;
|
||||
|
||||
// 컴포넌트의 조정된 X 좌표 계산
|
||||
// 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||
getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number;
|
||||
|
||||
// 레거시 호환 (단일 분할 패널용)
|
||||
leftWidthPercent: number;
|
||||
containerRect: DOMRect | null;
|
||||
dividerX: number;
|
||||
isDragging: boolean;
|
||||
splitPanelId: string | null;
|
||||
updateLeftWidth: (percent: number) => void;
|
||||
updateContainerRect: (rect: DOMRect | null) => void;
|
||||
updateDragging: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
// Context 생성
|
||||
const SplitPanelResizeContext = createContext<SplitPanelResizeContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context Provider
|
||||
* 스크린 빌더 레벨에서 감싸서 사용
|
||||
*/
|
||||
export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// 등록된 분할 패널들
|
||||
const splitPanelsRef = useRef<Map<string, SplitPanelInfo>>(new Map());
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// 레거시 호환용 상태
|
||||
const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30);
|
||||
const [legacyContainerRect, setLegacyContainerRect] = useState<DOMRect | null>(null);
|
||||
const [legacyIsDragging, setLegacyIsDragging] = useState(false);
|
||||
const [legacySplitPanelId, setLegacySplitPanelId] = useState<string | null>(null);
|
||||
|
||||
// 분할 패널 등록
|
||||
const registerSplitPanel = useCallback((id: string, info: Omit<SplitPanelInfo, "id">) => {
|
||||
splitPanelsRef.current.set(id, { id, ...info });
|
||||
setLegacySplitPanelId(id);
|
||||
setLegacyLeftWidthPercent(info.leftWidthPercent);
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
// 분할 패널 해제
|
||||
const unregisterSplitPanel = useCallback(
|
||||
(id: string) => {
|
||||
splitPanelsRef.current.delete(id);
|
||||
if (legacySplitPanelId === id) {
|
||||
setLegacySplitPanelId(null);
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[legacySplitPanelId],
|
||||
);
|
||||
|
||||
// 분할 패널 업데이트
|
||||
const updateSplitPanel = useCallback((id: string, updates: Partial<SplitPanelInfo>) => {
|
||||
const panel = splitPanelsRef.current.get(id);
|
||||
if (panel) {
|
||||
const updatedPanel = { ...panel, ...updates };
|
||||
splitPanelsRef.current.set(id, updatedPanel);
|
||||
|
||||
// 레거시 호환 상태 업데이트
|
||||
if (updates.leftWidthPercent !== undefined) {
|
||||
setLegacyLeftWidthPercent(updates.leftWidthPercent);
|
||||
}
|
||||
if (updates.isDragging !== undefined) {
|
||||
setLegacyIsDragging(updates.isDragging);
|
||||
}
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||
*/
|
||||
const getOverlappingSplitPanel = useCallback(
|
||||
(
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => {
|
||||
for (const [panelId, panel] of splitPanelsRef.current) {
|
||||
// 컴포넌트의 중심점
|
||||
const componentCenterX = componentX + componentWidth / 2;
|
||||
const componentCenterY = componentY + componentHeight / 2;
|
||||
|
||||
// 컴포넌트가 분할 패널 영역 내에 있는지 확인
|
||||
const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width;
|
||||
const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height;
|
||||
|
||||
if (isInPanelX && isInPanelY) {
|
||||
// 좌측 패널의 현재 너비 (px)
|
||||
const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
// 좌측 패널 경계 (분할 패널 기준 상대 좌표)
|
||||
const dividerX = panel.x + leftPanelWidth;
|
||||
|
||||
// 컴포넌트 중심이 좌측 패널 내에 있는지 확인
|
||||
const isInLeftPanel = componentCenterX < dividerX;
|
||||
|
||||
return { panelId, panel, isInLeftPanel };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 컴포넌트의 조정된 X 좌표 계산
|
||||
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||
*
|
||||
* 핵심 로직:
|
||||
* - 버튼의 원래 X 좌표가 초기 좌측 패널 너비 내에서 어느 비율에 있는지 계산
|
||||
* - 드래그로 좌측 패널 너비가 바뀌면, 같은 비율을 유지하도록 X 좌표 조정
|
||||
*/
|
||||
const getAdjustedX = useCallback(
|
||||
(componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => {
|
||||
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
if (!overlap || !overlap.isInLeftPanel) {
|
||||
// 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지
|
||||
return componentX;
|
||||
}
|
||||
|
||||
const { panel } = overlap;
|
||||
|
||||
// 초기 좌측 패널 너비 (설정된 splitRatio 기준)
|
||||
const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100;
|
||||
// 현재 좌측 패널 너비 (드래그로 변경된 값)
|
||||
const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
|
||||
// 변화가 없으면 원래 위치 반환
|
||||
if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) {
|
||||
return componentX;
|
||||
}
|
||||
|
||||
// 컴포넌트의 분할 패널 내 상대 X 좌표
|
||||
const relativeX = componentX - panel.x;
|
||||
|
||||
// 좌측 패널 내에서의 비율 (0~1)
|
||||
const ratioInLeftPanel = relativeX / initialLeftPanelWidth;
|
||||
|
||||
// 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비
|
||||
const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth;
|
||||
|
||||
// 절대 X 좌표로 변환
|
||||
const adjustedX = panel.x + adjustedRelativeX;
|
||||
|
||||
console.log("📍 [SplitPanel] 버튼 위치 조정:", {
|
||||
componentX,
|
||||
panelX: panel.x,
|
||||
relativeX,
|
||||
initialLeftPanelWidth,
|
||||
currentLeftPanelWidth,
|
||||
ratioInLeftPanel,
|
||||
adjustedX,
|
||||
delta: adjustedX - componentX,
|
||||
});
|
||||
|
||||
return adjustedX;
|
||||
},
|
||||
[getOverlappingSplitPanel],
|
||||
);
|
||||
|
||||
// 레거시 호환 - dividerX 계산
|
||||
const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0;
|
||||
|
||||
// 레거시 호환 함수들
|
||||
const updateLeftWidth = useCallback((percent: number) => {
|
||||
setLegacyLeftWidthPercent(percent);
|
||||
// 첫 번째 분할 패널 업데이트
|
||||
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||
if (firstPanelId) {
|
||||
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||
if (panel) {
|
||||
splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent });
|
||||
}
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const updateContainerRect = useCallback((rect: DOMRect | null) => {
|
||||
setLegacyContainerRect(rect);
|
||||
}, []);
|
||||
|
||||
const updateDragging = useCallback((dragging: boolean) => {
|
||||
setLegacyIsDragging(dragging);
|
||||
// 첫 번째 분할 패널 업데이트
|
||||
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||
if (firstPanelId) {
|
||||
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||
if (panel) {
|
||||
// 드래그 시작 시 초기 비율 저장
|
||||
const updates: Partial<SplitPanelInfo> = { isDragging: dragging };
|
||||
if (dragging) {
|
||||
updates.initialLeftWidthPercent = panel.leftWidthPercent;
|
||||
}
|
||||
splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates });
|
||||
}
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SplitPanelResizeContextValue>(
|
||||
() => ({
|
||||
splitPanels: splitPanelsRef.current,
|
||||
registerSplitPanel,
|
||||
unregisterSplitPanel,
|
||||
updateSplitPanel,
|
||||
getOverlappingSplitPanel,
|
||||
getAdjustedX,
|
||||
// 레거시 호환
|
||||
leftWidthPercent: legacyLeftWidthPercent,
|
||||
containerRect: legacyContainerRect,
|
||||
dividerX: legacyDividerX,
|
||||
isDragging: legacyIsDragging,
|
||||
splitPanelId: legacySplitPanelId,
|
||||
updateLeftWidth,
|
||||
updateContainerRect,
|
||||
updateDragging,
|
||||
}),
|
||||
[
|
||||
registerSplitPanel,
|
||||
unregisterSplitPanel,
|
||||
updateSplitPanel,
|
||||
getOverlappingSplitPanel,
|
||||
getAdjustedX,
|
||||
legacyLeftWidthPercent,
|
||||
legacyContainerRect,
|
||||
legacyDividerX,
|
||||
legacyIsDragging,
|
||||
legacySplitPanelId,
|
||||
updateLeftWidth,
|
||||
updateContainerRect,
|
||||
updateDragging,
|
||||
],
|
||||
);
|
||||
|
||||
return <SplitPanelResizeContext.Provider value={value}>{children}</SplitPanelResizeContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context 사용 훅
|
||||
* 분할 패널의 드래그 리사이즈 상태를 구독합니다.
|
||||
*/
|
||||
export const useSplitPanel = (): SplitPanelResizeContextValue => {
|
||||
const context = useContext(SplitPanelResizeContext);
|
||||
|
||||
// Context가 없으면 기본값 반환 (Provider 외부에서 사용 시)
|
||||
if (!context) {
|
||||
return {
|
||||
splitPanels: new Map(),
|
||||
registerSplitPanel: () => {},
|
||||
unregisterSplitPanel: () => {},
|
||||
updateSplitPanel: () => {},
|
||||
getOverlappingSplitPanel: () => null,
|
||||
getAdjustedX: (x) => x,
|
||||
leftWidthPercent: 30,
|
||||
containerRect: null,
|
||||
dividerX: 0,
|
||||
isDragging: false,
|
||||
splitPanelId: null,
|
||||
updateLeftWidth: () => {},
|
||||
updateContainerRect: () => {},
|
||||
updateDragging: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 조정된 위치를 계산하는 훅
|
||||
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 X 좌표가 조정됨
|
||||
*
|
||||
* @param componentX - 컴포넌트의 X 좌표 (px)
|
||||
* @param componentY - 컴포넌트의 Y 좌표 (px)
|
||||
* @param componentWidth - 컴포넌트 너비 (px)
|
||||
* @param componentHeight - 컴포넌트 높이 (px)
|
||||
* @returns 조정된 X 좌표와 관련 정보
|
||||
*/
|
||||
export const useAdjustedComponentPosition = (
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
) => {
|
||||
const context = useSplitPanel();
|
||||
|
||||
const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight);
|
||||
const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
return {
|
||||
adjustedX,
|
||||
isInSplitPanel: !!overlap,
|
||||
isInLeftPanel: overlap?.isInLeftPanel ?? false,
|
||||
isDragging: overlap?.panel.isDragging ?? false,
|
||||
panelId: overlap?.panelId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼 등 외부 컴포넌트에서 분할 패널 좌측 영역 내 위치를 계산하는 훅 (레거시 호환)
|
||||
*/
|
||||
export const useAdjustedPosition = (originalXPercent: number) => {
|
||||
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||
|
||||
const isInLeftPanel = originalXPercent <= leftWidthPercent;
|
||||
const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent;
|
||||
const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0;
|
||||
|
||||
return {
|
||||
adjustedXPercent,
|
||||
adjustedXPx,
|
||||
isInLeftPanel,
|
||||
isDragging,
|
||||
dividerX,
|
||||
containerRect,
|
||||
leftWidthPercent,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼이 좌측 패널 위에 배치되었을 때, 드래그에 따라 위치가 조정되는 스타일을 반환하는 훅 (레거시 호환)
|
||||
*/
|
||||
export const useSplitPanelAwarePosition = (
|
||||
initialLeftPercent: number,
|
||||
options?: {
|
||||
followDivider?: boolean;
|
||||
offset?: number;
|
||||
},
|
||||
) => {
|
||||
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||
const { followDivider = false, offset = 0 } = options || {};
|
||||
|
||||
if (followDivider) {
|
||||
return {
|
||||
left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`,
|
||||
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||
};
|
||||
}
|
||||
|
||||
const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent;
|
||||
|
||||
return {
|
||||
left: `${adjustedLeft}%`,
|
||||
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||
};
|
||||
};
|
||||
|
||||
export default SplitPanelResizeContext;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2SplitPanelLayoutDefinition } from "./index";
|
||||
import { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SplitPanelLayoutRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2SplitPanelLayoutDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SplitPanelLayoutComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// 좌측 패널 데이터 로드
|
||||
protected async loadLeftPanelData() {
|
||||
// 좌측 패널 데이터 로드 로직
|
||||
}
|
||||
|
||||
// 우측 패널 데이터 로드 (선택된 항목 기반)
|
||||
protected async loadRightPanelData(selectedItem: any) {
|
||||
// 우측 패널 데이터 로드 로직
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SplitPanelLayoutRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
SplitPanelLayoutRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* SplitPanelLayout 컴포넌트 설정
|
||||
*/
|
||||
|
||||
export const splitPanelLayoutConfig = {
|
||||
// 기본 스타일
|
||||
defaultStyle: {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
|
||||
// 프리셋 설정들
|
||||
presets: {
|
||||
codeManagement: {
|
||||
name: "코드 관리",
|
||||
leftPanel: {
|
||||
title: "코드 카테고리",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "코드 목록",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "category_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
},
|
||||
tableJoin: {
|
||||
name: "테이블 조인",
|
||||
leftPanel: {
|
||||
title: "기본 테이블",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "조인 조건",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "join",
|
||||
},
|
||||
},
|
||||
splitRatio: 35,
|
||||
},
|
||||
menuSettings: {
|
||||
name: "메뉴 설정",
|
||||
leftPanel: {
|
||||
title: "메뉴 트리",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "메뉴 상세",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "menu_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 25,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { SplitPanelLayoutWrapper } from "./SplitPanelLayoutComponent";
|
||||
import { SplitPanelLayoutConfigPanel } from "./SplitPanelLayoutConfigPanel";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트 정의
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
*/
|
||||
export const V2SplitPanelLayoutDefinition = createComponentDefinition({
|
||||
id: "v2-split-panel-layout",
|
||||
name: "분할 패널",
|
||||
nameEng: "SplitPanelLayout Component",
|
||||
description: "마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: SplitPanelLayoutWrapper,
|
||||
defaultConfig: {
|
||||
leftPanel: {
|
||||
title: "마스터",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "디테일",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "parent_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
resizable: true,
|
||||
minLeftWidth: 200,
|
||||
minRightWidth: 300,
|
||||
autoLoad: true,
|
||||
syncSelection: true,
|
||||
} as SplitPanelLayoutConfig,
|
||||
defaultSize: { width: 800, height: 600 },
|
||||
configPanel: SplitPanelLayoutConfigPanel,
|
||||
icon: "PanelLeftRight",
|
||||
tags: ["분할", "마스터", "디테일", "레이아웃"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/split-panel-layout",
|
||||
});
|
||||
|
||||
// 컴포넌트는 SplitPanelLayoutRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { SplitPanelLayoutConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
|
||||
|
||||
// Resize Context 내보내기 (버튼 등 외부 컴포넌트에서 분할 패널 드래그 리사이즈 상태 활용)
|
||||
export {
|
||||
SplitPanelProvider,
|
||||
useSplitPanel,
|
||||
useAdjustedPosition,
|
||||
useSplitPanelAwarePosition,
|
||||
useAdjustedComponentPosition,
|
||||
} from "./SplitPanelContext";
|
||||
export type { SplitPanelResizeContextValue, SplitPanelInfo } from "./SplitPanelContext";
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* SplitPanelLayout 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
import { DataFilterConfig } from "@/types/screen-management";
|
||||
|
||||
/**
|
||||
* 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label)
|
||||
*/
|
||||
export interface AdditionalTabConfig {
|
||||
// 탭 고유 정보
|
||||
tabId: string;
|
||||
label: string;
|
||||
langKeyId?: number; // 탭 라벨 다국어 키 ID
|
||||
langKey?: string; // 탭 라벨 다국어 키
|
||||
|
||||
// === 우측 패널과 동일한 설정 ===
|
||||
title: string;
|
||||
titleLangKeyId?: number; // 탭 제목 다국어 키 ID
|
||||
titleLangKey?: string; // 탭 제목 다국어 키
|
||||
panelHeaderHeight?: number;
|
||||
tableName?: string;
|
||||
dataSource?: string;
|
||||
displayMode?: "list" | "table";
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
showEdit?: boolean;
|
||||
showDelete?: boolean;
|
||||
summaryColumnCount?: number;
|
||||
summaryShowLabel?: boolean;
|
||||
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
bold?: boolean;
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text";
|
||||
thousandSeparator?: boolean;
|
||||
decimalPlaces?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
dateFormat?: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
addModalColumns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
|
||||
relation?: {
|
||||
type?: "join" | "detail";
|
||||
leftColumn?: string;
|
||||
rightColumn?: string;
|
||||
foreignKey?: string;
|
||||
keys?: Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
addConfig?: {
|
||||
targetTable?: string;
|
||||
autoFillColumns?: Record<string, any>;
|
||||
leftPanelColumn?: string;
|
||||
targetColumn?: string;
|
||||
};
|
||||
|
||||
tableConfig?: {
|
||||
showCheckbox?: boolean;
|
||||
showRowNumber?: boolean;
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
striped?: boolean;
|
||||
bordered?: boolean;
|
||||
hoverable?: boolean;
|
||||
stickyHeader?: boolean;
|
||||
};
|
||||
|
||||
dataFilter?: DataFilterConfig;
|
||||
|
||||
deduplication?: {
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
};
|
||||
|
||||
editButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
buttonLabel?: string;
|
||||
buttonVariant?: "default" | "outline" | "ghost";
|
||||
groupByColumns?: string[];
|
||||
};
|
||||
|
||||
deleteButton?: {
|
||||
enabled: boolean;
|
||||
buttonLabel?: string;
|
||||
buttonVariant?: "default" | "outline" | "ghost" | "destructive";
|
||||
confirmMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SplitPanelLayoutConfig {
|
||||
// 좌측 패널 설정
|
||||
leftPanel: {
|
||||
title: string;
|
||||
langKeyId?: number; // 다국어 키 ID
|
||||
langKey?: string; // 다국어 키
|
||||
panelHeaderHeight?: number; // 패널 상단 헤더 높이 (px)
|
||||
tableName?: string; // 데이터베이스 테이블명
|
||||
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
|
||||
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
||||
dataSource?: string; // API 엔드포인트
|
||||
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
showEdit?: boolean; // 수정 버튼
|
||||
showDelete?: boolean; // 삭제 버튼
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
||||
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
||||
decimalPlaces?: number; // 소수점 자릿수
|
||||
prefix?: string; // 접두사 (예: "₩", "$")
|
||||
suffix?: string; // 접미사 (예: "원", "개")
|
||||
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||
};
|
||||
}>;
|
||||
// 추가 모달에서 입력받을 컬럼 설정
|
||||
addModalColumns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
// 각 항목에 + 버튼 표시 (하위 항목 추가)
|
||||
showItemAddButton?: boolean;
|
||||
// + 버튼 클릭 시 하위 항목 추가를 위한 설정
|
||||
itemAddConfig?: {
|
||||
// 하위 항목 추가 모달에서 입력받을 컬럼
|
||||
addModalColumns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
// 상위 항목의 ID를 저장할 컬럼 (예: parent_dept_code)
|
||||
parentColumn: string;
|
||||
// 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code)
|
||||
sourceColumn: string;
|
||||
};
|
||||
// 테이블 모드 설정
|
||||
tableConfig?: {
|
||||
showCheckbox?: boolean; // 체크박스 표시 여부
|
||||
showRowNumber?: boolean; // 행 번호 표시 여부
|
||||
rowHeight?: number; // 행 높이
|
||||
headerHeight?: number; // 헤더 높이
|
||||
striped?: boolean; // 줄무늬 배경
|
||||
bordered?: boolean; // 테두리 표시
|
||||
hoverable?: boolean; // 호버 효과
|
||||
stickyHeader?: boolean; // 헤더 고정
|
||||
};
|
||||
|
||||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
dataFilter?: DataFilterConfig;
|
||||
};
|
||||
|
||||
// 우측 패널 설정
|
||||
rightPanel: {
|
||||
title: string;
|
||||
langKeyId?: number; // 다국어 키 ID
|
||||
langKey?: string; // 다국어 키
|
||||
panelHeaderHeight?: number; // 패널 상단 헤더 높이 (px)
|
||||
tableName?: string;
|
||||
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
|
||||
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
||||
dataSource?: string;
|
||||
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
showEdit?: boolean; // 수정 버튼
|
||||
showDelete?: boolean; // 삭제 버튼
|
||||
summaryColumnCount?: number; // 요약에서 표시할 컬럼 개수 (기본: 3)
|
||||
summaryShowLabel?: boolean; // 요약에서 라벨 표시 여부 (기본: true)
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
||||
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
||||
decimalPlaces?: number; // 소수점 자릿수
|
||||
prefix?: string; // 접두사 (예: "₩", "$")
|
||||
suffix?: string; // 접미사 (예: "원", "개")
|
||||
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||
};
|
||||
}>;
|
||||
// 추가 모달에서 입력받을 컬럼 설정
|
||||
addModalColumns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
|
||||
// 좌측 선택 항목과의 관계 설정
|
||||
relation?: {
|
||||
type?: "join" | "detail"; // 관계 타입 (optional - 하위 호환성)
|
||||
leftColumn?: string; // 좌측 테이블의 연결 컬럼 (단일키 - 하위 호환성)
|
||||
rightColumn?: string; // 우측 테이블의 연결 컬럼 (단일키 - 하위 호환성)
|
||||
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
||||
// 🆕 복합키 지원 (여러 컬럼으로 조인)
|
||||
keys?: Array<{
|
||||
leftColumn: string; // 좌측 테이블의 조인 컬럼
|
||||
rightColumn: string; // 우측 테이블의 조인 컬럼
|
||||
}>;
|
||||
};
|
||||
|
||||
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
|
||||
addConfig?: {
|
||||
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
|
||||
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
|
||||
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
|
||||
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
|
||||
};
|
||||
|
||||
// 테이블 모드 설정
|
||||
tableConfig?: {
|
||||
showCheckbox?: boolean; // 체크박스 표시 여부
|
||||
showRowNumber?: boolean; // 행 번호 표시 여부
|
||||
rowHeight?: number; // 행 높이
|
||||
headerHeight?: number; // 헤더 높이
|
||||
striped?: boolean; // 줄무늬 배경
|
||||
bordered?: boolean; // 테두리 표시
|
||||
hoverable?: boolean; // 호버 효과
|
||||
stickyHeader?: boolean; // 헤더 고정
|
||||
};
|
||||
|
||||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
dataFilter?: DataFilterConfig;
|
||||
|
||||
// 🆕 중복 제거 설정
|
||||
deduplication?: {
|
||||
enabled: boolean; // 중복 제거 활성화
|
||||
groupByColumn: string; // 중복 제거 기준 컬럼 (예: "item_id")
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; // 어떤 행을 유지할지
|
||||
sortColumn?: string; // keepStrategy가 latest/earliest일 때 정렬 기준 컬럼
|
||||
};
|
||||
|
||||
// 🆕 수정 버튼 설정
|
||||
editButton?: {
|
||||
enabled: boolean; // 수정 버튼 표시 여부 (기본: true)
|
||||
mode: "auto" | "modal"; // auto: 자동 편집 (인라인), modal: 커스텀 모달
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "수정")
|
||||
buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline")
|
||||
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
|
||||
};
|
||||
|
||||
// 🆕 삭제 버튼 설정
|
||||
deleteButton?: {
|
||||
enabled: boolean; // 삭제 버튼 표시 여부 (기본: true)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "삭제")
|
||||
buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost")
|
||||
confirmMessage?: string; // 삭제 확인 메시지
|
||||
};
|
||||
|
||||
// 🆕 추가 탭 설정 (멀티 테이블 탭)
|
||||
additionalTabs?: AdditionalTabConfig[];
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
splitRatio?: number; // 좌우 비율 (0-100, 기본 30)
|
||||
resizable?: boolean; // 크기 조절 가능 여부
|
||||
minLeftWidth?: number; // 좌측 최소 너비 (px)
|
||||
minRightWidth?: number; // 우측 최소 너비 (px)
|
||||
|
||||
// 동작 설정
|
||||
autoLoad?: boolean; // 자동 데이터 로드
|
||||
syncSelection?: boolean; // 선택 항목 동기화
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
|
||||
import { CardDisplayConfig, ColumnConfig } from "./types";
|
||||
|
||||
interface CardModeRendererProps {
|
||||
data: Record<string, any>[];
|
||||
cardConfig: CardDisplayConfig;
|
||||
visibleColumns: ColumnConfig[];
|
||||
onRowClick?: (row: Record<string, any>, index: number, e: React.MouseEvent) => void;
|
||||
onRowSelect?: (row: Record<string, any>, selected: boolean) => void;
|
||||
selectedRows?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 모드 렌더러
|
||||
* 테이블 데이터를 카드 형태로 표시
|
||||
*/
|
||||
export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
||||
data,
|
||||
cardConfig,
|
||||
visibleColumns,
|
||||
onRowClick,
|
||||
selectedRows = [],
|
||||
}) => {
|
||||
// 기본값과 병합
|
||||
const config = {
|
||||
idColumn: cardConfig?.idColumn || "",
|
||||
titleColumn: cardConfig?.titleColumn || "",
|
||||
subtitleColumn: cardConfig?.subtitleColumn,
|
||||
descriptionColumn: cardConfig?.descriptionColumn,
|
||||
imageColumn: cardConfig?.imageColumn,
|
||||
cardsPerRow: cardConfig?.cardsPerRow ?? 3,
|
||||
cardSpacing: cardConfig?.cardSpacing ?? 16,
|
||||
showActions: cardConfig?.showActions ?? true,
|
||||
cardHeight: cardConfig?.cardHeight as number | "auto" | undefined,
|
||||
};
|
||||
|
||||
// 디버깅: cardConfig 확인
|
||||
console.log("🃏 CardModeRenderer config:", { cardConfig, mergedConfig: config });
|
||||
|
||||
// 카드 그리드 스타일 계산
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${config.cardsPerRow}, 1fr)`,
|
||||
gap: `${config.cardSpacing}px`,
|
||||
padding: `${config.cardSpacing}px`,
|
||||
overflow: "auto",
|
||||
};
|
||||
|
||||
// 카드 높이 스타일
|
||||
const cardStyle: React.CSSProperties = {
|
||||
height: config.cardHeight === "auto" ? "auto" : `${config.cardHeight}px`,
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
};
|
||||
|
||||
// 컬럼 값 가져오기 함수
|
||||
const getColumnValue = (row: Record<string, any>, columnName?: string): string => {
|
||||
if (!columnName || !row) return "";
|
||||
return String(row[columnName] || "");
|
||||
};
|
||||
|
||||
// 액션 버튼 렌더링
|
||||
const renderActions = (_row: Record<string, any>) => {
|
||||
if (!config.showActions) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-end space-x-1 border-t border-gray-100 pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 상세보기 액션
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 편집 액션
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 삭제 액션
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 더보기 액션
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 데이터가 없는 경우
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
|
||||
<div className="bg-muted-foreground/20 h-8 w-8 rounded-lg"></div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-sm font-medium">표시할 데이터가 없습니다</div>
|
||||
<div className="text-muted-foreground/60 text-xs">조건을 변경하거나 새로운 데이터를 추가해보세요</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={gridStyle} className="w-full">
|
||||
{data.map((row, index) => {
|
||||
const idValue = getColumnValue(row, config.idColumn);
|
||||
const titleValue = getColumnValue(row, config.titleColumn);
|
||||
const subtitleValue = getColumnValue(row, config.subtitleColumn);
|
||||
const descriptionValue = getColumnValue(row, config.descriptionColumn);
|
||||
const imageValue = getColumnValue(row, config.imageColumn);
|
||||
|
||||
const isSelected = selectedRows.includes(idValue);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`card-${index}-${idValue}`}
|
||||
style={cardStyle}
|
||||
className={`transition-all duration-200 hover:shadow-md ${
|
||||
isSelected ? "bg-blue-50/30 ring-2 ring-blue-500" : ""
|
||||
}`}
|
||||
onClick={(e) => onRowClick?.(row, index, e)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-sm font-medium">{titleValue || "제목 없음"}</CardTitle>
|
||||
{subtitleValue && <div className="mt-1 truncate text-xs text-gray-500">{subtitleValue}</div>}
|
||||
</div>
|
||||
|
||||
{/* ID 뱃지 */}
|
||||
{idValue && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
{idValue}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{/* 이미지 표시 */}
|
||||
{imageValue && (
|
||||
<div className="mb-3">
|
||||
<img
|
||||
src={imageValue}
|
||||
alt={titleValue}
|
||||
className="h-24 w-full rounded-md bg-gray-100 object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설명 표시 */}
|
||||
{descriptionValue && <div className="mb-3 line-clamp-2 text-xs text-gray-600">{descriptionValue}</div>}
|
||||
|
||||
{/* 추가 필드들 표시 (선택적) */}
|
||||
<div className="space-y-1">
|
||||
{(visibleColumns || [])
|
||||
.filter(
|
||||
(col) =>
|
||||
col.columnName !== config.idColumn &&
|
||||
col.columnName !== config.titleColumn &&
|
||||
col.columnName !== config.subtitleColumn &&
|
||||
col.columnName !== config.descriptionColumn &&
|
||||
col.columnName !== config.imageColumn &&
|
||||
col.columnName !== "__checkbox__" &&
|
||||
col.visible,
|
||||
)
|
||||
.slice(0, 3) // 최대 3개 추가 필드만 표시
|
||||
.map((col) => {
|
||||
const value = getColumnValue(row, col.columnName);
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div key={col.columnName} className="flex items-center justify-between text-xs">
|
||||
<span className="truncate text-gray-500">{col.displayName}:</span>
|
||||
<span className="ml-2 truncate font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
{renderActions(row)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
# TableList 컴포넌트
|
||||
|
||||
데이터베이스 테이블의 데이터를 목록으로 표시하는 고급 테이블 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `table-list`
|
||||
- **카테고리**: display
|
||||
- **웹타입**: table
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ **동적 테이블 연동**: 데이터베이스 테이블 자동 로드
|
||||
- ✅ **고급 페이지네이션**: 대용량 데이터 효율적 처리
|
||||
- ✅ **실시간 검색**: 빠른 데이터 검색 및 필터링
|
||||
- ✅ **컬럼 커스터마이징**: 표시/숨김, 순서 변경, 정렬
|
||||
- ✅ **정렬 기능**: 컬럼별 오름차순/내림차순 정렬
|
||||
- ✅ **반응형 디자인**: 다양한 화면 크기 지원
|
||||
- ✅ **다양한 테마**: 기본, 줄무늬, 테두리, 미니멀 테마
|
||||
- ✅ **실시간 새로고침**: 데이터 자동/수동 새로고침
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { TableListComponent } from "@/lib/registry/components/table-list";
|
||||
|
||||
<TableListComponent
|
||||
component={{
|
||||
id: "my-table-list",
|
||||
type: "widget",
|
||||
webType: "table",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 800, height: 400 },
|
||||
config: {
|
||||
selectedTable: "users",
|
||||
title: "사용자 목록",
|
||||
showHeader: true,
|
||||
showFooter: true,
|
||||
autoLoad: true,
|
||||
pagination: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
showSizeSelector: true,
|
||||
showPageInfo: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
},
|
||||
filter: {
|
||||
enabled: true,
|
||||
quickSearch: true,
|
||||
advancedFilter: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>;
|
||||
```
|
||||
|
||||
## 주요 설정 옵션
|
||||
|
||||
### 기본 설정
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
| ------------- | ------------------------------- | ------ | ---------------------------- |
|
||||
| selectedTable | string | - | 표시할 데이터베이스 테이블명 |
|
||||
| title | string | - | 테이블 제목 |
|
||||
| showHeader | boolean | true | 헤더 표시 여부 |
|
||||
| showFooter | boolean | true | 푸터 표시 여부 |
|
||||
| autoLoad | boolean | true | 자동 데이터 로드 |
|
||||
| height | "auto" \| "fixed" \| "viewport" | "auto" | 높이 설정 모드 |
|
||||
| fixedHeight | number | 400 | 고정 높이 (px) |
|
||||
|
||||
### 페이지네이션 설정
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
| --------------------------- | -------- | -------------- | ----------------------- |
|
||||
| pagination.enabled | boolean | true | 페이지네이션 사용 여부 |
|
||||
| pagination.pageSize | number | 20 | 페이지당 표시 항목 수 |
|
||||
| pagination.showSizeSelector | boolean | true | 페이지 크기 선택기 표시 |
|
||||
| pagination.showPageInfo | boolean | true | 페이지 정보 표시 |
|
||||
| pagination.pageSizeOptions | number[] | [10,20,50,100] | 선택 가능한 페이지 크기 |
|
||||
|
||||
### 컬럼 설정
|
||||
|
||||
| 속성 | 타입 | 설명 |
|
||||
| --------------------- | ------------------------------------------------------- | ------------------- |
|
||||
| columns | ColumnConfig[] | 컬럼 설정 배열 |
|
||||
| columns[].columnName | string | 데이터베이스 컬럼명 |
|
||||
| columns[].displayName | string | 화면 표시명 |
|
||||
| columns[].visible | boolean | 표시 여부 |
|
||||
| columns[].sortable | boolean | 정렬 가능 여부 |
|
||||
| columns[].searchable | boolean | 검색 가능 여부 |
|
||||
| columns[].align | "left" \| "center" \| "right" | 텍스트 정렬 |
|
||||
| columns[].format | "text" \| "number" \| "date" \| "currency" \| "boolean" | 데이터 형식 |
|
||||
| columns[].width | number | 컬럼 너비 (px) |
|
||||
| columns[].order | number | 표시 순서 |
|
||||
|
||||
### 필터 설정
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
| ------------------------ | -------- | ------ | ------------------- |
|
||||
| filter.enabled | boolean | true | 필터 기능 사용 여부 |
|
||||
| filter.quickSearch | boolean | true | 빠른 검색 사용 여부 |
|
||||
| filter.advancedFilter | boolean | false | 고급 필터 사용 여부 |
|
||||
| filter.filterableColumns | string[] | [] | 필터 가능 컬럼 목록 |
|
||||
|
||||
### 스타일 설정
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
| ------------------------ | ------------------------------------------------- | --------- | ------------------- |
|
||||
| tableStyle.theme | "default" \| "striped" \| "bordered" \| "minimal" | "default" | 테이블 테마 |
|
||||
| tableStyle.headerStyle | "default" \| "dark" \| "light" | "default" | 헤더 스타일 |
|
||||
| tableStyle.rowHeight | "compact" \| "normal" \| "comfortable" | "normal" | 행 높이 |
|
||||
| tableStyle.alternateRows | boolean | true | 교대로 행 색상 변경 |
|
||||
| tableStyle.hoverEffect | boolean | true | 마우스 오버 효과 |
|
||||
| tableStyle.borderStyle | "none" \| "light" \| "heavy" | "light" | 테두리 스타일 |
|
||||
| stickyHeader | boolean | false | 헤더 고정 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onRowClick`: 행 클릭 시
|
||||
- `onRowDoubleClick`: 행 더블클릭 시
|
||||
- `onSelectionChange`: 선택 변경 시
|
||||
- `onPageChange`: 페이지 변경 시
|
||||
- `onSortChange`: 정렬 변경 시
|
||||
- `onFilterChange`: 필터 변경 시
|
||||
- `onRefresh`: 새로고침 시
|
||||
|
||||
## API 연동
|
||||
|
||||
### 테이블 목록 조회
|
||||
|
||||
```
|
||||
GET /api/tables
|
||||
```
|
||||
|
||||
### 테이블 컬럼 정보 조회
|
||||
|
||||
```
|
||||
GET /api/tables/{tableName}/columns
|
||||
```
|
||||
|
||||
### 테이블 데이터 조회
|
||||
|
||||
```
|
||||
GET /api/tables/{tableName}/data?page=1&limit=20&search=&sortBy=&sortDirection=
|
||||
```
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 1. 기본 사용자 목록
|
||||
|
||||
```tsx
|
||||
<TableListComponent
|
||||
component={{
|
||||
id: "user-list",
|
||||
config: {
|
||||
selectedTable: "users",
|
||||
title: "사용자 관리",
|
||||
pagination: { enabled: true, pageSize: 25 },
|
||||
filter: { enabled: true, quickSearch: true },
|
||||
columns: [
|
||||
{ columnName: "id", displayName: "ID", visible: true, sortable: true },
|
||||
{ columnName: "name", displayName: "이름", visible: true, sortable: true },
|
||||
{ columnName: "email", displayName: "이메일", visible: true, sortable: true },
|
||||
{ columnName: "created_at", displayName: "가입일", visible: true, format: "date" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. 매출 데이터 (통화 형식)
|
||||
|
||||
```tsx
|
||||
<TableListComponent
|
||||
component={{
|
||||
id: "sales-list",
|
||||
config: {
|
||||
selectedTable: "sales",
|
||||
title: "매출 현황",
|
||||
tableStyle: { theme: "striped", rowHeight: "comfortable" },
|
||||
columns: [
|
||||
{ columnName: "product_name", displayName: "상품명", visible: true },
|
||||
{ columnName: "amount", displayName: "금액", visible: true, format: "currency", align: "right" },
|
||||
{ columnName: "quantity", displayName: "수량", visible: true, format: "number", align: "center" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. 고정 높이 테이블
|
||||
|
||||
```tsx
|
||||
<TableListComponent
|
||||
component={{
|
||||
id: "fixed-table",
|
||||
config: {
|
||||
selectedTable: "products",
|
||||
height: "fixed",
|
||||
fixedHeight: 300,
|
||||
stickyHeader: true,
|
||||
pagination: { enabled: false },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 상세설정 패널
|
||||
|
||||
컴포넌트 설정 패널은 5개의 탭으로 구성되어 있습니다:
|
||||
|
||||
1. **기본 탭**: 테이블 선택, 제목, 표시 설정, 높이, 페이지네이션
|
||||
2. **컬럼 탭**: 컬럼 추가/제거, 표시 설정, 순서 변경, 형식 지정
|
||||
3. **필터 탭**: 검색 및 필터 옵션 설정
|
||||
4. **액션 탭**: 행 액션 버튼, 일괄 액션 설정
|
||||
5. **스타일 탭**: 테마, 행 높이, 색상, 효과 설정
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-12
|
||||
- **CLI 명령어**: `node scripts/create-component.js table-list "테이블 리스트" "데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트" display`
|
||||
- **경로**: `lib/registry/components/table-list/`
|
||||
|
||||
## API 요구사항
|
||||
|
||||
이 컴포넌트가 정상 작동하려면 다음 API 엔드포인트가 구현되어 있어야 합니다:
|
||||
|
||||
- `GET /api/tables` - 사용 가능한 테이블 목록
|
||||
- `GET /api/tables/{tableName}/columns` - 테이블 컬럼 정보
|
||||
- `GET /api/tables/{tableName}/data` - 테이블 데이터 (페이징, 검색, 정렬 지원)
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [API 문서](https://docs.example.com/api/tables)
|
||||
- [개발자 문서](https://docs.example.com/components/table-list)
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ColumnConfig } from "./types";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
interface SingleTableWithStickyProps {
|
||||
visibleColumns?: ColumnConfig[];
|
||||
columns?: ColumnConfig[];
|
||||
data: Record<string, any>[];
|
||||
columnLabels: Record<string, string>;
|
||||
sortColumn: string | null;
|
||||
sortDirection: "asc" | "desc";
|
||||
tableConfig?: any;
|
||||
isDesignMode?: boolean;
|
||||
isAllSelected?: boolean;
|
||||
handleSort?: (columnName: string) => void;
|
||||
onSort?: (columnName: string) => void;
|
||||
handleSelectAll?: (checked: boolean) => void;
|
||||
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
|
||||
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
|
||||
renderCheckboxHeader?: () => React.ReactNode;
|
||||
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
|
||||
getColumnWidth: (column: ColumnConfig) => number;
|
||||
containerWidth?: string; // 컨테이너 너비 설정
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
// 인라인 편집 관련 props
|
||||
onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void;
|
||||
editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null;
|
||||
editingValue?: string;
|
||||
onEditingValueChange?: (value: string) => void;
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
editInputRef?: React.RefObject<HTMLInputElement>;
|
||||
// 검색 하이라이트 관련 props
|
||||
searchHighlights?: Set<string>;
|
||||
currentSearchIndex?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
visibleColumns,
|
||||
columns,
|
||||
data,
|
||||
columnLabels,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
tableConfig,
|
||||
isDesignMode = false,
|
||||
isAllSelected = false,
|
||||
handleSort,
|
||||
onSort,
|
||||
handleSelectAll,
|
||||
handleRowClick,
|
||||
renderCheckboxCell,
|
||||
renderCheckboxHeader,
|
||||
formatCellValue,
|
||||
getColumnWidth,
|
||||
containerWidth,
|
||||
loading = false,
|
||||
error = null,
|
||||
// 인라인 편집 관련 props
|
||||
onCellDoubleClick,
|
||||
editingCell,
|
||||
editingValue,
|
||||
onEditingValueChange,
|
||||
onEditKeyDown,
|
||||
editInputRef,
|
||||
// 검색 하이라이트 관련 props
|
||||
searchHighlights,
|
||||
currentSearchIndex = 0,
|
||||
searchTerm = "",
|
||||
}) => {
|
||||
const { getTranslatedText } = useScreenMultiLang();
|
||||
const checkboxConfig = tableConfig?.checkbox || {};
|
||||
const actualColumns = visibleColumns || columns || [];
|
||||
const sortHandler = onSort || handleSort || (() => {});
|
||||
const actualData = data || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex-1 overflow-auto">
|
||||
<Table
|
||||
noWrapper
|
||||
className="w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
tableLayout: "auto", // 테이블 크기 자동 조정
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<TableHeader
|
||||
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
|
||||
>
|
||||
<TableRow className="border-b">
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
|
||||
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-primary/10",
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
|
||||
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
checkboxConfig.selectAll && (
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate">
|
||||
{/* langKey가 있으면 다국어 번역 사용, 없으면 기존 라벨 */}
|
||||
{(column as any).langKey
|
||||
? getTranslatedText(
|
||||
(column as any).langKey,
|
||||
columnLabels[column.columnName] || column.displayName || column.columnName,
|
||||
)
|
||||
: columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||
</span>
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{actualData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={actualColumns.length || 1} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<svg
|
||||
className="text-muted-foreground h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">데이터가 없습니다</span>
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
|
||||
조건을 변경하여 다시 검색해보세요
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
actualData.map((row, index) => (
|
||||
<TableRow
|
||||
key={`row-${index}`}
|
||||
className={cn(
|
||||
"bg-background h-10 cursor-pointer border-b transition-colors",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
||||
)}
|
||||
onClick={(e) => handleRowClick?.(row, index, e)}
|
||||
>
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns
|
||||
.slice(rightFixedIndex + 1)
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
|
||||
// 현재 셀이 편집 중인지 확인
|
||||
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
|
||||
|
||||
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
|
||||
const cellKey = `${index}-${colIndex}`;
|
||||
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
|
||||
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
|
||||
|
||||
// 인덱스 기반 하이라이트 + 실제 값 검증
|
||||
const isHighlighted =
|
||||
column.columnName !== "__checkbox__" &&
|
||||
hasSearchTerm &&
|
||||
(searchHighlights?.has(cellKey) ?? false);
|
||||
|
||||
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
|
||||
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
|
||||
const isCurrentSearchResult =
|
||||
isHighlighted &&
|
||||
currentSearchIndex >= 0 &&
|
||||
currentSearchIndex < highlightArray.length &&
|
||||
highlightArray[currentSearchIndex] === cellKey;
|
||||
|
||||
// 셀 값에서 검색어 하이라이트 렌더링
|
||||
const renderCellContent = () => {
|
||||
const cellValue =
|
||||
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
||||
|
||||
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||
return cellValue;
|
||||
}
|
||||
|
||||
// 검색어 하이라이트 처리
|
||||
const lowerValue = String(cellValue).toLowerCase();
|
||||
const lowerTerm = searchTerm.toLowerCase();
|
||||
const startIndex = lowerValue.indexOf(lowerTerm);
|
||||
|
||||
if (startIndex === -1) return cellValue;
|
||||
|
||||
const before = String(cellValue).slice(0, startIndex);
|
||||
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
|
||||
const after = String(cellValue).slice(startIndex + searchTerm.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<mark
|
||||
className={cn(
|
||||
"rounded px-0.5",
|
||||
isCurrentSearchResult
|
||||
? "bg-orange-400 font-semibold text-white"
|
||||
: "bg-yellow-200 text-yellow-900",
|
||||
)}
|
||||
>
|
||||
{match}
|
||||
</mark>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={`cell-${column.columnName}`}
|
||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||
className={cn(
|
||||
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
|
||||
`text-${column.align}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" &&
|
||||
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
|
||||
column.fixed === "right" &&
|
||||
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
|
||||
// 편집 가능 셀 스타일
|
||||
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
|
||||
e.stopPropagation();
|
||||
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxCell?.(row, index)
|
||||
) : isEditing ? (
|
||||
// 인라인 편집 입력 필드
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editingValue ?? ""}
|
||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||
onKeyDown={onEditKeyDown}
|
||||
onBlur={() => {
|
||||
// blur 시 저장 (Enter와 동일)
|
||||
if (onEditKeyDown) {
|
||||
const fakeEvent = {
|
||||
key: "Enter",
|
||||
preventDefault: () => {},
|
||||
} as React.KeyboardEvent<HTMLInputElement>;
|
||||
onEditKeyDown(fakeEvent);
|
||||
}
|
||||
}}
|
||||
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
renderCellContent()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2TableListDefinition } from "./index";
|
||||
import { TableListComponent } from "./TableListComponent";
|
||||
|
||||
/**
|
||||
* TableList 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TableListRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2TableListDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <TableListComponent {...this.props} renderer={this} onConfigChange={this.handleConfigChange} />;
|
||||
}
|
||||
|
||||
// 설정 변경 핸들러
|
||||
protected handleConfigChange = (config: any) => {
|
||||
console.log("📥 TableListRenderer에서 설정 변경 받음:", config);
|
||||
|
||||
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
|
||||
if (this.props.onConfigChange) {
|
||||
this.props.onConfigChange(config);
|
||||
} else {
|
||||
console.log("⚠️ 상위 컴포넌트에서 onConfigChange가 전달되지 않음");
|
||||
}
|
||||
|
||||
this.updateComponent({ config });
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getTableListProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TableListRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
TableListRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("❌ TableList 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue