Compare commits
2 Commits
0658ce41f9
...
338f3c27fd
| Author | SHA1 | Date |
|---|---|---|
|
|
338f3c27fd | |
|
|
901cb04a88 |
|
|
@ -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;
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function AggregationWidgetConfigPanel({
|
|||
onChange,
|
||||
screenTableName,
|
||||
}: AggregationWidgetConfigPanelProps) {
|
||||
const [columns, setColumns] = useState<Array<{ columnName: string; label?: string; dataType?: string }>>([]);
|
||||
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);
|
||||
|
|
@ -93,6 +93,8 @@ export function AggregationWidgetConfigPanel({
|
|||
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 {
|
||||
|
|
@ -140,15 +142,19 @@ export function AggregationWidgetConfigPanel({
|
|||
});
|
||||
};
|
||||
|
||||
// 숫자형 컬럼만 필터링 (count 제외)
|
||||
const numericColumns = columns.filter(
|
||||
(col) =>
|
||||
col.dataType?.toLowerCase().includes("int") ||
|
||||
col.dataType?.toLowerCase().includes("numeric") ||
|
||||
col.dataType?.toLowerCase().includes("decimal") ||
|
||||
col.dataType?.toLowerCase().includes("float") ||
|
||||
col.dataType?.toLowerCase().includes("double")
|
||||
);
|
||||
// 숫자형 컬럼만 필터링 (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">
|
||||
|
|
|
|||
|
|
@ -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
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue