V2 컴포넌트 규칙 추가 및 기존 컴포넌트 자동 등록 개선: 화면 컴포넌트 개발 가이드에 V2 컴포넌트 사용 규칙을 명시하고, ComponentsPanel에서 수동으로 추가하던 table-list 컴포넌트를 자동 등록으로 변경하여 관리 효율성을 높였습니다. 또한, V2 컴포넌트 목록과 수정/개발 시 규칙을 추가하여 일관된 개발 환경을 조성하였습니다.

This commit is contained in:
kjs 2026-01-19 14:52:11 +09:00
parent 901cb04a88
commit 338f3c27fd
116 changed files with 48850 additions and 61 deletions

View File

@ -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 참조 확인
### 컴포넌트별 테이블 설정 (핵심)
- [ ] 화면 메인 테이블과 다른 테이블을 사용할 수 있는지 확인

View File

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

View File

@ -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";
/**
*

View File

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

View File

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

View File

@ -0,0 +1,12 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { V2AggregationWidgetDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(V2AggregationWidgetDefinition);
}
export {};

View File

@ -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";

View File

@ -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

View File

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

View File

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

View File

@ -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"
},
};

View File

@ -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";

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
},
};

View File

@ -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";

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

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

View File

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

View File

@ -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
- **작성자**: 개발팀

View File

@ -0,0 +1,15 @@
/**
*
*/
import { NumberingRuleComponentConfig } from "./types";
export const defaultConfig: NumberingRuleComponentConfig = {
maxRules: 6,
readonly: false,
showPreview: true,
showRuleList: true,
enableReorder: false,
cardLayout: "vertical",
};

View File

@ -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";

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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`를 통해 영역 내 필드 순서를 지정하세요.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

@ -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";

View File

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

View File

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

View File

@ -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";

View File

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

View File

@ -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] || "합계";
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export * from "./aggregation";
export * from "./pivotEngine";
export * from "./exportExcel";
export * from "./conditionalFormat";

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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: [],
};

View File

@ -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";

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

@ -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 분리

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

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

View File

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

View File

@ -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";

View File

@ -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,
};
```

View File

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

View File

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

View File

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

View File

@ -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";

View File

@ -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; // 선택 항목 동기화
}

View File

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

View File

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

View File

@ -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

View File

@ -0,0 +1,76 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2TableListDefinition } from "./index";
import { TableListComponent } from "./TableListComponent";
/**
* TableList
*
*/
export class TableListRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2TableListDefinition;
render(): React.ReactElement {
return <TableListComponent {...this.props} renderer={this} onConfigChange={this.handleConfigChange} />;
}
// 설정 변경 핸들러
protected handleConfigChange = (config: any) => {
console.log("📥 TableListRenderer에서 설정 변경 받음:", config);
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
if (this.props.onConfigChange) {
this.props.onConfigChange(config);
} else {
console.log("⚠️ 상위 컴포넌트에서 onConfigChange가 전달되지 않음");
}
this.updateComponent({ config });
};
/**
*
*/
// text 타입 특화 속성 처리
protected getTableListProps() {
const baseProps = this.getWebTypeProps();
// text 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 text 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
TableListRenderer.registerSelf();
// 강제 등록 (디버깅용)
if (typeof window !== "undefined") {
setTimeout(() => {
try {
TableListRenderer.registerSelf();
} catch (error) {
console.error("❌ TableList 강제 등록 실패:", error);
}
}, 1000);
}

Some files were not shown because too many files have changed in this diff Show More