diff --git a/.cursor/rules/component-development-guide.mdc b/.cursor/rules/component-development-guide.mdc index 046d231f..f3262b30 100644 --- a/.cursor/rules/component-development-guide.mdc +++ b/.cursor/rules/component-development-guide.mdc @@ -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 = ({ 새 컴포넌트 개발 시 다음 항목을 확인하세요: +### V2 컴포넌트 규칙 (최우선) + +- [ ] V2 폴더(`v2-*/`)에서 작업 중인지 확인 +- [ ] 원본 폴더는 수정하지 않음 +- [ ] 컴포넌트 ID에 `v2-` 접두사 사용 +- [ ] Definition 이름에 `V2` 접두사 사용 (예: `V2TableListDefinition`) +- [ ] Renderer에서 올바른 V2 Definition 참조 확인 + ### 컴포넌트별 테이블 설정 (핵심) - [ ] 화면 메인 테이블과 다른 테이블을 사용할 수 있는지 확인 diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 70b45412..5e929933 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -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; }, []); diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 156a3f01..ad656abf 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -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"; /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx new file mode 100644 index 00000000..ce0b0325 --- /dev/null +++ b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx @@ -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([]); + + // 외부 데이터가 있으면 사용 + 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 ; + case "avg": + return ; + case "count": + return ; + case "max": + return ; + case "min": + return ; + } + }; + + // 집계 타입 라벨 + 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 ( +
+ {previewItems.map((result, index) => ( +
+ {showIcons && ( + {getIcon(result.type)} + )} + {showLabels && ( + + {result.label} ({getTypeLabel(result.type)}): + + )} + + {result.formattedValue} + +
+ ))} +
+ ); + } + + // 실제 렌더링 + if (aggregationResults.length === 0) { + return ( +
+ 집계 항목을 설정해주세요 +
+ ); + } + + return ( +
+ {aggregationResults.map((result, index) => ( +
+ {showIcons && ( + {getIcon(result.type)} + )} + {showLabels && ( + + {result.label} ({getTypeLabel(result.type)}): + + )} + + {result.formattedValue} + +
+ ))} +
+ ); +} + +export const AggregationWidgetWrapper = AggregationWidgetComponent; + diff --git a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx new file mode 100644 index 00000000..ebfff828 --- /dev/null +++ b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx @@ -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) => void; + screenTableName?: string; +} + +/** + * 집계 위젯 설정 패널 + */ +export function AggregationWidgetConfigPanel({ + config, + onChange, + screenTableName, +}: AggregationWidgetConfigPanelProps) { + const [columns, setColumns] = useState>([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [availableTables, setAvailableTables] = useState>([]); + 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) => { + 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 ( +
+
집계 위젯 설정
+ + {/* 테이블 설정 (컴포넌트 개발 가이드 준수) */} +
+
+

데이터 소스 테이블

+

집계할 데이터의 테이블을 선택합니다

+
+
+ + {/* 현재 선택된 테이블 표시 (카드 형태) */} +
+ +
+
+ {config.customTableName || config.tableName || screenTableName || "테이블 미선택"} +
+
+ {config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"} +
+
+
+ + {/* 테이블 선택 Combobox */} + + + + + + + + + 테이블을 찾을 수 없습니다 + + {/* 그룹 1: 화면 기본 테이블 */} + {screenTableName && ( + + { + onChange({ + useCustomTable: false, + customTableName: undefined, + tableName: screenTableName, + items: [], // 테이블 변경 시 집계 항목 초기화 + }); + setTableComboboxOpen(false); + }} + className="text-xs cursor-pointer" + > + + + {screenTableName} + + + )} + + {/* 그룹 2: 전체 테이블 */} + + {availableTables + .filter((table) => table.tableName !== screenTableName) + .map((table) => ( + { + onChange({ + useCustomTable: true, + customTableName: table.tableName, + tableName: table.tableName, + items: [], // 테이블 변경 시 집계 항목 초기화 + }); + setTableComboboxOpen(false); + }} + className="text-xs cursor-pointer" + > + + + {table.displayName || table.tableName} + + ))} + + + + + +
+ + {/* 레이아웃 설정 */} +
+
+

레이아웃

+
+
+ +
+
+ + +
+ +
+ + onChange({ gap: e.target.value })} + placeholder="16px" + className="h-8 text-xs" + /> +
+
+ +
+
+ onChange({ showLabels: checked as boolean })} + /> + +
+ +
+ onChange({ showIcons: checked as boolean })} + /> + +
+
+
+ + {/* 집계 항목 설정 */} +
+
+

집계 항목

+ +
+
+ + {(config.items || []).length === 0 ? ( +
+ 집계 항목을 추가해주세요 +
+ ) : ( +
+ {(config.items || []).map((item, index) => ( +
+
+
+ + 항목 {index + 1} +
+ +
+ +
+ {/* 컬럼 선택 */} +
+ + +
+ + {/* 집계 타입 */} +
+ + +
+ + {/* 표시 라벨 */} +
+ + updateItem(item.id, { columnLabel: e.target.value })} + placeholder="표시될 라벨" + className="h-7 text-xs" + /> +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 접두사 */} +
+ + updateItem(item.id, { prefix: e.target.value })} + placeholder="예: ₩" + className="h-7 text-xs" + /> +
+ + {/* 접미사 */} +
+ + updateItem(item.id, { suffix: e.target.value })} + placeholder="예: 원, 개" + className="h-7 text-xs" + /> +
+
+
+ ))} +
+ )} +
+ + {/* 스타일 설정 */} +
+
+

스타일

+
+
+ +
+
+ + onChange({ backgroundColor: e.target.value })} + className="h-8" + /> +
+ +
+ + onChange({ borderRadius: e.target.value })} + placeholder="6px" + className="h-8 text-xs" + /> +
+ +
+ + onChange({ labelColor: e.target.value })} + className="h-8" + /> +
+ +
+ + onChange({ valueColor: e.target.value })} + className="h-8" + /> +
+
+
+
+ ); +} + diff --git a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetRenderer.tsx b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetRenderer.tsx new file mode 100644 index 00000000..50a8e86a --- /dev/null +++ b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetRenderer.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; +import { V2AggregationWidgetDefinition } from "./index"; + +// 컴포넌트 자동 등록 +if (typeof window !== "undefined") { + ComponentRegistry.registerComponent(V2AggregationWidgetDefinition); +} + +export {}; + diff --git a/frontend/lib/registry/components/v2-aggregation-widget/index.ts b/frontend/lib/registry/components/v2-aggregation-widget/index.ts new file mode 100644 index 00000000..ab47c1db --- /dev/null +++ b/frontend/lib/registry/components/v2-aggregation-widget/index.ts @@ -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, + defaultSize: { width: 400, height: 60 }, + configPanel: AggregationWidgetConfigPanel, + icon: "Calculator", + tags: ["집계", "합계", "평균", "개수", "통계", "데이터"], + version: "1.0.0", + author: "개발팀", +}); + +// 타입 내보내기 +export type { AggregationWidgetConfig, AggregationItem, AggregationType, AggregationResult } from "./types"; + diff --git a/frontend/lib/registry/components/v2-aggregation-widget/types.ts b/frontend/lib/registry/components/v2-aggregation-widget/types.ts new file mode 100644 index 00000000..6d480599 --- /dev/null +++ b/frontend/lib/registry/components/v2-aggregation-widget/types.ts @@ -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; +} + diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx new file mode 100644 index 00000000..173a67ad --- /dev/null +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -0,0 +1,1362 @@ +"use client"; + +import React, { useState, useRef, useEffect, useMemo } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { ButtonPrimaryConfig } from "./types"; +import { + ButtonActionExecutor, + ButtonActionContext, + ButtonActionType, + DEFAULT_BUTTON_ACTIONS, +} from "@/lib/utils/buttonActions"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { useCurrentFlowStep } from "@/stores/flowStepStore"; +import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; +import { applyMappingRules } from "@/lib/utils/dataMapping"; +import { apiClient } from "@/lib/api/client"; + +export interface ButtonPrimaryComponentProps extends ComponentRendererProps { + config?: ButtonPrimaryConfig; + // 추가 props + screenId?: number; + tableName?: string; + userId?: string; // 🆕 현재 사용자 ID + userName?: string; // 🆕 현재 사용자 이름 + companyCode?: string; // 🆕 현재 사용자의 회사 코드 + onRefresh?: () => void; + onClose?: () => void; + onFlowRefresh?: () => void; + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 + + // 폼 데이터 관련 + originalData?: Record; // 부분 업데이트용 원본 데이터 + + // 테이블 선택된 행 정보 (다중 선택 액션용) + selectedRows?: any[]; + selectedRowsData?: any[]; + + // 테이블 정렬 정보 (엑셀 다운로드용) + sortBy?: string; + sortOrder?: "asc" | "desc"; + columnOrder?: string[]; + tableDisplayData?: any[]; // 화면에 표시된 데이터 (컬럼 순서 포함) + + // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) + flowSelectedData?: any[]; + flowSelectedStepId?: number | null; + + // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용) + allComponents?: any[]; + + // 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용) + groupedData?: Record[]; +} + +/** + * ButtonPrimary 컴포넌트 + * button-primary 컴포넌트입니다 + */ +export const ButtonPrimaryComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + isInteractive = false, + onClick, + onDragStart, + onDragEnd, + config, + className, + style, + formData, + originalData, + onFormDataChange, + screenId, + tableName, + userId, // 🆕 사용자 ID + userName, // 🆕 사용자 이름 + companyCode, // 🆕 회사 코드 + onRefresh, + onClose, + onFlowRefresh, + onSave, // 🆕 EditModal의 handleSave 콜백 + sortBy, // 🆕 정렬 컬럼 + sortOrder, // 🆕 정렬 방향 + columnOrder, // 🆕 컬럼 순서 + tableDisplayData, // 🆕 화면에 표시된 데이터 + selectedRows, + selectedRowsData, + flowSelectedData, + flowSelectedStepId, + allComponents, // 🆕 같은 화면의 모든 컴포넌트 + groupedData, // 🆕 부모창에서 전달된 그룹 데이터 + ...props +}) => { + const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + const screenContext = useScreenContextOptional(); // 화면 컨텍스트 + const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) + const splitPanelPosition = screenContext?.splitPanelPosition; + + // 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기 + const effectiveTableName = tableName || screenContext?.tableName; + const effectiveScreenId = screenId || screenContext?.screenId; + + // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) + const propsOnSave = (props as any).onSave as (() => Promise) | undefined; + const finalOnSave = onSave || propsOnSave; + + // 🆕 플로우 단계별 표시 제어 + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; + const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId); + + // 🆕 버튼 표시 여부 계산 + const shouldShowButton = useMemo(() => { + // 플로우 제어 비활성화 시 항상 표시 + if (!flowConfig?.enabled) { + return true; + } + + // 플로우 단계가 선택되지 않은 경우 처리 + if (currentStep === null) { + // 🔧 화이트리스트 모드일 때는 단계 미선택 시 숨김 + if (flowConfig.mode === "whitelist") { + return false; + } + // 블랙리스트나 all 모드는 표시 + return true; + } + + const { mode, visibleSteps = [], hiddenSteps = [] } = flowConfig; + + let result = true; + if (mode === "whitelist") { + result = visibleSteps.includes(currentStep); + } else if (mode === "blacklist") { + result = !hiddenSteps.includes(currentStep); + } else if (mode === "all") { + result = true; + } + + return result; + }, [flowConfig, currentStep, component.id, component.label]); + + // 🆕 운행알림 버튼 조건부 비활성화 (출발지/도착지 필수, 상태 체크) + // 상태는 API로 조회 (formData에 없는 경우) + const [vehicleStatus, setVehicleStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(false); + + // 상태 조회 (operation_control + enableOnStatusCheck일 때) + const actionConfig = component.componentConfig?.action; + const shouldFetchStatus = actionConfig?.type === "operation_control" && actionConfig?.enableOnStatusCheck && userId; + const statusTableName = actionConfig?.statusCheckTableName || "vehicles"; + const statusKeyField = actionConfig?.statusCheckKeyField || "user_id"; + const statusFieldName = actionConfig?.statusCheckField || "status"; + + useEffect(() => { + if (!shouldFetchStatus) return; + + let isMounted = true; + + const fetchStatus = async () => { + if (!isMounted) return; + + try { + const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, { + page: 1, + size: 1, + search: { [statusKeyField]: userId }, + autoFilter: true, + }); + + if (!isMounted) return; + + const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; + const firstRow = Array.isArray(rows) ? rows[0] : null; + + if (response.data?.success && firstRow) { + const newStatus = firstRow[statusFieldName]; + if (newStatus !== vehicleStatus) { + // console.log("🔄 [ButtonPrimary] 상태 변경 감지:", { 이전: vehicleStatus, 현재: newStatus, buttonLabel: component.label }); + } + setVehicleStatus(newStatus); + } else { + setVehicleStatus(null); + } + } catch (error: any) { + // console.error("❌ [ButtonPrimary] 상태 조회 오류:", error?.message); + if (isMounted) setVehicleStatus(null); + } finally { + if (isMounted) setStatusLoading(false); + } + }; + + // 즉시 실행 + setStatusLoading(true); + fetchStatus(); + + // 2초마다 갱신 + const interval = setInterval(fetchStatus, 2000); + + return () => { + isMounted = false; + clearInterval(interval); + }; + }, [shouldFetchStatus, statusTableName, statusKeyField, statusFieldName, userId, component.label]); + + // 버튼 비활성화 조건 계산 + const isOperationButtonDisabled = useMemo(() => { + const actionConfig = component.componentConfig?.action; + + if (actionConfig?.type !== "operation_control") return false; + + // 1. 출발지/도착지 필수 체크 + if (actionConfig?.requireLocationFields) { + const departureField = actionConfig.trackingDepartureField || "departure"; + const destinationField = actionConfig.trackingArrivalField || "destination"; + + const departure = formData?.[departureField]; + const destination = formData?.[destinationField]; + + // console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", { + // departureField, destinationField, departure, destination, + // buttonLabel: component.label + // }); + + if (!departure || departure === "" || !destination || destination === "") { + // console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label); + return true; + } + } + + // 2. 상태 기반 활성화 조건 (API로 조회한 vehicleStatus 우선 사용) + if (actionConfig?.enableOnStatusCheck) { + const statusField = actionConfig.statusCheckField || "status"; + // API 조회 결과를 우선 사용 (실시간 DB 상태 반영) + const currentStatus = vehicleStatus || formData?.[statusField]; + + const conditionType = actionConfig.statusConditionType || "enableOn"; + const conditionValues = (actionConfig.statusConditionValues || "") + .split(",") + .map((v: string) => v.trim()) + .filter((v: string) => v); + + // console.log("🔍 [ButtonPrimary] 상태 조건 체크:", { + // statusField, + // formDataStatus: formData?.[statusField], + // apiStatus: vehicleStatus, + // currentStatus, + // conditionType, + // conditionValues, + // buttonLabel: component.label, + // }); + + // 상태 로딩 중이면 비활성화 + if (statusLoading) { + // console.log("⏳ [ButtonPrimary] 상태 로딩 중 → 비활성화:", component.label); + return true; + } + + // 상태값이 없으면 → 비활성화 (조건 확인 불가) + if (!currentStatus) { + // console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label); + return true; + } + + if (conditionValues.length > 0) { + if (conditionType === "enableOn") { + // 이 상태일 때만 활성화 + if (!conditionValues.includes(currentStatus)) { + // console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∉ [${conditionValues}] → 비활성화:`, component.label); + return true; + } + } else if (conditionType === "disableOn") { + // 이 상태일 때 비활성화 + if (conditionValues.includes(currentStatus)) { + // console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∈ [${conditionValues}] → 비활성화:`, component.label); + return true; + } + } + } + } + + // console.log("✅ [ButtonPrimary] 버튼 활성화:", component.label); + return false; + }, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]); + + // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨) + const [modalStoreData, setModalStoreData] = useState>({}); + + // modalDataStore 상태 구독 (실시간 업데이트) + useEffect(() => { + const actionConfig = component.componentConfig?.action; + if (!actionConfig?.requireRowSelection) return; + + // 동적 import로 modalDataStore 구독 + let unsubscribe: (() => void) | undefined; + + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + // 초기값 설정 + setModalStoreData(useModalDataStore.getState().dataRegistry); + + // 상태 변경 구독 + unsubscribe = useModalDataStore.subscribe((state) => { + setModalStoreData(state.dataRegistry); + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [component.componentConfig?.action?.requireRowSelection]); + + // 🆕 행 선택 기반 비활성화 조건 계산 + const isRowSelectionDisabled = useMemo(() => { + const actionConfig = component.componentConfig?.action; + + // requireRowSelection이 활성화되어 있지 않으면 비활성화하지 않음 + if (!actionConfig?.requireRowSelection) { + return false; + } + + const rowSelectionSource = actionConfig.rowSelectionSource || "auto"; + const allowMultiRowSelection = actionConfig.allowMultiRowSelection ?? true; + + // 선택된 데이터 확인 + let hasSelection = false; + let selectionCount = 0; + let selectionSource = ""; + + // 1. 자동 감지 모드 또는 테이블 리스트 모드 + if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") { + // TableList에서 선택된 행 확인 (props로 전달됨) + if (selectedRowsData && selectedRowsData.length > 0) { + hasSelection = true; + selectionCount = selectedRowsData.length; + selectionSource = "tableList (selectedRowsData)"; + } + // 또는 selectedRows prop 확인 + else if (selectedRows && selectedRows.length > 0) { + hasSelection = true; + selectionCount = selectedRows.length; + selectionSource = "tableList (selectedRows)"; + } + } + + // 2. 분할 패널 좌측 선택 데이터 확인 + if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") { + // SplitPanelContext에서 확인 + if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) { + if (!hasSelection) { + hasSelection = true; + selectionCount = 1; + selectionSource = "splitPanelLeft (context)"; + } + } + + // 🆕 modalDataStore에서도 확인 (SplitPanelLayoutComponent에서 저장) + if (!hasSelection && Object.keys(modalStoreData).length > 0) { + // modalDataStore에서 데이터가 있는지 확인 + for (const [sourceId, items] of Object.entries(modalStoreData)) { + if (items && items.length > 0) { + hasSelection = true; + selectionCount = items.length; + selectionSource = `modalDataStore (${sourceId})`; + break; + } + } + } + } + + // 3. 플로우 위젯 선택 데이터 확인 + if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") { + // 플로우 위젯 선택 데이터 확인 + if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) { + hasSelection = true; + selectionCount = flowSelectedData.length; + selectionSource = "flowWidget"; + } + } + + // 디버깅 로그 + console.log("🔍 [ButtonPrimary] 행 선택 체크:", component.label, { + rowSelectionSource, + hasSelection, + selectionCount, + selectionSource, + hasSplitPanelContext: !!splitPanelContext, + selectedLeftData: splitPanelContext?.selectedLeftData, + selectedRowsData: selectedRowsData?.length, + selectedRows: selectedRows?.length, + flowSelectedData: flowSelectedData?.length, + modalStoreDataKeys: Object.keys(modalStoreData), + }); + + // 선택된 데이터가 없으면 비활성화 + if (!hasSelection) { + return true; + } + + // 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함 + if (!allowMultiRowSelection && selectionCount !== 1) { + return true; + } + + return false; + }, [ + component.componentConfig?.action, + component.label, + selectedRows, + selectedRowsData, + splitPanelContext?.selectedLeftData, + flowSelectedData, + splitPanelContext, + modalStoreData, + ]); + + // 확인 다이얼로그 상태 + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [pendingAction, setPendingAction] = useState<{ + type: ButtonActionType; + config: any; + context: ButtonActionContext; + } | null>(null); + + // 토스트 정리를 위한 ref + const currentLoadingToastRef = useRef(undefined); + + // 컴포넌트 언마운트 시 토스트 정리 + useEffect(() => { + return () => { + if (currentLoadingToastRef.current !== undefined) { + toast.dismiss(currentLoadingToastRef.current); + currentLoadingToastRef.current = undefined; + } + }; + }, []); + + // 삭제 액션 감지 로직 (실제 필드명 사용) + const isDeleteAction = () => { + const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"]; + return ( + component.componentConfig?.action?.type === "delete" || + component.config?.action?.type === "delete" || + component.webTypeConfig?.actionType === "delete" || + component.text?.toLowerCase().includes("삭제") || + component.text?.toLowerCase().includes("delete") || + component.label?.toLowerCase().includes("삭제") || + component.label?.toLowerCase().includes("delete") || + deleteKeywords.some( + (keyword) => + component.config?.buttonText?.toLowerCase().includes(keyword) || + component.config?.text?.toLowerCase().includes(keyword), + ) + ); + }; + + // 삭제 액션일 때 라벨 색상 자동 설정 + useEffect(() => { + if (isDeleteAction() && !component.style?.labelColor) { + // 삭제 액션이고 라벨 색상이 설정되지 않은 경우 빨간색으로 자동 설정 + if (component.style) { + component.style.labelColor = "#ef4444"; + } else { + component.style = { labelColor: "#ef4444" }; + } + } + }, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]); + + // 컴포넌트 설정 + // 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정) + const componentConfig = { + ...config, + ...component.config, + ...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함 + } as ButtonPrimaryConfig; + + // 🎨 동적 색상 설정 (webTypeConfig 우선, 레거시 style.labelColor 지원) + const getButtonBackgroundColor = () => { + // 1순위: webTypeConfig.backgroundColor (화면설정 모달에서 저장) + if (component.webTypeConfig?.backgroundColor) { + return component.webTypeConfig.backgroundColor; + } + // 2순위: componentConfig.backgroundColor + if (componentConfig.backgroundColor) { + return componentConfig.backgroundColor; + } + // 3순위: style.backgroundColor + if (component.style?.backgroundColor) { + return component.style.backgroundColor; + } + // 4순위: style.labelColor (레거시) + if (component.style?.labelColor) { + return component.style.labelColor; + } + // 기본값: 삭제 버튼이면 빨강, 아니면 파랑 + if (isDeleteAction()) { + return "#ef4444"; // 빨간색 (Tailwind red-500) + } + return "#3b82f6"; // 파란색 (Tailwind blue-500) + }; + + const getButtonTextColor = () => { + // 1순위: webTypeConfig.textColor (화면설정 모달에서 저장) + if (component.webTypeConfig?.textColor) { + return component.webTypeConfig.textColor; + } + // 2순위: componentConfig.textColor + if (componentConfig.textColor) { + return componentConfig.textColor; + } + // 3순위: style.color + if (component.style?.color) { + return component.style.color; + } + // 기본값: 흰색 + return "#ffffff"; + }; + + const buttonColor = getButtonBackgroundColor(); + const buttonTextColor = getButtonTextColor(); + + // 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환 + const processedConfig = { ...componentConfig }; + if (componentConfig.action && typeof componentConfig.action === "string") { + const actionType = componentConfig.action as ButtonActionType; + processedConfig.action = { + ...DEFAULT_BUTTON_ACTIONS[actionType], + type: actionType, + // 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴) + enableDataflowControl: component.webTypeConfig?.enableDataflowControl, + dataflowConfig: component.webTypeConfig?.dataflowConfig, + dataflowTiming: component.webTypeConfig?.dataflowTiming, + }; + } else if (componentConfig.action && typeof componentConfig.action === "object") { + // 🔥 이미 객체인 경우에도 제어관리 설정 추가 + processedConfig.action = { + ...componentConfig.action, + enableDataflowControl: component.webTypeConfig?.enableDataflowControl, + dataflowConfig: component.webTypeConfig?.dataflowConfig, + dataflowTiming: component.webTypeConfig?.dataflowTiming, + }; + } + + // 스타일 계산 + // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + const componentStyle: React.CSSProperties = { + ...component.style, + ...style, + width: "100%", + height: "100%", + }; + + // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) + if (isDesignMode) { + componentStyle.borderWidth = "1px"; + componentStyle.borderStyle = "dashed"; + componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; + } + + // 확인 다이얼로그가 필요한 액션 타입들 + const confirmationRequiredActions: ButtonActionType[] = ["save", "delete"]; + + // 실제 액션 실행 함수 + const executeAction = async (actionConfig: any, context: ButtonActionContext) => { + try { + // 기존 토스트가 있다면 먼저 제거 + if (currentLoadingToastRef.current !== undefined) { + toast.dismiss(currentLoadingToastRef.current); + currentLoadingToastRef.current = undefined; + } + + // 추가 안전장치: 모든 로딩 토스트 제거 + toast.dismiss(); + + // UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음 + const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + if (!silentActions.includes(actionConfig.type)) { + currentLoadingToastRef.current = toast.loading( + actionConfig.type === "save" + ? "저장 중..." + : actionConfig.type === "delete" + ? "삭제 중..." + : actionConfig.type === "submit" + ? "제출 중..." + : "처리 중...", + { + duration: Infinity, // 명시적으로 무한대로 설정 + }, + ); + } + + const success = await ButtonActionExecutor.executeAction(actionConfig, context); + + // 로딩 토스트 제거 (있는 경우에만) + if (currentLoadingToastRef.current !== undefined) { + toast.dismiss(currentLoadingToastRef.current); + currentLoadingToastRef.current = undefined; + } + + // 실패한 경우 오류 처리 + if (!success) { + // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) + const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + if (silentErrorActions.includes(actionConfig.type)) { + return; + } + // 기본 에러 메시지 결정 + const defaultErrorMessage = + actionConfig.type === "save" + ? "저장 중 오류가 발생했습니다." + : actionConfig.type === "delete" + ? "삭제 중 오류가 발생했습니다." + : actionConfig.type === "submit" + ? "제출 중 오류가 발생했습니다." + : "처리 중 오류가 발생했습니다."; + + // 커스텀 메시지 사용 조건: + // 1. 커스텀 메시지가 있고 + // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) + const useCustomMessage = + actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장")); + + const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage; + + toast.error(errorMessage); + return; + } + + // 성공한 경우에만 성공 토스트 표시 + // edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리 + // (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시) + const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + if (!silentSuccessActions.includes(actionConfig.type)) { + // 기본 성공 메시지 결정 + const defaultSuccessMessage = + actionConfig.type === "save" + ? "저장되었습니다." + : actionConfig.type === "delete" + ? "삭제되었습니다." + : actionConfig.type === "submit" + ? "제출되었습니다." + : "완료되었습니다."; + + // 커스텀 메시지 사용 조건: + // 1. 커스텀 메시지가 있고 + // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) + const useCustomMessage = + actionConfig.successMessage && + (actionConfig.type === "save" || !actionConfig.successMessage.includes("저장")); + + const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage; + + toast.success(successMessage); + } + + // 저장/수정 성공 시 자동 처리 + if (actionConfig.type === "save" || actionConfig.type === "edit") { + if (typeof window !== "undefined") { + // 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에) + window.dispatchEvent(new CustomEvent("refreshTable")); + + // 2. 모달 닫기 (약간의 딜레이) + setTimeout(() => { + // EditModal 내부인지 확인 (isInModal prop 사용) + const isInEditModal = (props as any).isInModal; + + if (isInEditModal) { + window.dispatchEvent(new CustomEvent("closeEditModal")); + } + + // ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생 + window.dispatchEvent(new CustomEvent("saveSuccessInModal")); + }, 100); + } + } + } catch (error) { + // 로딩 토스트 제거 + if (currentLoadingToastRef.current !== undefined) { + toast.dismiss(currentLoadingToastRef.current); + currentLoadingToastRef.current = undefined; + } + + console.error("❌ 버튼 액션 실행 오류:", error); + + // 오류 토스트는 buttonActions.ts에서 이미 표시되므로 여기서는 제거 + // (중복 토스트 방지) + } + }; + + // 이벤트 핸들러 + /** + * transferData 액션 처리 + */ + const handleTransferDataAction = async (actionConfig: any) => { + const dataTransferConfig = actionConfig.dataTransfer; + + if (!dataTransferConfig) { + toast.error("데이터 전달 설정이 없습니다."); + return; + } + + if (!screenContext) { + toast.error("화면 컨텍스트를 찾을 수 없습니다."); + return; + } + + try { + // 1. 소스 컴포넌트에서 데이터 가져오기 + let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + + // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 + // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) + if (!sourceProvider) { + console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); + console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색..."); + + const allProviders = screenContext.getAllDataProviders(); + + // 테이블 리스트 우선 탐색 + for (const [id, provider] of allProviders) { + if (provider.componentType === "table-list") { + sourceProvider = provider; + console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); + break; + } + } + + // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 + if (!sourceProvider && allProviders.size > 0) { + const firstEntry = allProviders.entries().next().value; + if (firstEntry) { + sourceProvider = firstEntry[1]; + console.log( + `✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`, + ); + } + } + + if (!sourceProvider) { + toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다."); + return; + } + } + + const rawSourceData = sourceProvider.getSelectedData(); + + // 🆕 배열이 아닌 경우 배열로 변환 + const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : []; + + console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) }); + + if (!sourceData || sourceData.length === 0) { + toast.warning("선택된 데이터가 없습니다."); + return; + } + + // 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값) + let additionalData: Record = {}; + + // 방법 1: additionalSources 설정에서 가져오기 + if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) { + for (const additionalSource of dataTransferConfig.additionalSources) { + const additionalProvider = screenContext.getDataProvider(additionalSource.componentId); + + if (additionalProvider) { + const additionalValues = additionalProvider.getSelectedData(); + + if (additionalValues && additionalValues.length > 0) { + // 첫 번째 값 사용 (조건부 컨테이너는 항상 1개) + const firstValue = additionalValues[0]; + + // fieldName이 지정되어 있으면 그 필드만 추출 + if (additionalSource.fieldName) { + additionalData[additionalSource.fieldName] = + firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; + } else { + // fieldName이 없으면 전체 객체 병합 + additionalData = { ...additionalData, ...firstValue }; + } + + console.log("📦 추가 데이터 수집 (additionalSources):", { + sourceId: additionalSource.componentId, + fieldName: additionalSource.fieldName, + value: additionalData[additionalSource.fieldName || "all"], + }); + } + } + } + } + + // 방법 2: formData에서 조건부 컨테이너 값 가져오기 (자동) + // ConditionalSectionViewer가 __conditionalContainerValue, __conditionalContainerControlField를 formData에 포함시킴 + if (formData && formData.__conditionalContainerValue) { + // includeConditionalValue 설정이 true이거나 설정이 없으면 자동 포함 + if (dataTransferConfig.includeConditionalValue !== false) { + const conditionalValue = formData.__conditionalContainerValue; + const conditionalLabel = formData.__conditionalContainerLabel; + const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용 + + // 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!) + if (controlField) { + additionalData[controlField] = conditionalValue; + console.log("📦 조건부 컨테이너 값 자동 매핑:", { + controlField, + value: conditionalValue, + label: conditionalLabel, + }); + } else { + // controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기 + for (const [key, value] of Object.entries(formData)) { + if (value === conditionalValue && !key.startsWith("__")) { + additionalData[key] = conditionalValue; + console.log("📦 조건부 컨테이너 값 자동 포함:", { + fieldName: key, + value: conditionalValue, + label: conditionalLabel, + }); + break; + } + } + + // 못 찾았으면 기본 필드명 사용 + if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) { + additionalData["condition_type"] = conditionalValue; + console.log("📦 조건부 컨테이너 값 (기본 필드명):", { + fieldName: "condition_type", + value: conditionalValue, + }); + } + } + } + } + + // 2. 검증 + const validation = dataTransferConfig.validation; + if (validation) { + if (validation.minSelection && sourceData.length < validation.minSelection) { + toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`); + return; + } + if (validation.maxSelection && sourceData.length > validation.maxSelection) { + toast.error(`최대 ${validation.maxSelection}개까지 선택할 수 있습니다.`); + return; + } + } + + // 3. 확인 메시지 + if (dataTransferConfig.confirmBeforeTransfer) { + const confirmMessage = dataTransferConfig.confirmMessage || `${sourceData.length}개 항목을 전달하시겠습니까?`; + if (!window.confirm(confirmMessage)) { + return; + } + } + + // 4. 매핑 규칙 적용 + 추가 데이터 병합 + const mappedData = sourceData.map((row) => { + const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []); + + // 추가 데이터를 모든 행에 포함 + return { + ...mappedRow, + ...additionalData, + }; + }); + + console.log("📦 데이터 전달:", { + sourceData, + mappedData, + targetType: dataTransferConfig.targetType, + targetComponentId: dataTransferConfig.targetComponentId, + targetScreenId: dataTransferConfig.targetScreenId, + }); + + // 5. 타겟으로 데이터 전달 + if (dataTransferConfig.targetType === "component") { + // 같은 화면의 컴포넌트로 전달 + const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId); + + if (!targetReceiver) { + toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`); + return; + } + + await targetReceiver.receiveData(mappedData, { + targetComponentId: dataTransferConfig.targetComponentId, + targetComponentType: targetReceiver.componentType, + mode: dataTransferConfig.mode || "append", + mappingRules: dataTransferConfig.mappingRules || [], + }); + + toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); + } else if (dataTransferConfig.targetType === "splitPanel") { + // 🆕 분할 패널의 반대편 화면으로 전달 + if (!splitPanelContext) { + toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요."); + return; + } + + // 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) + // screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로, + // SplitPanelPositionProvider로 전달된 위치를 우선 사용 + const currentPosition = + splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null); + + if (!currentPosition) { + toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId); + return; + } + + console.log("📦 분할 패널 데이터 전달:", { + currentPosition, + splitPanelPositionFromHook: splitPanelPosition, + screenId, + leftScreenId: splitPanelContext.leftScreenId, + rightScreenId: splitPanelContext.rightScreenId, + }); + + const result = await splitPanelContext.transferToOtherSide( + currentPosition, + mappedData, + dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항) + dataTransferConfig.mode || "append", + ); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + return; + } + } else if (dataTransferConfig.targetType === "screen") { + // 다른 화면으로 전달 (구현 예정) + toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다."); + return; + } else { + toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); + } + + // 6. 전달 후 정리 + if (dataTransferConfig.clearAfterTransfer) { + sourceProvider.clearSelection(); + } + } catch (error: any) { + console.error("❌ 데이터 전달 실패:", error); + toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); + } + }; + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + // 프리뷰 모드에서는 버튼 동작 차단 + if (isPreviewMode) { + return; + } + + // 디자인 모드에서는 기본 onClick만 실행 + if (isDesignMode) { + onClick?.(); + return; + } + + // 인터랙티브 모드에서 액션 실행 + if (isInteractive && processedConfig.action) { + // transferData 액션 처리 (화면 컨텍스트 필요) + if (processedConfig.action.type === "transferData") { + await handleTransferDataAction(processedConfig.action); + return; + } + + // 🆕 선택된 데이터 우선순위: + // 1. selectedRowsData (테이블에서 직접 선택) + // 2. groupedData (부모창에서 모달로 전달된 데이터) + // 3. modalDataStore (분할 패널 등에서 선택한 데이터) + let effectiveSelectedRowsData = selectedRowsData; + + // groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근) + if ( + (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && + groupedData && + groupedData.length > 0 + ) { + effectiveSelectedRowsData = groupedData; + } + + // modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) + // 단, 모달(modal) 액션은 신규 등록이므로 modalDataStore 데이터를 가져오지 않음 + // (다른 화면에서 선택한 데이터가 남아있을 수 있으므로) + const shouldFetchFromModalDataStore = + processedConfig.action.type !== "modal" && + (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && + effectiveTableName; + + if (shouldFetchFromModalDataStore) { + try { + const { useModalDataStore } = await import("@/stores/modalDataStore"); + const dataRegistry = useModalDataStore.getState().dataRegistry; + const modalData = dataRegistry[effectiveTableName]; + if (modalData && modalData.length > 0) { + // modalDataStore는 {id, originalData, additionalData} 형태로 저장됨 + // originalData를 추출하여 실제 행 데이터를 가져옴 + effectiveSelectedRowsData = modalData.map((item: any) => { + // originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성) + return item.originalData || item; + }); + } + } catch (error) { + console.warn("modalDataStore 접근 실패:", error); + } + } + + // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 + const hasDataToDelete = + (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || + (flowSelectedData && flowSelectedData.length > 0); + + if (processedConfig.action.type === "delete" && !hasDataToDelete) { + toast.warning("삭제할 항목을 먼저 선택해주세요."); + return; + } + + // 🔧 모달 액션 시 선택 데이터 경고 제거 + // 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나, + // 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함. + // 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨. + + // 수정(edit) 액션 검증 + if (processedConfig.action.type === "edit") { + // 선택된 데이터가 없으면 경고 + if (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) { + toast.warning("수정할 항목을 선택해주세요."); + return; + } + + // groupByColumns 설정이 있으면 해당 컬럼 값이 유일한지 확인 + const groupByColumns = processedConfig.action.groupByColumns; + if (groupByColumns && groupByColumns.length > 0 && effectiveSelectedRowsData.length > 1) { + // 첫 번째 그룹핑 컬럼 기준으로 중복 체크 (예: order_no) + const groupByColumn = groupByColumns[0]; + const uniqueValues = new Set(effectiveSelectedRowsData.map((row: any) => row[groupByColumn]).filter(Boolean)); + + if (uniqueValues.size > 1) { + // 컬럼명을 한글로 변환 (order_no -> 수주번호) + const columnLabels: Record = { + order_no: "수주번호", + shipment_no: "출하번호", + purchase_no: "구매번호", + }; + const columnLabel = columnLabels[groupByColumn] || groupByColumn; + toast.warning(`${columnLabel} 하나만 선택해주세요. (현재 ${uniqueValues.size}개 선택됨)`); + return; + } + } + } + + // 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등) + const componentConfigs: Record = {}; + if (allComponents && Array.isArray(allComponents)) { + for (const comp of allComponents) { + if (comp.id && comp.componentConfig) { + componentConfigs[comp.id] = comp.componentConfig; + } + } + } + + // 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) + // 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴 + // (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록) + let splitPanelParentData: Record | undefined; + if (splitPanelContext) { + // 우측 화면이거나, 탭 안의 화면(splitPanelPosition이 undefined)인 경우 모두 처리 + // 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨) + if (splitPanelPosition !== "left") { + splitPanelParentData = splitPanelContext.getMappedParentData(); + } + } + + // 🆕 분할 패널 우측이면 여러 소스에서 formData를 병합 + // 우선순위: props.formData > screenContext.formData > splitPanelParentData + const screenContextFormData = screenContext?.formData || {}; + const propsFormData = formData || {}; + + // 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드 + // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음) + let effectiveFormData = { ...propsFormData, ...screenContextFormData }; + + // 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 + if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) { + effectiveFormData = { ...splitPanelParentData }; + } + + const context: ButtonActionContext = { + formData: effectiveFormData, + originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) + screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용 + tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용 + userId, // 🆕 사용자 ID + userName, // 🆕 사용자 이름 + companyCode, // 🆕 회사 코드 + onFormDataChange, + onRefresh, + onClose, + onFlowRefresh, // 플로우 새로고침 콜백 추가 + onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출) + // 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선) + selectedRows, + selectedRowsData: effectiveSelectedRowsData, + // 테이블 정렬 정보 추가 + sortBy, // 🆕 정렬 컬럼 + sortOrder, // 🆕 정렬 방향 + columnOrder, // 🆕 컬럼 순서 + tableDisplayData, // 🆕 화면에 표시된 데이터 + // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용) + allComponents, + // 플로우 선택된 데이터 정보 추가 + flowSelectedData, + flowSelectedStepId, + // 🆕 컴포넌트별 설정 (parentDataMapping 등) + componentConfigs, + // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) + splitPanelParentData, + // 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용) + splitPanelContext: splitPanelContext + ? { + selectedLeftData: splitPanelContext.selectedLeftData, + refreshRightPanel: splitPanelContext.refreshRightPanel, + } + : undefined, + } as ButtonActionContext; + + // 확인이 필요한 액션인지 확인 (save/delete만 확인 다이얼로그 표시) + if (confirmationRequiredActions.includes(processedConfig.action.type)) { + // 확인 다이얼로그 표시 + setPendingAction({ + type: processedConfig.action.type, + config: processedConfig.action, + context, + }); + setShowConfirmDialog(true); + } else { + // 확인이 필요하지 않은 액션은 바로 실행 + await executeAction(processedConfig.action, context); + } + } else { + // 액션이 설정되지 않은 경우 기본 onClick 실행 + onClick?.(); + } + }; + + // 확인 다이얼로그에서 확인 버튼 클릭 시 + const handleConfirmAction = async () => { + if (pendingAction) { + await executeAction(pendingAction.config, pendingAction.context); + } + setShowConfirmDialog(false); + setPendingAction(null); + }; + + // 확인 다이얼로그에서 취소 버튼 클릭 시 + const handleCancelAction = () => { + setShowConfirmDialog(false); + setPendingAction(null); + }; + + // DOM에 전달하면 안 되는 React-specific props 필터링 + 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, + selectedRows: _selectedRows, + selectedRowsData: _selectedRowsData, + onSelectedRowsChange: _onSelectedRowsChange, + flowSelectedData: _flowSelectedData, // 플로우 선택 데이터 필터링 + flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링 + onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링 + originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링 + _originalData: __originalData, // DOM 필터링 + _initialData: __initialData, // DOM 필터링 + _groupedData: __groupedData, // DOM 필터링 + refreshKey: _refreshKey, // 필터링 추가 + isInModal: _isInModal, // 필터링 추가 + mode: _mode, // 필터링 추가 + ...domProps + } = props; + + // 다이얼로그 메시지 생성 + const getConfirmMessage = () => { + if (!pendingAction) return ""; + + const customMessage = pendingAction.config.confirmMessage; + if (customMessage) return customMessage; + + switch (pendingAction.type) { + case "save": + return "변경사항을 저장하시겠습니까?"; + case "delete": + return "정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."; + case "submit": + return "제출하시겠습니까?"; + default: + return "이 작업을 실행하시겠습니까?"; + } + }; + + const getConfirmTitle = () => { + if (!pendingAction) return ""; + + switch (pendingAction.type) { + case "save": + return "저장 확인"; + case "delete": + return "삭제 확인"; + case "submit": + return "제출 확인"; + default: + return "작업 확인"; + } + }; + + // DOM 안전한 props만 필터링 + const safeDomProps = filterDOMProps(domProps); + + // 🆕 플로우 단계별 표시 제어 + if (!shouldShowButton) { + // 레이아웃 동작에 따라 다르게 처리 + if (flowConfig?.layoutBehavior === "preserve-position") { + // 위치 유지 (빈 공간, display: none) + return
; + } else { + // 완전히 렌더링하지 않음 (auto-compact, 빈 공간 제거) + return null; + } + } + + // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수) + const finalDisabled = + componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; + + // 공통 버튼 스타일 + // 🔧 component.style에서 background/backgroundColor 충돌 방지 + const userStyle = component.style + ? Object.fromEntries( + Object.entries(component.style).filter( + ([key]) => !["width", "height", "background", "backgroundColor"].includes(key), + ), + ) + : {}; + + const buttonElementStyle: React.CSSProperties = { + width: "100%", + height: "100%", + minHeight: "40px", + border: "none", + borderRadius: "0.5rem", + backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, + color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원 + // 🔧 크기 설정 적용 (sm/md/lg) + fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", + fontWeight: "600", + cursor: finalDisabled ? "not-allowed" : "pointer", + outline: "none", + boxSizing: "border-box", + display: "flex", + alignItems: "center", + justifyContent: "center", + // 🔧 크기에 따른 패딩 조정 + padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", + margin: "0", + lineHeight: "1.25", + boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height/background 제외) + ...userStyle, + }; + + const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; + + return ( + <> +
+ {isDesignMode ? ( + // 디자인 모드: div로 렌더링하여 선택 가능하게 함 +
+ {buttonContent} +
+ ) : ( + // 일반 모드: button으로 렌더링 + + )} +
+ + {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + + + + {getConfirmTitle()} + {getConfirmMessage()} + + + 취소 + + {pendingAction?.type === "save" + ? "저장" + : pendingAction?.type === "delete" + ? "삭제" + : pendingAction?.type === "submit" + ? "제출" + : "확인"} + + + + + + ); +}; + +/** + * ButtonPrimary 래퍼 컴포넌트 + * 추가적인 로직이나 상태 관리가 필요한 경우 사용 + */ +export const ButtonPrimaryWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryRenderer.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryRenderer.tsx new file mode 100644 index 00000000..0d2850e2 --- /dev/null +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryRenderer.tsx @@ -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 ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // 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(); +} diff --git a/frontend/lib/registry/components/v2-button-primary/README.md b/frontend/lib/registry/components/v2-button-primary/README.md new file mode 100644 index 00000000..fd7d6134 --- /dev/null +++ b/frontend/lib/registry/components/v2-button-primary/README.md @@ -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"; + + +``` + +### 설정 옵션 + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| 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 +// 기본 예시 + +``` + +## 개발자 정보 + +- **생성일**: 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) diff --git a/frontend/lib/registry/components/v2-button-primary/config.ts b/frontend/lib/registry/components/v2-button-primary/config.ts new file mode 100644 index 00000000..06f73556 --- /dev/null +++ b/frontend/lib/registry/components/v2-button-primary/config.ts @@ -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" + }, +}; diff --git a/frontend/lib/registry/components/v2-button-primary/index.ts b/frontend/lib/registry/components/v2-button-primary/index.ts new file mode 100644 index 00000000..57e57d34 --- /dev/null +++ b/frontend/lib/registry/components/v2-button-primary/index.ts @@ -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"; diff --git a/frontend/lib/registry/components/v2-button-primary/types.ts b/frontend/lib/registry/components/v2-button-primary/types.ts new file mode 100644 index 00000000..6ef34298 --- /dev/null +++ b/frontend/lib/registry/components/v2-button-primary/types.ts @@ -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; +} diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx new file mode 100644 index 00000000..e8afb3b3 --- /dev/null +++ b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx @@ -0,0 +1,1316 @@ +"use client"; + +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { CardDisplayConfig } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { getFullImageUrl, apiClient } from "@/lib/api/client"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { useModalDataStore } from "@/stores/modalDataStore"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options"; + +export interface CardDisplayComponentProps extends ComponentRendererProps { + config?: CardDisplayConfig; + tableData?: any[]; + tableColumns?: any[]; +} + +/** + * CardDisplay 컴포넌트 + * 테이블 데이터를 카드 형태로 표시하는 컴포넌트 + */ +export const CardDisplayComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + isInteractive = false, + onClick, + onDragStart, + onDragEnd, + config, + className, + style, + formData, + onFormDataChange, + screenId, + tableName, + tableData = [], + tableColumns = [], + ...props +}) => { + // 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음) + const screenContext = useScreenContextOptional(); + const splitPanelContext = useSplitPanelContext(); + const splitPanelPosition = screenContext?.splitPanelPosition; + + // TableOptions Context (검색 필터 위젯 연동용) + let tableOptionsContext: ReturnType | null = null; + try { + tableOptionsContext = useTableOptions(); + } catch (e) { + // Context가 없으면 (디자이너 모드) 무시 + } + + // 테이블 데이터 상태 관리 + const [loadedTableData, setLoadedTableData] = useState([]); + const [loadedTableColumns, setLoadedTableColumns] = useState([]); + const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정 + const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부 + const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력 + + // 필터 상태 (검색 필터 위젯에서 전달받은 필터) + const [filters, setFiltersInternal] = useState([]); + + // 새로고침 트리거 (refreshCardDisplay 이벤트 수신 시 증가) + const [refreshKey, setRefreshKey] = useState(0); + + // refreshCardDisplay 이벤트 리스너 + useEffect(() => { + const handleRefreshCardDisplay = () => { + console.log("📍 [CardDisplay] refreshCardDisplay 이벤트 수신 - 데이터 새로고침"); + setRefreshKey((prev) => prev + 1); + }; + + window.addEventListener("refreshCardDisplay", handleRefreshCardDisplay); + + return () => { + window.removeEventListener("refreshCardDisplay", handleRefreshCardDisplay); + }; + }, []); + + // 필터 상태 변경 래퍼 + const setFilters = useCallback((newFilters: TableFilter[]) => { + setFiltersInternal(newFilters); + }, []); + + // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) + const [columnMeta, setColumnMeta] = useState< + Record + >({}); + const [categoryMappings, setCategoryMappings] = useState< + Record> + >({}); + + // 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게) + const [selectedRows, setSelectedRows] = useState>(new Set()); + + // 상세보기 모달 상태 + const [viewModalOpen, setViewModalOpen] = useState(false); + const [selectedData, setSelectedData] = useState(null); + + // 편집 모달 상태 + const [editModalOpen, setEditModalOpen] = useState(false); + const [editData, setEditData] = useState(null); + + // 카드 액션 핸들러 + const handleCardView = (data: any) => { + // console.log("👀 상세보기 클릭:", data); + setSelectedData(data); + setViewModalOpen(true); + }; + + const handleCardEdit = (data: any) => { + // console.log("✏️ 편집 클릭:", data); + setEditData({ ...data }); // 복사본 생성 + setEditModalOpen(true); + }; + + // 삭제 핸들러 + const handleCardDelete = async (data: any, index: number) => { + // 사용자 확인 + if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) { + return; + } + + try { + const tableNameToUse = tableName || component.componentConfig?.tableName; + if (!tableNameToUse) { + alert("테이블 정보가 없습니다."); + return; + } + + // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) + const deleteData = [data]; + + + // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) + // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 + // axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용 + const response = await apiClient.request({ + method: 'DELETE', + url: `/table-management/tables/${tableNameToUse}/delete`, + data: deleteData, + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data.success) { + alert("삭제되었습니다."); + + // 로컬 상태에서 삭제된 항목 제거 + setLoadedTableData(prev => prev.filter((item, idx) => idx !== index)); + + // 선택된 항목이면 선택 해제 + const cardKey = getCardKey(data, index); + if (selectedRows.has(cardKey)) { + const newSelectedRows = new Set(selectedRows); + newSelectedRows.delete(cardKey); + setSelectedRows(newSelectedRows); + } + } else { + alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류"; + alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`); + } + }; + + // 편집 폼 데이터 변경 핸들러 + const handleEditFormChange = (key: string, value: string) => { + setEditData((prev: any) => ({ + ...prev, + [key]: value + })); + }; + + // 편집 저장 핸들러 + const handleEditSave = async () => { + // console.log("💾 편집 저장:", editData); + + try { + // TODO: 실제 API 호출로 데이터 업데이트 + // await tableTypeApi.updateTableData(tableName, editData); + + // console.log("✅ 편집 저장 완료"); + alert("✅ 저장되었습니다!"); + + // 모달 닫기 + setEditModalOpen(false); + setEditData(null); + + // 데이터 새로고침 (필요시) + // loadTableData(); + + } catch (error) { + alert("저장에 실패했습니다."); + } + }; + + // 테이블 데이터 로딩 + useEffect(() => { + const loadTableData = async () => { + // 디자인 모드에서는 테이블 데이터를 로드하지 않음 + if (isDesignMode) { + setLoading(false); + setInitialLoadDone(true); + return; + } + + // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) + // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 + const isRightPanelEarly = splitPanelPosition === "right"; + const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + if (isRightPanelEarly && !hasSelectedLeftDataEarly) { + // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) + // 초기 로드가 아닌 경우에는 데이터를 지우지 않음 + if (!initialLoadDone) { + setLoadedTableData([]); + } + setLoading(false); + setInitialLoadDone(true); + return; + } + + // tableName 확인 (props에서 전달받은 tableName 사용) + const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정 + + if (!tableNameToUse) { + setLoading(false); + setInitialLoadDone(true); + return; + } + + // 연결 필터 확인 (분할 패널 내부일 때) + let linkedFilterValues: Record = {}; + let hasLinkedFiltersConfigured = false; + let hasSelectedLeftData = false; + + if (splitPanelContext) { + // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) + const linkedFiltersConfig = splitPanelContext.linkedFilters || []; + hasLinkedFiltersConfigured = linkedFiltersConfig.some( + (filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") || + filter.targetColumn === tableNameToUse + ); + + // 좌측 데이터 선택 여부 확인 + hasSelectedLeftData = splitPanelContext.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + linkedFilterValues = splitPanelContext.getLinkedFilterValues(); + // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) + // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 + const tableSpecificFilters: Record = {}; + for (const [key, value] of Object.entries(linkedFilterValues)) { + // key가 "테이블명.컬럼명" 형식인 경우 + if (key.includes(".")) { + const [tblName, columnName] = key.split("."); + if (tblName === tableNameToUse) { + // 연결 필터는 코드 값이므로 equals 연산자 사용 + tableSpecificFilters[columnName] = { value, operator: "equals" }; + hasLinkedFiltersConfigured = true; + } + } else { + // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) + tableSpecificFilters[key] = { value, operator: "equals" }; + } + } + linkedFilterValues = tableSpecificFilters; + + } + + // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 + // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 + // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 + const isRightPanelFromContext = splitPanelPosition === "right"; + const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId + ? splitPanelContext.getPositionByScreenId(screenId as number) === "right" + : false; + const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; + const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + + + if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { + setLoadedTableData([]); + setLoading(false); + setInitialLoadDone(true); + return; + } + + try { + setLoading(true); + + // API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함) + const apiParams: Record = { + page: 1, + size: 50, // 카드 표시용으로 적당한 개수 + search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, + }; + + // 조인 컬럼 설정 가져오기 (componentConfig에서) + const joinColumnsConfig = component.componentConfig?.joinColumns || []; + const entityJoinColumns = joinColumnsConfig + .filter((col: any) => col.isJoinColumn) + .map((col: any) => ({ + columnName: col.columnName, + sourceColumn: col.sourceColumn, + referenceTable: col.referenceTable, + referenceColumn: col.referenceColumn, + displayColumn: col.referenceColumn, + label: col.label, + joinAlias: col.columnName, // 백엔드에서 필요한 joinAlias 추가 + sourceTable: tableNameToUse, // 기준 테이블 + })); + + // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 + // 조인 컬럼이 있으면 entityJoinApi 사용 + let dataResponse; + if (entityJoinColumns.length > 0) { + console.log("🔗 [CardDisplay] 엔티티 조인 API 사용:", entityJoinColumns); + dataResponse = await entityJoinApi.getTableDataWithJoins(tableNameToUse, { + ...apiParams, + additionalJoinColumns: entityJoinColumns, + }); + } else { + dataResponse = await tableTypeApi.getTableData(tableNameToUse, apiParams); + } + + const [columnsResponse, inputTypesResponse] = await Promise.all([ + tableTypeApi.getColumns(tableNameToUse), + tableTypeApi.getColumnInputTypes(tableNameToUse), + ]); + + setLoadedTableData(dataResponse.data); + setLoadedTableColumns(columnsResponse); + + // 컬럼 메타 정보 설정 (inputType 포함) + const meta: Record = {}; + inputTypesResponse.forEach((item: any) => { + meta[item.columnName || item.column_name] = { + webType: item.webType || item.web_type, + inputType: item.inputType || item.input_type, + codeCategory: item.codeCategory || item.code_category, + }; + }); + setColumnMeta(meta); + + // 카테고리 타입 컬럼 찾기 및 매핑 로드 + const categoryColumns = Object.entries(meta) + .filter(([_, m]) => m.inputType === "category") + .map(([columnName]) => columnName); + + + if (categoryColumns.length > 0) { + const mappings: Record> = {}; + + for (const columnName of categoryColumns) { + try { + const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); + + + if (response.data.success && response.data.data) { + const mapping: Record = {}; + response.data.data.forEach((item: any) => { + // API 응답 형식: valueCode, valueLabel (camelCase) + const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; + const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code; + // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) + const rawColor = item.color ?? item.badge_color; + const color = (rawColor && rawColor !== "none") ? rawColor : undefined; + mapping[code] = { label, color }; + }); + mappings[columnName] = mapping; + } + } catch (error) { + // 카테고리 매핑 로드 실패 시 무시 + } + } + + setCategoryMappings(mappings); + } + } catch (error) { + setLoadedTableData([]); + setLoadedTableColumns([]); + } finally { + setLoading(false); + setInitialLoadDone(true); + } + }; + + loadTableData(); + }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]); + + // 컴포넌트 설정 (기본값 보장) + const componentConfig = { + 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: [], + ...config, + ...component.config, + ...component.componentConfig, + } as CardDisplayConfig; + + // 컴포넌트 기본 스타일 + const componentStyle: React.CSSProperties = { + width: "100%", + height: "100%", + position: "relative", + backgroundColor: "transparent", + }; + + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + // 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정 + + if (isDesignMode) { + componentStyle.border = "1px dashed hsl(var(--border))"; + componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))"; + } + + // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) + const isRightPanelForDisplay = splitPanelPosition === "right" || + (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); + const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData; + const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay && + Object.keys(selectedLeftDataForDisplay).length > 0; + + // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 + useEffect(() => { + if (hasSelectedLeftDataForDisplay) { + setHasEverSelectedLeftData(true); + } + }, [hasSelectedLeftDataForDisplay]); + + // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 + // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 + const shouldHideDataForRightPanel = isRightPanelForDisplay && + !hasEverSelectedLeftData && + !hasSelectedLeftDataForDisplay; + + // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) + const displayData = useMemo(() => { + // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환 + if (shouldHideDataForRightPanel) { + return []; + } + + // 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시) + if (loadedTableData.length > 0) { + return loadedTableData; + } + + // props로 전달받은 테이블 데이터가 있으면 사용 + if (tableData.length > 0) { + return tableData; + } + + if (componentConfig.staticData && componentConfig.staticData.length > 0) { + return componentConfig.staticData; + } + + // 데이터가 없으면 빈 배열 반환 + return []; + }, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]); + + // 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용) + const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns; + + // 카드 ID 가져오기 함수 (훅은 조기 리턴 전에 선언) + const getCardKey = useCallback((data: any, index: number): string => { + return String(data.id || data.objid || data.ID || index); + }, []); + + // 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제) + const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => { + // 단일 선택: 새로운 Set 생성 (기존 선택 초기화) + const newSelectedRows = new Set(); + + if (checked) { + // 선택 시 해당 카드만 선택 + newSelectedRows.add(cardKey); + } + // checked가 false면 빈 Set (선택 해제) + + setSelectedRows(newSelectedRows); + + // 선택된 카드 데이터 계산 + const selectedRowsData = displayData.filter((item, index) => + newSelectedRows.has(getCardKey(item, index)) + ); + + // onFormDataChange 호출 + if (onFormDataChange) { + onFormDataChange({ + selectedRows: Array.from(newSelectedRows), + selectedRowsData, + }); + } + + // modalDataStore에 선택된 데이터 저장 + const tableNameToUse = componentConfig.dataSource?.tableName || tableName; + if (tableNameToUse && selectedRowsData.length > 0) { + const modalItems = selectedRowsData.map((row, idx) => ({ + id: getCardKey(row, idx), + originalData: row, + additionalData: {}, + })); + useModalDataStore.getState().setData(tableNameToUse, modalItems); + } else if (tableNameToUse && selectedRowsData.length === 0) { + useModalDataStore.getState().clearData(tableNameToUse); + } + + // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) + // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) + if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + if (checked) { + splitPanelContext.setSelectedLeftData(data); + } else { + splitPanelContext.setSelectedLeftData(null); + } + } + }, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]); + + const handleCardClick = useCallback((data: any, index: number) => { + const cardKey = getCardKey(data, index); + const isCurrentlySelected = selectedRows.has(cardKey); + + // 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택 + handleCardSelection(cardKey, data, !isCurrentlySelected); + + if (componentConfig.onCardClick) { + componentConfig.onCardClick(data); + } + }, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]); + + // DataProvidable 인터페이스 구현 (테이블 리스트와 동일) + const dataProvider = useMemo(() => ({ + componentId: component.id, + componentType: "card-display" as const, + + getSelectedData: () => { + const selectedData = displayData.filter((item, index) => + selectedRows.has(getCardKey(item, index)) + ); + return selectedData; + }, + + getAllData: () => { + return displayData; + }, + + clearSelection: () => { + setSelectedRows(new Set()); + }, + }), [component.id, displayData, selectedRows, getCardKey]); + + // ScreenContext에 데이터 제공자로 등록 + useEffect(() => { + if (screenContext && component.id) { + screenContext.registerDataProvider(component.id, dataProvider); + + return () => { + screenContext.unregisterDataProvider(component.id); + }; + } + }, [screenContext, component.id, dataProvider]); + + // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) + const tableId = `card-display-${component.id}`; + const tableNameToUse = tableName || component.componentConfig?.tableName || ''; + const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; + + // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) + const loadedTableDataRef = useRef(loadedTableData); + const categoryMappingsRef = useRef(categoryMappings); + + useEffect(() => { + loadedTableDataRef.current = loadedTableData; + }, [loadedTableData]); + + useEffect(() => { + categoryMappingsRef.current = categoryMappings; + }, [categoryMappings]); + + // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) + // 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응) + const mountCountRef = useRef(0); + + useEffect(() => { + mountCountRef.current += 1; + const currentMount = mountCountRef.current; + + if (!tableNameToUse || isDesignMode) return; + + // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 + const isRightPanel = splitPanelPosition === "right" || + (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); + const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + const hasSelectedLeftData = splitPanelContext?.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) + if (isRightPanel && !hasSelectedLeftData) { + // 데이터를 지우지 않고 로딩만 false로 설정 + setLoading(false); + return; + } + + // 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨) + // 필터 변경이 아닌 경우 스킵 + if (currentMount <= 2 && filters.length === 0) { + return; + } + + const loadFilteredData = async () => { + try { + // 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지) + + // 필터 값을 검색 파라미터로 변환 + const searchParams: Record = {}; + filters.forEach(filter => { + if (filter.value !== undefined && filter.value !== null && filter.value !== '') { + searchParams[filter.columnName] = filter.value; + } + }); + + // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) + const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { + page: 1, + size: 50, + search: searchParams, + }); + + setLoadedTableData(dataResponse.data); + + // 데이터 건수 업데이트 + if (tableOptionsContext) { + tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); + } + } catch (error) { + // 필터 적용 실패 시 무시 + } + }; + + // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) + loadFilteredData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]); + + // 컬럼 고유 값 조회 함수 (select 타입 필터용) + const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { + if (!tableNameToUse) return []; + + try { + // 현재 로드된 데이터에서 고유 값 추출 + const uniqueValues = new Set(); + loadedTableDataRef.current.forEach(row => { + const value = row[columnName]; + if (value !== null && value !== undefined && value !== '') { + uniqueValues.add(String(value)); + } + }); + + // 카테고리 매핑이 있으면 라벨 적용 + const mapping = categoryMappingsRef.current[columnName]; + return Array.from(uniqueValues).map(value => ({ + value, + label: mapping?.[value]?.label || value, + })); + } catch (error) { + return []; + } + }, [tableNameToUse]); + + // TableOptionsContext에 등록 + // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) + const registerTableRef = useRef(tableOptionsContext?.registerTable); + const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable); + + // setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용) + const setFiltersRef = useRef(setFiltersInternal); + const getColumnUniqueValuesRef = useRef(getColumnUniqueValues); + + useEffect(() => { + registerTableRef.current = tableOptionsContext?.registerTable; + unregisterTableRef.current = tableOptionsContext?.unregisterTable; + }, [tableOptionsContext]); + + useEffect(() => { + setFiltersRef.current = setFiltersInternal; + }, [setFiltersInternal]); + + useEffect(() => { + getColumnUniqueValuesRef.current = getColumnUniqueValues; + }, [getColumnUniqueValues]); + + // 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록) + const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name)); + + useEffect(() => { + if (!registerTableRef.current || !unregisterTableRef.current) return; + if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return; + + // 컬럼 정보를 TableColumn 형식으로 변환 + const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text', + visible: true, + width: 200, + sortable: true, + filterable: true, + })); + + // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 + const onFilterChangeWrapper = (newFilters: TableFilter[]) => { + setFiltersRef.current(newFilters); + }; + + const getColumnUniqueValuesWrapper = async (columnName: string) => { + return getColumnUniqueValuesRef.current(columnName); + }; + + const registration = { + tableId, + label: tableLabel, + tableName: tableNameToUse, + columns, + dataCount: loadedTableData.length, + onFilterChange: onFilterChangeWrapper, + onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원 + onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원 + getColumnUniqueValues: getColumnUniqueValuesWrapper, + }; + + registerTableRef.current(registration); + + const unregister = unregisterTableRef.current; + const currentTableId = tableId; + + return () => { + unregister(currentTableId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isDesignMode, + tableId, + tableNameToUse, + tableLabel, + columnsKey, // 컬럼 변경 시에만 재등록 + ]); + + // 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 + // 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) + if (shouldHideDataForRightPanel) { + return ( +
+
+
좌측에서 항목을 선택해주세요
+
선택한 항목의 관련 데이터가 여기에 표시됩니다
+
+
+ ); + } + + // 로딩 중이고 데이터가 없는 경우에만 로딩 표시 + // 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) + if (loading && displayData.length === 0 && !hasEverSelectedLeftData) { + return ( +
+
테이블 데이터를 로드하는 중...
+
+ ); + } + + // 컨테이너 스타일 - 통일된 디자인 시스템 적용 + const containerStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수) + gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시 + gap: `${componentConfig.cardSpacing || 16}px`, // 카드 간격 + padding: "16px", // 패딩 + width: "100%", + height: "100%", + background: "transparent", // 배경색 제거 + overflow: "auto", + borderRadius: "0", // 라운드 제거 + }; + + // 카드 스타일 - 컴팩트한 디자인 + const cardStyle: React.CSSProperties = { + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + padding: "16px", + boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)", + transition: "all 0.2s ease", + overflow: "hidden", + display: "flex", + flexDirection: "column", + position: "relative", + cursor: isDesignMode ? "pointer" : "default", + width: "100%", // 전체 너비 차지 + }; + + // 텍스트 자르기 함수 + const truncateText = (text: string, maxLength: number) => { + if (!text) return ""; + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + "..."; + }; + + // 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환) + const getColumnValueAsString = (data: any, columnName?: string): string => { + if (!columnName) return ""; + const value = data[columnName]; + if (value === null || value === undefined || value === "") return ""; + + // 카테고리 타입인 경우 매핑된 라벨 반환 + const meta = columnMeta[columnName]; + if (meta?.inputType === "category") { + const mapping = categoryMappings[columnName]; + const valueStr = String(value); + const categoryData = mapping?.[valueStr]; + return categoryData?.label || valueStr; + } + + return String(value); + }; + + // 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시) + const getColumnValue = (data: any, columnName?: string): React.ReactNode => { + if (!columnName) return ""; + const value = data[columnName]; + if (value === null || value === undefined || value === "") return ""; + + // 카테고리 타입인 경우 매핑된 라벨과 배지로 표시 + const meta = columnMeta[columnName]; + if (meta?.inputType === "category") { + const mapping = categoryMappings[columnName]; + const valueStr = String(value); + const categoryData = mapping?.[valueStr]; + const displayLabel = categoryData?.label || valueStr; + const displayColor = categoryData?.color; + + // 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음) + if (!displayColor || displayColor === "none") { + return displayLabel; + } + + return ( + + {displayLabel} + + ); + } + + return String(value); + }; + + // 컬럼명을 라벨로 변환하는 헬퍼 함수 + const getColumnLabel = (columnName: string) => { + if (!actualTableColumns || actualTableColumns.length === 0) { + // 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환 + return formatColumnName(columnName); + } + const column = actualTableColumns.find( + (col) => col.columnName === columnName || col.column_name === columnName + ); + // 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨) + const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label; + return label || formatColumnName(columnName); + }; + + // 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분) + const formatColumnName = (columnName: string) => { + // 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화 + return columnName + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); + }; + + // 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기 + const getAutoFallbackValue = (data: any, type: "title" | "subtitle" | "description") => { + const keys = Object.keys(data); + switch (type) { + case "title": + // 이름 관련 필드 우선 검색 + return data.name || data.title || data.label || data[keys[0]] || "제목 없음"; + case "subtitle": + // 직책, 부서, 카테고리 관련 필드 검색 + return data.position || data.role || data.department || data.category || data.type || ""; + case "description": + // 설명, 내용 관련 필드 검색 + return data.description || data.content || data.summary || data.memo || ""; + default: + return ""; + } + }; + + // 이벤트 핸들러 + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(); + }; + + // DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용) + const safeDomProps = filterDOMProps(props); + + return ( + <> + +
+
+ {displayData.length === 0 ? ( +
+ 표시할 데이터가 없습니다. +
+ ) : ( + displayData.map((data, index) => { + // 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시) + const titleValue = + getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title"); + + const subtitleValue = + getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) || + getAutoFallbackValue(data, "subtitle"); + + const descriptionValue = + getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) || + getAutoFallbackValue(data, "description"); + + // 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시 + const imageColumn = componentConfig.columnMapping?.imageColumn || + Object.keys(data).find(key => { + const lowerKey = key.toLowerCase(); + return lowerKey.includes('image') || lowerKey.includes('photo') || + lowerKey.includes('avatar') || lowerKey.includes('thumbnail') || + lowerKey.includes('picture') || lowerKey.includes('img'); + }); + + // 이미지 값 가져오기 (직접 접근 + 폴백) + const imageValue = imageColumn + ? data[imageColumn] + : (data.image_path || data.imagePath || data.avatar || data.image || data.photo || ""); + + // 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우 + const shouldShowImage = componentConfig.cardStyle?.showImage !== false; + + // 이미지 URL 생성 (TableListComponent와 동일한 로직 사용) + const imageUrl = imageValue ? getFullImageUrl(imageValue) : ""; + + const cardKey = getCardKey(data, index); + const isCardSelected = selectedRows.has(cardKey); + + return ( +
handleCardClick(data, index)} + > + {/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */} + {shouldShowImage && ( +
+ {imageUrl ? ( + {titleValue { + // 이미지 로드 실패 시 기본 아이콘으로 대체 + e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E"; + }} + /> + ) : ( +
+ 👤 +
+ )} +
+ )} + + {/* 우측 컨텐츠 영역 */} +
+ {/* 타이틀 + 서브타이틀 */} + {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && ( +
+ {componentConfig.cardStyle?.showTitle && ( +

{titleValue}

+ )} + {componentConfig.cardStyle?.showSubtitle && subtitleValue && ( + {subtitleValue} + )} +
+ )} + + {/* 추가 표시 컬럼들 - 가로 배치 */} + {componentConfig.columnMapping?.displayColumns && + componentConfig.columnMapping.displayColumns.length > 0 && ( +
+ {componentConfig.columnMapping.displayColumns.map((columnName, idx) => { + const value = getColumnValue(data, columnName); + if (!value) return null; + + return ( +
+ {getColumnLabel(columnName)}: + {value} +
+ ); + })} +
+ )} + + {/* 카드 설명 */} + {componentConfig.cardStyle?.showDescription && descriptionValue && ( +
+

+ {truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)} +

+
+ )} + + {/* 카드 액션 - 설정에 따라 표시 */} + {(componentConfig.cardStyle?.showActions ?? true) && ( +
+ {(componentConfig.cardStyle?.showViewButton ?? true) && ( + + )} + {(componentConfig.cardStyle?.showEditButton ?? true) && ( + + )} + {(componentConfig.cardStyle?.showDeleteButton ?? false) && ( + + )} +
+ )} +
+
+ ); + }) + )} +
+
+ + {/* 상세보기 모달 */} + + + + + 📋 + 상세 정보 + + + + {selectedData && ( +
+
+ {Object.entries(selectedData) + .filter(([key, value]) => value !== null && value !== undefined && value !== '') + .map(([key, value]) => { + // 카테고리 타입인 경우 배지로 표시 + const meta = columnMeta[key]; + let displayValue: React.ReactNode = String(value); + + if (meta?.inputType === "category") { + const mapping = categoryMappings[key]; + const valueStr = String(value); + const categoryData = mapping?.[valueStr]; + const displayLabel = categoryData?.label || valueStr; + const displayColor = categoryData?.color; + + // 색상이 있고 "none"이 아닌 경우에만 배지로 표시 + if (displayColor && displayColor !== "none") { + displayValue = ( + + {displayLabel} + + ); + } else { + // 배지 없음: 일반 텍스트로 표시 + displayValue = displayLabel; + } + } + + return ( +
+
+ {getColumnLabel(key)} +
+
+ {displayValue} +
+
+ ); + }) + } +
+ +
+ +
+
+ )} +
+
+ + {/* 편집 모달 */} + + + + + ✏️ + 데이터 편집 + + + + {editData && ( +
+
+ {Object.entries(editData) + .filter(([key, value]) => value !== null && value !== undefined) + .map(([key, value]) => ( +
+ + handleEditFormChange(key, e.target.value)} + className="w-full" + placeholder={`${key} 입력`} + /> +
+ )) + } +
+ +
+ + +
+
+ )} +
+
+ + ); +}; diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayConfigPanel.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayConfigPanel.tsx new file mode 100644 index 00000000..b13a5946 --- /dev/null +++ b/frontend/lib/registry/components/v2-card-display/CardDisplayConfigPanel.tsx @@ -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 = ({ + config, + onChange, + screenTableName, + tableColumns = [], +}) => { + // 테이블 선택 상태 + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [allTables, setAllTables] = useState>([]); + const [loadingTables, setLoadingTables] = useState(false); + const [availableColumns, setAvailableColumns] = useState([]); + 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 = {}; + 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 ( + + ); + }; + + return ( +
+
카드 디스플레이 설정
+ + {/* 테이블 선택 */} +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {/* 화면 기본 테이블 */} + {screenTableName && ( + + handleTableSelect(screenTableName, true)} + className="text-xs" + > + + + {allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName} + + + )} + + {/* 전체 테이블 */} + + {allTables + .filter(t => t.tableName !== screenTableName) + .map((table) => ( + handleTableSelect(table.tableName, false)} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + {config.useCustomTable && ( +

+ 화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다. +

+ )} +
+ + {/* 테이블이 선택된 경우 컬럼 매핑 설정 */} + {(currentTableColumns.length > 0 || loadingColumns) && ( +
+
컬럼 매핑
+ + {(loadingEntityJoins || loadingColumns) && ( +
+ {loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."} +
+ )} + +
+ + {renderColumnSelect( + config.columnMapping?.titleColumn || "", + (value) => handleColumnSelect("columnMapping.titleColumn", value) + )} +
+ +
+ + {renderColumnSelect( + config.columnMapping?.subtitleColumn || "", + (value) => handleColumnSelect("columnMapping.subtitleColumn", value) + )} +
+ +
+ + {renderColumnSelect( + config.columnMapping?.descriptionColumn || "", + (value) => handleColumnSelect("columnMapping.descriptionColumn", value) + )} +
+ +
+ + {renderColumnSelect( + config.columnMapping?.imageColumn || "", + (value) => handleColumnSelect("columnMapping.imageColumn", value) + )} +
+ + {/* 동적 표시 컬럼 추가 */} +
+
+ + +
+ +
+ {(config.columnMapping?.displayColumns || []).map((column: string, index: number) => ( +
+
+ {renderColumnSelect( + column, + (value) => updateDisplayColumn(index, value) + )} +
+ +
+ ))} + + {(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && ( +
+ "컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요 +
+ )} +
+
+
+ )} + + {/* 카드 스타일 설정 */} +
+
카드 스타일
+ +
+
+ + handleChange("cardsPerRow", parseInt(e.target.value))} + className="h-8 text-xs" + /> +
+ +
+ + handleChange("cardSpacing", parseInt(e.target.value))} + className="h-8 text-xs" + /> +
+
+ +
+
+ handleNestedChange("cardStyle.showTitle", checked)} + /> + +
+ +
+ handleNestedChange("cardStyle.showSubtitle", checked)} + /> + +
+ +
+ handleNestedChange("cardStyle.showDescription", checked)} + /> + +
+ +
+ handleNestedChange("cardStyle.showImage", checked)} + /> + +
+ +
+ handleNestedChange("cardStyle.showActions", checked)} + /> + +
+ + {/* 개별 버튼 설정 */} + {(config.cardStyle?.showActions ?? true) && ( +
+
+ handleNestedChange("cardStyle.showViewButton", checked)} + /> + +
+ +
+ handleNestedChange("cardStyle.showEditButton", checked)} + /> + +
+ +
+ handleNestedChange("cardStyle.showDeleteButton", checked)} + /> + +
+
+ )} +
+ +
+ + handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))} + className="h-8 text-xs" + /> +
+
+ + {/* 공통 설정 */} +
+
공통 설정
+ +
+ handleChange("disabled", checked)} + /> + +
+ +
+ handleChange("readonly", checked)} + /> + +
+
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayRenderer.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayRenderer.tsx new file mode 100644 index 00000000..b1d927e8 --- /dev/null +++ b/frontend/lib/registry/components/v2-card-display/CardDisplayRenderer.tsx @@ -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 ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // text 타입 특화 속성 처리 + protected getCardDisplayProps() { + const baseProps = this.getWebTypeProps(); + + // text 타입에 특화된 추가 속성들 + return { + ...baseProps, + // 여기에 text 타입 특화 속성들 추가 + }; + } + + // 값 변경 처리 + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; + + // 포커스 처리 + protected handleFocus = () => { + // 포커스 로직 + }; + + // 블러 처리 + protected handleBlur = () => { + // 블러 로직 + }; +} + +// 자동 등록 실행 +CardDisplayRenderer.registerSelf(); diff --git a/frontend/lib/registry/components/v2-card-display/README.md b/frontend/lib/registry/components/v2-card-display/README.md new file mode 100644 index 00000000..e2811a52 --- /dev/null +++ b/frontend/lib/registry/components/v2-card-display/README.md @@ -0,0 +1,93 @@ +# CardDisplay 컴포넌트 + +테이블 데이터를 카드 형태로 표시하는 컴포넌트 + +## 개요 + +- **ID**: `card-display` +- **카테고리**: display +- **웹타입**: text +- **작성자**: 개발팀 +- **버전**: 1.0.0 + +## 특징 + +- ✅ 자동 등록 시스템 +- ✅ 타입 안전성 +- ✅ Hot Reload 지원 +- ✅ 설정 패널 제공 +- ✅ 반응형 디자인 + +## 사용법 + +### 기본 사용법 + +```tsx +import { CardDisplayComponent } from "@/lib/registry/components/card-display"; + + +``` + +### 설정 옵션 + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| 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 +// 기본 예시 + +``` + +## 개발자 정보 + +- **생성일**: 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) diff --git a/frontend/lib/registry/components/v2-card-display/index.ts b/frontend/lib/registry/components/v2-card-display/index.ts new file mode 100644 index 00000000..4e219fee --- /dev/null +++ b/frontend/lib/registry/components/v2-card-display/index.ts @@ -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"; diff --git a/frontend/lib/registry/components/v2-card-display/types.ts b/frontend/lib/registry/components/v2-card-display/types.ts new file mode 100644 index 00000000..368e43cc --- /dev/null +++ b/frontend/lib/registry/components/v2-card-display/types.ts @@ -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; +} diff --git a/frontend/lib/registry/components/v2-divider-line/DividerLineComponent.tsx b/frontend/lib/registry/components/v2-divider-line/DividerLineComponent.tsx new file mode 100644 index 00000000..ea4428ca --- /dev/null +++ b/frontend/lib/registry/components/v2-divider-line/DividerLineComponent.tsx @@ -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 = ({ + 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 ( +
+ {/* 라벨 렌더링 */} + {component.label && component.style?.labelDisplay !== false && ( + + )} + +
+ {componentConfig.dividerText ? ( +
+
+ + {componentConfig.dividerText} + +
+
+ ) : ( +
+ )} +
+
+ ); +}; + +/** + * DividerLine 래퍼 컴포넌트 + * 추가적인 로직이나 상태 관리가 필요한 경우 사용 + */ +export const DividerLineWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-divider-line/DividerLineConfigPanel.tsx b/frontend/lib/registry/components/v2-divider-line/DividerLineConfigPanel.tsx new file mode 100644 index 00000000..5e546e90 --- /dev/null +++ b/frontend/lib/registry/components/v2-divider-line/DividerLineConfigPanel.tsx @@ -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) => void; +} + +/** + * DividerLine 설정 패널 + * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 + */ +export const DividerLineConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const handleChange = (key: keyof DividerLineConfig, value: any) => { + onChange({ [key]: value }); + }; + + return ( +
+
+ divider-line 설정 +
+ + {/* 텍스트 관련 설정 */} +
+ + handleChange("placeholder", e.target.value)} + /> +
+ +
+ + handleChange("maxLength", parseInt(e.target.value) || undefined)} + /> +
+ + {/* 공통 설정 */} +
+ + handleChange("disabled", checked)} + /> +
+ +
+ + handleChange("required", checked)} + /> +
+ +
+ + handleChange("readonly", checked)} + /> +
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-divider-line/DividerLineRenderer.tsx b/frontend/lib/registry/components/v2-divider-line/DividerLineRenderer.tsx new file mode 100644 index 00000000..ffe676f0 --- /dev/null +++ b/frontend/lib/registry/components/v2-divider-line/DividerLineRenderer.tsx @@ -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 ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // 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(); +} diff --git a/frontend/lib/registry/components/v2-divider-line/README.md b/frontend/lib/registry/components/v2-divider-line/README.md new file mode 100644 index 00000000..7564c9ad --- /dev/null +++ b/frontend/lib/registry/components/v2-divider-line/README.md @@ -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"; + + +``` + +### 설정 옵션 + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| 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 +// 기본 예시 + +``` + +## 개발자 정보 + +- **생성일**: 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) diff --git a/frontend/lib/registry/components/v2-divider-line/config.ts b/frontend/lib/registry/components/v2-divider-line/config.ts new file mode 100644 index 00000000..300d6e3b --- /dev/null +++ b/frontend/lib/registry/components/v2-divider-line/config.ts @@ -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" + }, +}; diff --git a/frontend/lib/registry/components/v2-divider-line/index.ts b/frontend/lib/registry/components/v2-divider-line/index.ts new file mode 100644 index 00000000..274cded1 --- /dev/null +++ b/frontend/lib/registry/components/v2-divider-line/index.ts @@ -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"; diff --git a/frontend/lib/registry/components/v2-divider-line/types.ts b/frontend/lib/registry/components/v2-divider-line/types.ts new file mode 100644 index 00000000..c244ff26 --- /dev/null +++ b/frontend/lib/registry/components/v2-divider-line/types.ts @@ -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; +} diff --git a/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorComponent.tsx new file mode 100644 index 00000000..88e9002a --- /dev/null +++ b/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorComponent.tsx @@ -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; + 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(DEFAULT_OPTIONS); + const [loading, setLoading] = useState(false); + const [isSwapping, setIsSwapping] = useState(false); + const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부 + + // 로컬 선택 상태 (Select 컴포넌트용) + const [localDeparture, setLocalDeparture] = useState(""); + const [localDestination, setLocalDestination] = useState(""); + + // 옵션 로드 + 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 ( +
+
+ {/* 출발지 */} +
+ {departureLabel} + +
+ + {/* 교환 버튼 */} + {showSwapButton && ( + + )} + + {/* 도착지 */} +
+ {destinationLabel} + +
+
+
+ ); + } + + // Inline 스타일 + if (variant === "inline") { + return ( +
+
+ + +
+ + {showSwapButton && ( + + )} + +
+ + +
+
+ ); + } + + // Minimal 스타일 + return ( +
+ + + {showSwapButton && ( + + )} + + +
+ ); +} + diff --git a/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorConfigPanel.tsx b/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorConfigPanel.tsx new file mode 100644 index 00000000..cd84806f --- /dev/null +++ b/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorConfigPanel.tsx @@ -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>([]); + const [columns, setColumns] = useState>([]); + const [codeCategories, setCodeCategories] = useState>([]); + + // 테이블 목록 로드 + 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 ( +
+ {/* 데이터 소스 타입 */} +
+ + +
+ + {/* 고정 옵션 설정 (type이 static일 때) */} + {(!config?.dataSource?.type || config?.dataSource?.type === "static") && ( +
+

고정 옵션 설정

+
+
+ + { + 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" + /> +
+
+ + { + 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" + /> +
+
+
+
+ + { + 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" + /> +
+
+ + { + 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" + /> +
+
+

+ 고정된 2개 장소만 사용할 때 설정하세요. (예: 포항 ↔ 광양) +

+
+ )} + + {/* 테이블 선택 (type이 table일 때) */} + {config?.dataSource?.type === "table" && ( + <> +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + )} + + {/* 코드 카테고리 선택 (type이 code일 때) */} + {config?.dataSource?.type === "code" && ( +
+ + +
+ )} + + {/* 필드 매핑 */} +
+

필드 매핑 (저장 위치)

+ {screenTableName && ( +

+ 현재 화면 테이블: {screenTableName} +

+ )} +
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("departureField", e.target.value)} + placeholder="departure" + /> + )} +
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("destinationField", e.target.value)} + placeholder="destination" + /> + )} +
+
+
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("departureLabelField", e.target.value)} + placeholder="departure_name" + /> + )} +
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("destinationLabelField", e.target.value)} + placeholder="destination_name" + /> + )} +
+
+
+ + {/* UI 설정 */} +
+

UI 설정

+
+
+ + handleChange("departureLabel", e.target.value)} + /> +
+
+ + handleChange("destinationLabel", e.target.value)} + /> +
+
+ +
+ + +
+ +
+ + handleChange("showSwapButton", checked)} + /> +
+
+ + {/* DB 초기값 로드 설정 */} +
+

DB 초기값 로드

+

+ 새로고침 시에도 DB에 저장된 출발지/목적지를 자동으로 불러옵니다 +

+ +
+ + handleChange("loadFromDb", checked)} + /> +
+ + {config?.loadFromDb !== false && ( + <> +
+ + +
+ +
+ + handleChange("dbKeyField", e.target.value)} + placeholder="user_id" + /> +

+ 현재 사용자 ID로 조회할 필드 (기본: user_id) +

+
+ + )} +
+ + {/* 안내 */} +
+

+ 사용 방법: +
+ 1. 데이터 소스에서 장소 목록을 가져올 위치를 선택합니다 +
+ 2. 출발지/도착지 값이 저장될 필드를 지정합니다 +
+ 3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다 +
+ 4. DB 초기값 로드를 활성화하면 새로고침 후에도 값이 유지됩니다 +

+
+
+ ); +} + diff --git a/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorRenderer.tsx b/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorRenderer.tsx new file mode 100644 index 00000000..64d99d6f --- /dev/null +++ b/frontend/lib/registry/components/v2-location-swap-selector/LocationSwapSelectorRenderer.tsx @@ -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 ( + + ); + } +} + +// 자동 등록 실행 +LocationSwapSelectorRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + LocationSwapSelectorRenderer.enableHotReload(); +} + diff --git a/frontend/lib/registry/components/v2-location-swap-selector/index.ts b/frontend/lib/registry/components/v2-location-swap-selector/index.ts new file mode 100644 index 00000000..33455afb --- /dev/null +++ b/frontend/lib/registry/components/v2-location-swap-selector/index.ts @@ -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"; + diff --git a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleComponent.tsx b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleComponent.tsx new file mode 100644 index 00000000..6f1048f9 --- /dev/null +++ b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleComponent.tsx @@ -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 = ({ + config, + onChange, + isPreview = false, + tableName, + menuObjid, +}) => { + console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", { + tableName, + menuObjid, + hasMenuObjid: !!menuObjid, + config + }); + + return ( +
+ +
+ ); +}; + +export const NumberingRuleComponent = NumberingRuleWrapper; diff --git a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel.tsx b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel.tsx new file mode 100644 index 00000000..332d4055 --- /dev/null +++ b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel.tsx @@ -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 = ({ + config, + onChange, +}) => { + return ( +
+
+ + + onChange({ ...config, maxRules: parseInt(e.target.value) || 6 }) + } + className="h-9" + /> +

+ 한 규칙에 추가할 수 있는 최대 파트 개수 (1-10) +

+
+ +
+
+ +

+ 편집 기능을 비활성화합니다 +

+
+ + onChange({ ...config, readonly: checked }) + } + /> +
+ +
+
+ +

+ 코드 미리보기를 항상 표시합니다 +

+
+ + onChange({ ...config, showPreview: checked }) + } + /> +
+ +
+
+ +

+ 저장된 규칙 목록을 표시합니다 +

+
+ + onChange({ ...config, showRuleList: checked }) + } + /> +
+ +
+ + +

+ 규칙 파트 카드의 배치 방향 +

+
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleRenderer.tsx b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleRenderer.tsx new file mode 100644 index 00000000..5a4b1325 --- /dev/null +++ b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleRenderer.tsx @@ -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 ; + } + + /** + * 채번 규칙 컴포넌트 특화 메서드 + */ + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +NumberingRuleRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + NumberingRuleRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-numbering-rule/README.md b/frontend/lib/registry/components/v2-numbering-rule/README.md new file mode 100644 index 00000000..5d04d894 --- /dev/null +++ b/frontend/lib/registry/components/v2-numbering-rule/README.md @@ -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 + +``` + +## 데이터베이스 구조 + +### 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 +- **작성자**: 개발팀 + diff --git a/frontend/lib/registry/components/v2-numbering-rule/config.ts b/frontend/lib/registry/components/v2-numbering-rule/config.ts new file mode 100644 index 00000000..87e5c996 --- /dev/null +++ b/frontend/lib/registry/components/v2-numbering-rule/config.ts @@ -0,0 +1,15 @@ +/** + * 채번 규칙 컴포넌트 기본 설정 + */ + +import { NumberingRuleComponentConfig } from "./types"; + +export const defaultConfig: NumberingRuleComponentConfig = { + maxRules: 6, + readonly: false, + showPreview: true, + showRuleList: true, + enableReorder: false, + cardLayout: "vertical", +}; + diff --git a/frontend/lib/registry/components/v2-numbering-rule/index.ts b/frontend/lib/registry/components/v2-numbering-rule/index.ts new file mode 100644 index 00000000..92d42d34 --- /dev/null +++ b/frontend/lib/registry/components/v2-numbering-rule/index.ts @@ -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"; + diff --git a/frontend/lib/registry/components/v2-numbering-rule/types.ts b/frontend/lib/registry/components/v2-numbering-rule/types.ts new file mode 100644 index 00000000..43def2cb --- /dev/null +++ b/frontend/lib/registry/components/v2-numbering-rule/types.ts @@ -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"; +} diff --git a/frontend/lib/registry/components/v2-pivot-grid/PLAN.md b/frontend/lib/registry/components/v2-pivot-grid/PLAN.md new file mode 100644 index 00000000..7b96ab38 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/PLAN.md @@ -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 diff --git a/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx new file mode 100644 index 00000000..b55907a4 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx @@ -0,0 +1,1963 @@ +"use client"; + +/** + * PivotGrid 메인 컴포넌트 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ + +import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridProps, + PivotResult, + PivotFieldConfig, + PivotCellData, + PivotFlatRow, + PivotCellValue, + PivotGridState, +} from "./types"; +import { processPivotData, pathToKey } from "./utils/pivotEngine"; +import { exportPivotToExcel } from "./utils/exportExcel"; +import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat"; +import { FieldPanel } from "./components/FieldPanel"; +import { FieldChooser } from "./components/FieldChooser"; +import { DrillDownModal } from "./components/DrillDownModal"; +import { PivotChart } from "./components/PivotChart"; +import { FilterPopup } from "./components/FilterPopup"; +import { useVirtualScroll } from "./hooks/useVirtualScroll"; +import { + ChevronRight, + ChevronDown, + Download, + Settings, + RefreshCw, + Maximize2, + Minimize2, + LayoutGrid, + FileSpreadsheet, + BarChart3, + Filter, + ArrowUp, + ArrowDown, + ArrowUpDown, + Printer, + Save, + RotateCcw, + FileText, + Loader2, + Eye, + EyeOff, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ==================== 유틸리티 함수 ==================== + +// 셀 병합 정보 계산 +interface MergeCellInfo { + rowSpan: number; + skip: boolean; // 병합된 셀에서 건너뛸지 여부 +} + +const calculateMergeCells = ( + rows: PivotFlatRow[], + mergeCells: boolean +): Map => { + const mergeInfo = new Map(); + + if (!mergeCells || rows.length === 0) { + rows.forEach((_, idx) => mergeInfo.set(idx, { rowSpan: 1, skip: false })); + return mergeInfo; + } + + let i = 0; + while (i < rows.length) { + const currentPath = rows[i].path.join("|||"); + let spanCount = 1; + + // 같은 path를 가진 연속 행 찾기 + while ( + i + spanCount < rows.length && + rows[i + spanCount].path.join("|||") === currentPath + ) { + spanCount++; + } + + // 첫 번째 행은 rowSpan 설정 + mergeInfo.set(i, { rowSpan: spanCount, skip: false }); + + // 나머지 행은 skip + for (let j = 1; j < spanCount; j++) { + mergeInfo.set(i + j, { rowSpan: 1, skip: true }); + } + + i += spanCount; + } + + return mergeInfo; +}; + +// ==================== 서브 컴포넌트 ==================== + +// 행 헤더 셀 +interface RowHeaderCellProps { + row: PivotFlatRow; + rowFields: PivotFieldConfig[]; + onToggleExpand: (path: string[]) => void; + rowSpan?: number; +} + +const RowHeaderCell: React.FC = ({ + row, + rowFields, + onToggleExpand, + rowSpan = 1, +}) => { + const indentSize = row.level * 20; + + return ( + 1 ? rowSpan : undefined} + > +
+ {row.hasChildren && ( + + )} + {!row.hasChildren && } + {row.caption} +
+ + ); +}; + +// 데이터 셀 +interface DataCellProps { + values: PivotCellValue[]; + isTotal?: boolean; + isSelected?: boolean; + onClick?: (e?: React.MouseEvent) => void; + onDoubleClick?: () => void; + conditionalStyle?: CellFormatStyle; +} + +const DataCell: React.FC = ({ + values, + isTotal = false, + isSelected = false, + onClick, + onDoubleClick, + conditionalStyle, +}) => { + // 조건부 서식 스타일 계산 + const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; + const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; + const icon = conditionalStyle?.icon; + + // 선택 상태 스타일 + const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10"; + + if (!values || values.length === 0) { + return ( + + - + + ); + } + + // 툴팁 내용 생성 + const tooltipContent = values.map((v) => + `${v.field || "값"}: ${v.formattedValue || v.value}` + ).join("\n"); + + // 단일 데이터 필드인 경우 + if (values.length === 1) { + return ( + + {/* Data Bar */} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {values[0].formattedValue} + + + ); + } + + // 다중 데이터 필드인 경우 + return ( + <> + {values.map((val, idx) => ( + + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {val.formattedValue} + + + ))} + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotGridComponent: React.FC = ({ + title, + fields: initialFields = [], + totals = { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style = { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + fieldChooser, + chart: chartConfig, + allowExpandAll = true, + height = "auto", + maxHeight, + exportConfig, + data: externalData, + onCellClick, + onCellDoubleClick, + onFieldDrop, + onExpandChange, +}) => { + // 디버깅 로그 + console.log("🔶 PivotGridComponent props:", { + title, + hasExternalData: !!externalData, + externalDataLength: externalData?.length, + initialFieldsLength: initialFields?.length, + }); + + // 🆕 데이터 샘플 확인 + if (externalData && externalData.length > 0) { + console.log("🔶 첫 번째 데이터 샘플:", externalData[0]); + console.log("🔶 전체 데이터 개수:", externalData.length); + } + + // 🆕 필드 설정 확인 + if (initialFields && initialFields.length > 0) { + console.log("🔶 필드 설정:", initialFields); + } + // ==================== 상태 ==================== + + const [fields, setFields] = useState(initialFields); + const [pivotState, setPivotState] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + + // 🆕 초기 로드 시 자동 확장 (첫 레벨만) + const [isInitialExpanded, setIsInitialExpanded] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태 + const [showFieldChooser, setShowFieldChooser] = useState(false); + const [drillDownData, setDrillDownData] = useState<{ + open: boolean; + cellData: PivotCellData | null; + }>({ open: false, cellData: null }); + const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + const [containerHeight, setContainerHeight] = useState(400); + const tableContainerRef = useRef(null); + + // 셀 선택 상태 (범위 선택 지원) + const [selectedCell, setSelectedCell] = useState<{ + rowIndex: number; + colIndex: number; + } | null>(null); + const [selectionRange, setSelectionRange] = useState<{ + startRow: number; + startCol: number; + endRow: number; + endCol: number; + } | null>(null); + const tableRef = useRef(null); + + // 정렬 상태 + const [sortConfig, setSortConfig] = useState<{ + field: string; + direction: "asc" | "desc"; + } | null>(null); + + // 열 너비 상태 + const [columnWidths, setColumnWidths] = useState>({}); + const [resizingColumn, setResizingColumn] = useState(null); + const [resizeStartX, setResizeStartX] = useState(0); + const [resizeStartWidth, setResizeStartWidth] = useState(0); + + // 외부 fields 변경 시 동기화 + useEffect(() => { + if (initialFields.length > 0) { + setFields(initialFields); + } + }, [initialFields]); + + // 상태 저장 키 + const stateStorageKey = `pivot-state-${title || "default"}`; + + // 상태 저장 (localStorage) + const saveStateToStorage = useCallback(() => { + if (typeof window === "undefined") return; + const stateToSave = { + fields, + pivotState, + sortConfig, + columnWidths, + }; + localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); + }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]); + + // 상태 복원 (localStorage) + useEffect(() => { + if (typeof window === "undefined") return; + const savedState = localStorage.getItem(stateStorageKey); + if (savedState) { + try { + const parsed = JSON.parse(savedState); + if (parsed.fields) setFields(parsed.fields); + if (parsed.pivotState) setPivotState(parsed.pivotState); + if (parsed.sortConfig) setSortConfig(parsed.sortConfig); + if (parsed.columnWidths) setColumnWidths(parsed.columnWidths); + } catch (e) { + console.warn("피벗 상태 복원 실패:", e); + } + } + }, [stateStorageKey]); + + // 데이터 + const data = externalData || []; + + // ==================== 필드 분류 ==================== + + const rowFields = useMemo( + () => + fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const columnFields = useMemo( + () => + fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const dataFields = useMemo( + () => + fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + // 필터 영역 필드 + const filterFields = useMemo( + () => + fields + .filter((f) => f.area === "filter" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + // 사용 가능한 필드 목록 (FieldChooser용) + const availableFields = useMemo(() => { + if (data.length === 0) return []; + + const sampleRow = data[0]; + return Object.keys(sampleRow).map((key) => { + const existingField = fields.find((f) => f.field === key); + const value = sampleRow[key]; + + // 데이터 타입 추론 + let dataType: "string" | "number" | "date" | "boolean" = "string"; + if (typeof value === "number") dataType = "number"; + else if (typeof value === "boolean") dataType = "boolean"; + else if (value instanceof Date) dataType = "date"; + else if (typeof value === "string") { + // 날짜 문자열 감지 + if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date"; + } + + return { + field: key, + caption: existingField?.caption || key, + dataType, + isSelected: existingField?.visible !== false, + currentArea: existingField?.area, + }; + }); + }, [data, fields]); + + // ==================== 필터 적용 ==================== + + const filteredData = useMemo(() => { + if (!data || data.length === 0) return data; + + // 필터 영역의 필드들로 데이터 필터링 + const activeFilters = fields.filter( + (f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 + ); + + if (activeFilters.length === 0) return data; + + return data.filter((row) => { + return activeFilters.every((filter) => { + const value = row[filter.field]; + const filterValues = filter.filterValues || []; + const filterType = filter.filterType || "include"; + + if (filterType === "include") { + return filterValues.includes(value); + } else { + return !filterValues.includes(value); + } + }); + }); + }, [data, fields]); + + // ==================== 피벗 처리 ==================== + + const pivotResult = useMemo(() => { + if (!filteredData || filteredData.length === 0 || fields.length === 0) { + return null; + } + + const visibleFields = fields.filter((f) => f.visible !== false); + // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) + if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { + return null; + } + + const result = processPivotData( + filteredData, + visibleFields, + pivotState.expandedRowPaths, + pivotState.expandedColumnPaths + ); + + // 🆕 피벗 결과 확인 + console.log("🔶 피벗 처리 결과:", { + hasResult: !!result, + flatRowsCount: result?.flatRows?.length, + flatColumnsCount: result?.flatColumns?.length, + dataMatrixSize: result?.dataMatrix?.size, + expandedRowPaths: pivotState.expandedRowPaths.length, + expandedColumnPaths: pivotState.expandedColumnPaths.length, + }); + + return result; + }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + + // 🆕 초기 로드 시 첫 레벨 자동 확장 + useEffect(() => { + if (!isInitialExpanded && pivotResult && pivotResult.flatRows.length > 0) { + // 첫 레벨 행들의 경로 수집 (level 0인 행들) + const firstLevelPaths = pivotResult.flatRows + .filter(row => row.level === 0 && row.hasChildren) + .map(row => row.path); + + if (firstLevelPaths.length > 0) { + console.log("🔶 초기 자동 확장:", firstLevelPaths); + setPivotState(prev => ({ + ...prev, + expandedRowPaths: firstLevelPaths, + })); + setIsInitialExpanded(true); + } + } + }, [pivotResult, isInitialExpanded]); + + // 조건부 서식용 전체 값 수집 + const allCellValues = useMemo(() => { + if (!pivotResult) return new Map(); + + const valuesByField = new Map(); + + // 데이터 매트릭스에서 모든 값 수집 + pivotResult.dataMatrix.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // 행 총계 값 수집 + pivotResult.grandTotals.row.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // 열 총계 값 수집 + pivotResult.grandTotals.column.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + return valuesByField; + }, [pivotResult]); + + // ==================== 가상 스크롤 ==================== + + const ROW_HEIGHT = 32; // 행 높이 (px) + const VIRTUAL_SCROLL_THRESHOLD = 50; // 이 행 수 이상이면 가상 스크롤 활성화 + + // 컨테이너 높이 측정 + useEffect(() => { + if (!tableContainerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(tableContainerRef.current); + return () => observer.disconnect(); + }, []); + + // 열 크기 조절 중 + useEffect(() => { + if (resizingColumn === null) return; + + const handleMouseMove = (e: MouseEvent) => { + const diff = e.clientX - resizeStartX; + const newWidth = Math.max(50, resizeStartWidth + diff); // 최소 50px + setColumnWidths((prev) => ({ + ...prev, + [resizingColumn]: newWidth, + })); + }; + + const handleMouseUp = () => { + setResizingColumn(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [resizingColumn, resizeStartX, resizeStartWidth]); + + // 가상 스크롤 훅 사용 + const flatRows = pivotResult?.flatRows || []; + + // 정렬된 행 데이터 + const sortedFlatRows = useMemo(() => { + if (!sortConfig || !pivotResult) return flatRows; + + const { field, direction } = sortConfig; + const { dataMatrix, flatColumns } = pivotResult; + + // 각 행의 정렬 기준 값 계산 + const rowsWithSortValue = flatRows.map((row) => { + let sortValue = 0; + // 모든 열에 대해 해당 필드의 합계 계산 + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + const targetValue = values.find((v) => v.field === field); + if (targetValue?.value != null) { + sortValue += targetValue.value; + } + }); + return { row, sortValue }; + }); + + // 정렬 + rowsWithSortValue.sort((a, b) => { + if (direction === "asc") { + return a.sortValue - b.sortValue; + } + return b.sortValue - a.sortValue; + }); + + return rowsWithSortValue.map((item) => item.row); + }, [flatRows, sortConfig, pivotResult]); + + const enableVirtualScroll = sortedFlatRows.length > VIRTUAL_SCROLL_THRESHOLD; + + const virtualScroll = useVirtualScroll({ + itemCount: sortedFlatRows.length, + itemHeight: ROW_HEIGHT, + containerHeight: containerHeight, + overscan: 10, + }); + + // 가상 스크롤 적용된 행 데이터 + const visibleFlatRows = useMemo(() => { + if (!enableVirtualScroll) return sortedFlatRows; + return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + + // 조건부 서식 스타일 계산 헬퍼 + const getCellConditionalStyle = useCallback( + (value: number | undefined, field: string): CellFormatStyle => { + if (!style?.conditionalFormats || style.conditionalFormats.length === 0) { + return {}; + } + const allValues = allCellValues.get(field) || []; + return getConditionalStyle(value, field, style.conditionalFormats, allValues); + }, + [style?.conditionalFormats, allCellValues] + ); + + // ==================== 이벤트 핸들러 ==================== + + // 필드 변경 + const handleFieldsChange = useCallback( + (newFields: PivotFieldConfig[]) => { + setFields(newFields); + }, + [] + ); + + // 행 확장/축소 + const handleToggleRowExpand = useCallback( + (path: string[]) => { + console.log("🔶 행 확장/축소 클릭:", path); + + setPivotState((prev) => { + const pathKey = pathToKey(path); + const existingIndex = prev.expandedRowPaths.findIndex( + (p) => pathToKey(p) === pathKey + ); + + let newPaths: string[][]; + if (existingIndex >= 0) { + console.log("🔶 행 축소:", path); + newPaths = prev.expandedRowPaths.filter( + (_, i) => i !== existingIndex + ); + } else { + console.log("🔶 행 확장:", path); + newPaths = [...prev.expandedRowPaths, path]; + } + + console.log("🔶 새로운 확장 경로:", newPaths); + onExpandChange?.(newPaths); + + return { + ...prev, + expandedRowPaths: newPaths, + }; + }); + }, + [onExpandChange] + ); + + // 전체 확장 + const handleExpandAll = useCallback(() => { + if (!pivotResult) return; + + const allRowPaths: string[][] = []; + pivotResult.flatRows.forEach((row) => { + if (row.hasChildren) { + allRowPaths.push(row.path); + } + }); + + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + }, [pivotResult]); + + // 전체 축소 + const handleCollapseAll = useCallback(() => { + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: [], + expandedColumnPaths: [], + })); + }, []); + + // 셀 클릭 + const handleCellClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + if (!onCellClick) return; + + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + onCellClick(cellData); + }, + [onCellClick] + ); + + // 셀 더블클릭 (Drill Down) + const handleCellDoubleClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + // Drill Down 모달 열기 + setDrillDownData({ open: true, cellData }); + + // 외부 콜백 호출 + if (onCellDoubleClick) { + onCellDoubleClick(cellData); + } + }, + [onCellDoubleClick] + ); + + // CSV 내보내기 + const handleExportCSV = useCallback(() => { + if (!pivotResult) return; + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + let csv = ""; + + // 헤더 행 + const headerRow = [""].concat( + flatColumns.map((col) => col.caption || "총계") + ); + if (totals?.showRowGrandTotals) { + headerRow.push("총계"); + } + csv += headerRow.join(",") + "\n"; + + // 데이터 행 + flatRows.forEach((row) => { + const rowData = [row.caption]; + + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + rowData.push(values?.[0]?.value?.toString() || ""); + }); + + if (totals?.showRowGrandTotals) { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + rowData.push(rowTotal?.[0]?.value?.toString() || ""); + } + + csv += rowData.join(",") + "\n"; + }); + + // 열 총계 행 + if (totals?.showColumnGrandTotals) { + const totalRow = ["총계"]; + flatColumns.forEach((col) => { + const colTotal = grandTotals.column.get(pathToKey(col.path)); + totalRow.push(colTotal?.[0]?.value?.toString() || ""); + }); + if (totals?.showRowGrandTotals) { + totalRow.push(grandTotals.grand[0]?.value?.toString() || ""); + } + csv += totalRow.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 = `${title || "pivot"}_export.csv`; + link.click(); + }, [pivotResult, totals, title]); + + // Excel 내보내기 + const handleExportExcel = useCallback(async () => { + if (!pivotResult) return; + + try { + await exportPivotToExcel(pivotResult, fields, totals, { + fileName: title || "pivot_export", + title: title, + }); + } catch (error) { + console.error("Excel 내보내기 실패:", error); + } + }, [pivotResult, fields, totals, title]); + + // 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함) + const handlePrint = useCallback(() => { + const printContent = tableRef.current; + if (!printContent) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) return; + + printWindow.document.write(` + + + + ${title || "피벗 테이블"} + + + +

${title || "피벗 테이블"}

+ ${printContent.outerHTML} + + + `); + + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => { + printWindow.print(); + printWindow.close(); + }, 250); + }, [title]); + + // PDF 내보내기 + const handleExportPDF = useCallback(async () => { + if (!pivotResult || !tableRef.current) return; + + try { + // 동적 import로 jspdf와 html2canvas 로드 + const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ + import("jspdf"), + import("html2canvas"), + ]); + + const canvas = await html2canvas(tableRef.current, { + scale: 2, + useCORS: true, + logging: false, + }); + + const imgData = canvas.toDataURL("image/png"); + const pdf = new jsPDF({ + orientation: canvas.width > canvas.height ? "landscape" : "portrait", + unit: "px", + format: [canvas.width, canvas.height], + }); + + pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height); + pdf.save(`${title || "pivot"}_export.pdf`); + } catch (error) { + console.error("PDF 내보내기 실패:", error); + // jspdf가 없으면 인쇄 대화상자로 대체 + handlePrint(); + } + }, [pivotResult, title, handlePrint]); + + // 데이터 새로고침 + const [isRefreshing, setIsRefreshing] = useState(false); + const handleRefreshData = useCallback(async () => { + setIsRefreshing(true); + // 외부 데이터 소스가 있으면 새로고침 + // 여기서는 상태만 초기화 + setPivotState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + setSortConfig(null); + setSelectedCell(null); + setSelectionRange(null); + setTimeout(() => setIsRefreshing(false), 500); + }, []); + + // 상태 저장 버튼 핸들러 + const handleSaveState = useCallback(() => { + saveStateToStorage(); + console.log("피벗 상태가 저장되었습니다."); + }, [saveStateToStorage]); + + // 상태 초기화 + const handleResetState = useCallback(() => { + localStorage.removeItem(stateStorageKey); + setFields(initialFields); + setPivotState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + setSortConfig(null); + setColumnWidths({}); + setSelectedCell(null); + setSelectionRange(null); + }, [stateStorageKey, initialFields]); + + // 필드 숨기기/표시 상태 + const [hiddenFields, setHiddenFields] = useState>(new Set()); + + const toggleFieldVisibility = useCallback((fieldName: string) => { + setHiddenFields((prev) => { + const newSet = new Set(prev); + if (newSet.has(fieldName)) { + newSet.delete(fieldName); + } else { + newSet.add(fieldName); + } + return newSet; + }); + }, []); + + // 숨겨진 필드 제외한 활성 필드들 + const visibleFields = useMemo(() => { + return fields.filter((f) => !hiddenFields.has(f.field)); + }, [fields, hiddenFields]); + + // 숨겨진 필드 목록 + const hiddenFieldsList = useMemo(() => { + return fields.filter((f) => hiddenFields.has(f.field)); + }, [fields, hiddenFields]); + + // 모든 필드 표시 + const showAllFields = useCallback(() => { + setHiddenFields(new Set()); + }, []); + + // ==================== 렌더링 ==================== + + // 빈 상태 + if (!data || data.length === 0) { + return ( +
+ +

데이터가 없습니다

+

데이터를 로드하거나 필드를 설정해주세요

+
+ ); + } + + // 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인) + const hasActiveFields = fields.some( + (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) + ); + if (!hasActiveFields) { + return ( +
+ {/* 필드 패널 */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 안내 메시지 */} +
+ +

필드가 설정되지 않았습니다

+

+ 행, 열, 데이터 영역에 필드를 배치해주세요 +

+ +
+ + {/* 필드 선택기 모달 */} + +
+ ); + } + + // 피벗 결과 없음 + if (!pivotResult) { + return ( +
+ +
+ ); + } + + const { flatColumns, dataMatrix, grandTotals } = pivotResult; + + // ==================== 키보드 네비게이션 ==================== + + // 키보드 핸들러 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!selectedCell) return; + + const { rowIndex, colIndex } = selectedCell; + const maxRowIndex = visibleFlatRows.length - 1; + const maxColIndex = flatColumns.length - 1; + + let newRowIndex = rowIndex; + let newColIndex = colIndex; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 1); + break; + case "ArrowDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 1); + break; + case "ArrowLeft": + e.preventDefault(); + newColIndex = Math.max(0, colIndex - 1); + break; + case "ArrowRight": + e.preventDefault(); + newColIndex = Math.min(maxColIndex, colIndex + 1); + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = 0; + newColIndex = 0; + } else { + newColIndex = 0; + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = maxRowIndex; + newColIndex = maxColIndex; + } else { + newColIndex = maxColIndex; + } + break; + case "PageUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 10); + break; + case "PageDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 10); + break; + case "Enter": + e.preventDefault(); + // 셀 더블클릭과 동일한 동작 (드릴다운) + if (visibleFlatRows[rowIndex] && flatColumns[colIndex]) { + const row = visibleFlatRows[rowIndex]; + const col = flatColumns[colIndex]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + // 드릴다운 모달 열기 + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath: row.path, + columnPath: col.path, + field: values[0]?.field, + }; + setDrillDownData({ open: true, cellData }); + } + break; + case "Escape": + e.preventDefault(); + setSelectedCell(null); + setSelectionRange(null); + break; + case "c": + // Ctrl+C: 클립보드 복사 + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + copySelectionToClipboard(); + } + return; + case "a": + // Ctrl+A: 전체 선택 + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + setSelectionRange({ + startRow: 0, + startCol: 0, + endRow: visibleFlatRows.length - 1, + endCol: flatColumns.length - 1, + }); + } + return; + default: + return; + } + + if (newRowIndex !== rowIndex || newColIndex !== colIndex) { + setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex }); + } + }; + + // 셀 클릭으로 선택 (Shift+클릭으로 범위 선택) + const handleCellSelect = (rowIndex: number, colIndex: number, shiftKey: boolean = false) => { + if (shiftKey && selectedCell) { + // Shift+클릭: 범위 선택 + setSelectionRange({ + startRow: Math.min(selectedCell.rowIndex, rowIndex), + startCol: Math.min(selectedCell.colIndex, colIndex), + endRow: Math.max(selectedCell.rowIndex, rowIndex), + endCol: Math.max(selectedCell.colIndex, colIndex), + }); + } else { + // 일반 클릭: 단일 선택 + setSelectedCell({ rowIndex, colIndex }); + setSelectionRange(null); + } + }; + + // 셀이 선택 범위 내에 있는지 확인 + const isCellInRange = (rowIndex: number, colIndex: number): boolean => { + if (selectionRange) { + return ( + rowIndex >= selectionRange.startRow && + rowIndex <= selectionRange.endRow && + colIndex >= selectionRange.startCol && + colIndex <= selectionRange.endCol + ); + } + if (selectedCell) { + return selectedCell.rowIndex === rowIndex && selectedCell.colIndex === colIndex; + } + return false; + }; + + // 열 크기 조절 시작 + const handleResizeStart = (colIdx: number, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setResizingColumn(colIdx); + setResizeStartX(e.clientX); + setResizeStartWidth(columnWidths[colIdx] || 100); + }; + + // 클립보드에 선택 영역 복사 + const copySelectionToClipboard = () => { + const range = selectionRange || (selectedCell ? { + startRow: selectedCell.rowIndex, + startCol: selectedCell.colIndex, + endRow: selectedCell.rowIndex, + endCol: selectedCell.colIndex, + } : null); + + if (!range) return; + + const lines: string[] = []; + + for (let rowIdx = range.startRow; rowIdx <= range.endRow; rowIdx++) { + const row = visibleFlatRows[rowIdx]; + if (!row) continue; + + const rowValues: string[] = []; + for (let colIdx = range.startCol; colIdx <= range.endCol; colIdx++) { + const col = flatColumns[colIdx]; + if (!col) continue; + + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + const cellValue = values.map((v) => v.formattedValue || v.value || "").join(", "); + rowValues.push(cellValue); + } + lines.push(rowValues.join("\t")); + } + + const text = lines.join("\n"); + navigator.clipboard.writeText(text).then(() => { + // 복사 성공 피드백 (선택적) + console.log("클립보드에 복사됨:", text); + }).catch((err) => { + console.error("클립보드 복사 실패:", err); + }); + }; + + // 정렬 토글 + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (prev?.field === field) { + // 같은 필드 클릭: asc -> desc -> null 순환 + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; // 정렬 해제 + } + // 새로운 필드: asc로 시작 + return { field, direction: "asc" }; + }); + }; + + // 정렬 아이콘 렌더링 + const SortIcon = ({ field }: { field: string }) => { + if (sortConfig?.field !== field) { + return ; + } + if (sortConfig.direction === "asc") { + return ; + } + return ; + }; + + return ( +
+ {/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 헤더 툴바 */} +
+
+ {title &&

{title}

} + + ({filteredData.length !== data.length + ? `${filteredData.length} / ${data.length}건` + : `${data.length}건`}) + +
+ +
+ {/* 필드 선택기 버튼 */} + {fieldChooser?.enabled !== false && ( + + )} + + {/* 필드 패널 토글 */} + + + {allowExpandAll && ( + <> + + + + + )} + + {/* 차트 토글 */} + {chartConfig && ( + + )} + + {/* 내보내기 버튼들 */} + {exportConfig?.excel && ( + <> + + + + + + )} + + + + + + + + {/* 숨겨진 필드 표시 드롭다운 */} + {hiddenFieldsList.length > 0 && ( +
+ +
+
+ 숨겨진 필드 +
+
+ {hiddenFieldsList.map((field) => ( + + ))} +
+
+ +
+
+
+ )} + + +
+
+ + {/* 필터 바 - 필터 영역에 필드가 있을 때만 표시 */} + {filterFields.length > 0 && ( +
+ + 필터: +
+ {filterFields.map((filterField) => { + const selectedValues = filterField.filterValues || []; + const isFiltered = selectedValues.length > 0; + + return ( + { + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, filterValues: values, filterType: type } + : f + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ); + })} +
+
+ )} + + {/* 피벗 테이블 */} +
+ + + {/* 열 헤더 */} + + {/* 좌상단 코너 (행 필드 라벨 + 필터) */} + + + {/* 열 헤더 셀 */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* 열 필드 필터 (헤더 왼쪽에 표시) */} + {columnFields.length > 0 && ( + + )} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} + + + {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} + {dataFields.length > 1 && ( + + {flatColumns.map((col, colIdx) => ( + + {dataFields.map((df, dfIdx) => ( + + ))} + + ))} + {totals?.showRowGrandTotals && + dataFields.map((df, dfIdx) => ( + + ))} + + )} + + + + {/* 열 총계 행 (상단 위치) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* 대총합 */} + {totals?.showRowGrandTotals && ( + + )} + + )} + + {/* 가상 스크롤 상단 여백 */} + {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( + + + )} + + {(() => { + // 셀 병합 정보 계산 + const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false); + + return visibleFlatRows.map((row, idx) => { + // 실제 행 인덱스 계산 + const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; + const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false }; + + return ( + + {/* 행 헤더 (병합되면 skip) */} + {!cellMerge.skip && ( + + )} + + {/* 데이터 셀 */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + // 조건부 서식 (첫 번째 값 기준) + const conditionalStyle = + values.length > 0 && values[0].field + ? getCellConditionalStyle(values[0].value ?? undefined, values[0].field) + : undefined; + + // 선택 상태 확인 (범위 선택 포함) + const isCellSelected = isCellInRange(rowIdx, colIdx); + + return ( + { + handleCellSelect(rowIdx, colIdx, e?.shiftKey || false); + if (onCellClick) { + handleCellClick(row.path, col.path, values); + } + }} + onDoubleClick={() => + handleCellDoubleClick(row.path, col.path, values) + } + /> + ); + })} + + {/* 행 총계 */} + {totals?.showRowGrandTotals && ( + + )} + + ); + }); + })()} + + {/* 가상 스크롤 하단 여백 */} + {enableVirtualScroll && ( + + + )} + + {/* 열 총계 행 (하단 위치 - 기본값) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* 대총합 */} + {totals?.showRowGrandTotals && ( + + )} + + )} + +
0 ? 2 : 1} + > +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
+ ))} + {rowFields.length === 0 && 항목} +
+
handleSort(dataFields[0].field) : undefined} + > +
+ {col.caption || "(전체)"} + {dataFields.length === 1 && } +
+ {/* 열 리사이즈 핸들 */} +
handleResizeStart(idx, e)} + /> +
0 ? 2 : 1} + > +
+ {columnFields.map((f) => ( + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "column" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ))} +
+
+ 총계 +
handleSort(df.field)} + > +
+ {df.caption} + +
+
+ {df.caption} +
+ 총계 +
+
+
+ 총계 +
+
+ + {/* 차트 */} + {showChart && chartConfig && pivotResult && ( + + )} + + {/* 필드 선택기 모달 */} + + + {/* Drill Down 모달 */} + setDrillDownData((prev) => ({ ...prev, open }))} + cellData={drillDownData.cellData} + data={data} + fields={fields} + rowFields={rowFields} + columnFields={columnFields} + /> +
+ ); +}; + +export default PivotGridComponent; diff --git a/frontend/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel.tsx new file mode 100644 index 00000000..37f0862b --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel.tsx @@ -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 = ({ 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 ( + + ); +}; + +// ==================== 영역 드롭존 컴포넌트 ==================== + +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) => void; + color: string; +} + +const AreaDropZone: React.FC = ({ + 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 ( +
+ {/* 헤더 */} +
setIsExpanded(!isExpanded)} + > +
+ {icon} + {label} + + {fields.length} + +
+ {isExpanded ? : } +
+ + {/* 설명 */} +

{description}

+ + {isExpanded && ( +
+ {/* 추가된 필드 목록 */} + {fields.length > 0 ? ( +
+ {fields.map((field, idx) => ( +
+ + + {field.caption || field.field} + + + {/* 데이터 영역일 때 집계 함수 선택 */} + {area === "data" && ( + + )} + + +
+ ))} +
+ ) : ( +
+ 아래에서 컬럼을 선택하세요 +
+ )} + + {/* 컬럼 추가 드롭다운 */} + {availableColumns.length > 0 && ( + + )} +
+ )} +
+ ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotGridConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + 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) => { + 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) => { + 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 ( +
+ {/* 사용 가이드 */} +
+
+ +
+

피벗 테이블 설정 방법

+
    +
  1. 데이터를 가져올 테이블을 선택하세요
  2. +
  3. 행 그룹에 그룹화할 컬럼을 추가하세요 (예: 지역, 부서)
  4. +
  5. 열 그룹에 가로로 펼칠 컬럼을 추가하세요 (예: 월, 분기)
  6. +
  7. 에 집계할 숫자 컬럼을 추가하세요 (예: 매출, 수량)
  8. +
+
+
+
+ + {/* STEP 1: 테이블 선택 */} +
+
+ + +
+ + +
+ + {/* STEP 2: 필드 배치 */} + {config.dataSource?.tableName && ( +
+
+ + + {loadingColumns && (컬럼 로딩 중...)} +
+ + {/* 사용 가능한 컬럼 목록 */} + {columns.length > 0 && ( +
+ +
+ {columns.map((col) => { + const isUsed = (config.fields || []).some(f => f.field === col.column_name); + return ( + {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}} + /> + ); + })} +
+
+ )} + + {/* 영역별 드롭존 */} +
+ } + 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" + /> + + } + 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" + /> + + } + 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" + /> +
+
+ )} + + {/* 고급 설정 토글 */} +
+ +
+ + {/* 고급 설정 */} + {showAdvanced && ( +
+ {/* 표시 설정 */} +
+ + +
+
+ + + updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) + } + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + + updateConfig({ totals: { ...config.totals, showRowTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ totals: { ...config.totals, showColumnTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, alternateRowColors: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, mergeCells: v } }) + } + /> +
+ +
+ + + updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) + } + /> +
+ +
+ + + updateConfig({ saveState: v }) + } + /> +
+
+
+ + {/* 크기 설정 */} +
+ +
+
+ + updateConfig({ height: e.target.value })} + placeholder="400px" + className="h-8 text-xs" + /> +
+
+ + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-8 text-xs" + /> +
+
+
+ + {/* 조건부 서식 */} +
+ +
+ {(config.style?.conditionalFormats || []).map((rule, index) => ( +
+ + + {rule.type === "colorScale" && ( +
+ { + 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="최소값 색상" + /> + + { + 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="최대값 색상" + /> +
+ )} + + {rule.type === "dataBar" && ( + { + 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" && ( + + )} + + +
+ ))} + + +
+
+
+ )} +
+ ); +}; + +export default PivotGridConfigPanel; diff --git a/frontend/lib/registry/components/v2-pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/v2-pivot-grid/PivotGridRenderer.tsx new file mode 100644 index 00000000..0244d76c --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/PivotGridRenderer.tsx @@ -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 = (props) => { + // 컴포넌트 설정에서 값 추출 + const componentConfig = props.componentConfig || props.config || {}; + const configFields = componentConfig.fields || props.fields; + const configData = props.data; + + // 🆕 테이블에서 데이터 자동 로딩 + const [loadedData, setLoadedData] = useState([]); + 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 ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + return ( + + ); +}; + +/** + * 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 ( + + ); + } +} + +// 자동 등록 실행 +PivotGridRenderer.registerSelf(); + +// 강제 등록 (디버깅용) +if (typeof window !== "undefined") { + setTimeout(() => { + try { + PivotGridRenderer.registerSelf(); + } catch (error) { + console.error("❌ PivotGrid 강제 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/v2-pivot-grid/README.md b/frontend/lib/registry/components/v2-pivot-grid/README.md new file mode 100644 index 00000000..bc6fba52 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/README.md @@ -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 }, + // ... +]; + + +``` + +### 날짜 그룹화 + +```tsx + +``` + +### 포맷 설정 + +```tsx + +``` + +### 화면 관리에서 사용 + +설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다. + +```tsx +import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid"; + + +``` + +## 설정 옵션 + +### 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`를 통해 영역 내 필드 순서를 지정하세요. + + diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/ContextMenu.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/ContextMenu.tsx new file mode 100644 index 00000000..1dac623b --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/components/ContextMenu.tsx @@ -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 = ({ + 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 ( + + {children} + + {/* 정렬 옵션 (헤더에서만) */} + {(cellType === "rowHeader" || cellType === "columnHeader") && field && ( + <> + + + + 정렬 + + + onSort?.(field.field, "asc")}> + + 오름차순 + + onSort?.(field.field, "desc")}> + + 내림차순 + + + + + + )} + + {/* 확장/축소 옵션 */} + {(cellType === "rowHeader" || cellType === "columnHeader") && ( + <> + {rowPath && rowPath.length > 0 && ( + <> + onExpand?.(rowPath)}> + + 확장 + + onCollapse?.(rowPath)}> + + 축소 + + + )} + + + 전체 확장 + + + + 전체 축소 + + + + )} + + {/* 필터 옵션 */} + {field && onFilter && ( + <> + onFilter(field.field)}> + + 필터 + + + + )} + + {/* 집계 함수 변경 (데이터 필드에서만) */} + {cellType === "data" && field && onChangeSummary && ( + <> + + + + 집계 함수 + + + onChangeSummary(field.field, "sum")} + > + 합계 + + onChangeSummary(field.field, "count")} + > + 개수 + + onChangeSummary(field.field, "avg")} + > + 평균 + + onChangeSummary(field.field, "min")} + > + 최소 + + onChangeSummary(field.field, "max")} + > + 최대 + + + + + + )} + + {/* 드릴다운 (데이터 셀에서만) */} + {cellType === "data" && rowPath && columnPath && onDrillDown && ( + <> + onDrillDown(rowPath, columnPath)}> + + 상세 데이터 보기 + + + + )} + + {/* 필드 숨기기 */} + {field && onHideField && ( + onHideField(field.field)}> + + 필드 숨기기 + + )} + + {/* 복사 */} + + + 복사 + + + + ); +}; + +export default PivotContextMenu; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx new file mode 100644 index 00000000..994d782f --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx @@ -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 = ({ + 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(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(); + + // 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 ( + + + + 상세 데이터 + + {pathDisplay || "선택한 셀의 원본 데이터"} + + ({filteredData.length}건) + + + + + {/* 툴바 */} +
+
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 테이블 */} + +
+ + + + {displayColumns.map((col) => ( + handleSort(col.field)} + > +
+ {col.caption} + {sortConfig?.field === col.field ? ( + sortConfig.direction === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ))} +
+
+ + {paginatedData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + paginatedData.map((row, idx) => ( + + {displayColumns.map((col) => ( + + {formatCellValue(row[col.field], col.dataType)} + + ))} + + )) + )} + +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+
+ {(currentPage - 1) * pageSize + 1} -{" "} + {Math.min(currentPage * pageSize, filteredData.length)} /{" "} + {filteredData.length}건 +
+ +
+ + + + + {currentPage} / {totalPages} + + + + +
+
+ )} +
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +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; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/FieldChooser.tsx new file mode 100644 index 00000000..89fe5128 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/components/FieldChooser.tsx @@ -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: }, + { value: "filter", label: "필터", icon: }, + { value: "row", label: "행", icon: }, + { value: "column", label: "열", icon: }, + { value: "data", label: "데이터", icon: }, +]; + +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: , + number: , + date: , + boolean: , +}; + +// ==================== 필드 아이템 ==================== + +interface FieldItemProps { + field: AvailableField; + config?: PivotFieldConfig; + onAreaChange: (area: PivotAreaType | "none") => void; + onSummaryChange?: (summary: AggregationType) => void; + onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void; +} + +const FieldItem: React.FC = ({ + field, + config, + onAreaChange, + onSummaryChange, + onDisplayModeChange, +}) => { + const currentArea = config?.area || "none"; + const isSelected = currentArea !== "none"; + + return ( +
+ {/* 데이터 타입 아이콘 */} +
+ {DATA_TYPE_ICONS[field.dataType] || } +
+ + {/* 필드명 */} +
+
{field.caption}
+
+ {field.field} +
+
+ + {/* 영역 선택 */} + + + {/* 집계 함수 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onSummaryChange && ( + + )} + + {/* 표시 모드 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onDisplayModeChange && ( + + )} +
+ ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const FieldChooser: React.FC = ({ + 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 ( + + + + 필드 선택기 + + 피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요. + + + + {/* 통계 */} +
+ 전체: {stats.total} + + 선택됨: {stats.selected} + + 필터: {stats.filter} + 행: {stats.row} + 열: {stats.column} + 데이터: {stats.data} +
+ + {/* 검색 및 필터 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 필드 목록 */} + +
+ {filteredFields.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredFields.map((field) => { + const config = selectedFields.find( + (f) => f.field === field.field && f.visible !== false + ); + return ( + handleAreaChange(field, area)} + onSummaryChange={ + config?.area === "data" + ? (summary) => handleSummaryChange(field, summary) + : undefined + } + onDisplayModeChange={ + config?.area === "data" + ? (mode) => handleDisplayModeChange(field, mode) + : undefined + } + /> + ); + }) + )} +
+
+ + {/* 푸터 */} +
+ +
+
+
+ ); +}; + +export default FieldChooser; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/FieldPanel.tsx new file mode 100644 index 00000000..fed43afb --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/components/FieldPanel.tsx @@ -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: , + color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", + }, + column: { + title: "열", + icon: , + color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800", + }, + row: { + title: "행", + icon: , + color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800", + }, + data: { + title: "데이터", + icon: , + color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800", + }, +}; + +// ==================== 필드 칩 (드래그 가능) ==================== + +const SortableFieldChip: React.FC = ({ + 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 ( +
+ {/* 드래그 핸들 */} + + + {/* 필드 라벨 */} + + + + + + {field.area === "data" && ( + <> + + onSettingsChange?.({ ...field, summaryType: "sum" }) + } + > + 합계 + + + onSettingsChange?.({ ...field, summaryType: "count" }) + } + > + 개수 + + + onSettingsChange?.({ ...field, summaryType: "avg" }) + } + > + 평균 + + + onSettingsChange?.({ ...field, summaryType: "min" }) + } + > + 최소 + + + onSettingsChange?.({ ...field, summaryType: "max" }) + } + > + 최대 + + + + )} + + onSettingsChange?.({ + ...field, + sortOrder: field.sortOrder === "asc" ? "desc" : "asc", + }) + } + > + {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} + + + onSettingsChange?.({ ...field, visible: false })} + > + 필드 숨기기 + + + + + {/* 삭제 버튼 */} + +
+ ); +}; + +// ==================== 드롭 영역 ==================== + +const DroppableArea: React.FC = ({ + 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 ( +
+ {/* 영역 헤더 */} +
+ {icon} + {title} + {areaFields.length > 0 && ( + + {areaFields.length} + + )} +
+ + {/* 필드 목록 */} + +
+ {areaFields.length === 0 ? ( + + 필드를 여기로 드래그 + + ) : ( + areaFields.map((field) => ( + onFieldRemove(field)} + onSettingsChange={onFieldSettingsChange} + /> + )) + )} +
+
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +function getSummaryLabel(type: string): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유", + }; + return labels[type] || type; +} + +// ==================== 메인 컴포넌트 ==================== + +export const FieldPanel: React.FC = ({ + fields, + onFieldsChange, + onFieldRemove, + onFieldSettingsChange, + collapsed = false, + onToggleCollapse, +}) => { + const [activeId, setActiveId] = useState(null); + const [overArea, setOverArea] = useState(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 ( +
+
+ {filterCount > 0 && ( + + + 필터 {filterCount} + + )} + + + 열 {columnCount} + + + + 행 {rowCount} + + + + 데이터 {dataCount} + +
+ +
+ ); + } + + return ( + +
+ {/* 4개 영역 배치: 2x2 그리드 */} +
+ {/* 필터 영역 */} + + + {/* 열 영역 */} + + + {/* 행 영역 */} + + + {/* 데이터 영역 */} + +
+ + {/* 접기 버튼 */} + {onToggleCollapse && ( +
+ +
+ )} +
+ + {/* 드래그 오버레이 */} + + {activeField ? ( +
+ + {activeField.caption} +
+ ) : null} +
+
+ ); +}; + +export default FieldPanel; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/FilterPopup.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/FilterPopup.tsx new file mode 100644 index 00000000..e3185f5a --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/components/FilterPopup.tsx @@ -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 = ({ + field, + data, + onFilterChange, + trigger, +}) => { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedValues, setSelectedValues] = useState>( + new Set(field.filterValues || []) + ); + const [filterType, setFilterType] = useState<"include" | "exclude">( + field.filterType || "include" + ); + + // 고유 값 추출 + const uniqueValues = useMemo(() => { + const values = new Set(); + 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 ( + + + {trigger || ( + + )} + + +
+
+ {field.caption} 필터 +
+ + +
+
+ + {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+ + {/* 전체 선택/해제 */} +
+ + {selectedCount} / {totalCount} 선택됨 + +
+ + +
+
+
+ + {/* 값 목록 */} + +
+ {filteredValues.length === 0 ? ( +
+ 결과가 없습니다 +
+ ) : ( + filteredValues.map((value) => ( + + )) + )} +
+
+ + {/* 버튼 */} +
+ +
+ + +
+
+
+
+ ); +}; + +export default FilterPopup; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/PivotChart.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/PivotChart.tsx new file mode 100644 index 00000000..6f7c3708 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/components/PivotChart.tsx @@ -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 = ({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + + return ( +
+

{label}

+ {payload.map((entry: any, idx: number) => ( +

+ {entry.name}: {entry.value?.toLocaleString()} +

+ ))} +
+ ); +}; + +// 막대 차트 +const PivotBarChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; + stacked?: boolean; +}> = ({ data, columns, height, showLegend, stacked }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 선 차트 +const PivotLineChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 영역 차트 +const PivotAreaChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 파이 차트 +const PivotPieChart: React.FC<{ + data: any[]; + height: number; + showLegend: boolean; +}> = ({ data, height, showLegend }) => { + return ( + + + + `${name} (${(percent * 100).toFixed(1)}%)` + } + labelLine + > + {data.map((entry, idx) => ( + + ))} + + } /> + {showLegend && ( + + )} + + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotChart: React.FC = ({ + 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 ( +
+ {/* 차트 렌더링 */} + {config.type === "bar" && ( + + )} + + {config.type === "stackedBar" && ( + + )} + + {config.type === "line" && ( + + )} + + {config.type === "area" && ( + + )} + + {config.type === "pie" && ( + + )} +
+ ); +}; + +export default PivotChart; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/index.ts b/frontend/lib/registry/components/v2-pivot-grid/components/index.ts new file mode 100644 index 00000000..9272e7db --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/components/index.ts @@ -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"; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/hooks/index.ts b/frontend/lib/registry/components/v2-pivot-grid/hooks/index.ts new file mode 100644 index 00000000..a9a1a4eb --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/hooks/index.ts @@ -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"; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/hooks/usePivotState.ts b/frontend/lib/registry/components/v2-pivot-grid/hooks/usePivotState.ts new file mode 100644 index 00000000..9b001377 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/hooks/usePivotState.ts @@ -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; + 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(initialFields); + const [pivotState, setPivotStateInternal] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + const [lastSaved, setLastSaved] = useState(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; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/v2-pivot-grid/hooks/useVirtualScroll.ts new file mode 100644 index 00000000..152cb2df --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/hooks/useVirtualScroll.ts @@ -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; +} + +// ==================== 훅 ==================== + +export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { + const { + itemCount, + itemHeight, + containerHeight, + overscan = 5, + } = options; + + const containerRef = useRef(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; +} + +export function useVirtual2DScroll( + options: Virtual2DScrollOptions +): Virtual2DScrollResult { + const { + rowCount, + columnCount, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + rowOverscan = 5, + columnOverscan = 3, + } = options; + + const containerRef = useRef(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; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/index.ts b/frontend/lib/registry/components/v2-pivot-grid/index.ts new file mode 100644 index 00000000..b1bbe99b --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/index.ts @@ -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"; diff --git a/frontend/lib/registry/components/v2-pivot-grid/types.ts b/frontend/lib/registry/components/v2-pivot-grid/types.ts new file mode 100644 index 00000000..87ba2414 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/types.ts @@ -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; + + // 플랫 행 목록 (렌더링용) + flatRows: PivotFlatRow[]; + + // 플랫 열 목록 (렌더링용) + flatColumns: PivotFlatColumn[]; + + // 총합계 + grandTotals: { + row: Map; // 행별 총합 + column: Map; // 열별 총합 + 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; // 필드별 필터값 +} + +// ==================== 컴포넌트 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; +} + + diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts new file mode 100644 index 00000000..39aa1c5f --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts @@ -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 = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유값", + }; + return labels[type] || "합계"; +} + + diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/conditionalFormat.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/conditionalFormat.ts new file mode 100644 index 00000000..a9195d92 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/conditionalFormat.ts @@ -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 = { + 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; + diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/exportExcel.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/exportExcel.ts new file mode 100644 index 00000000..6069a3a5 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/exportExcel.ts @@ -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 { + 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 { + 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`); +} diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/index.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/index.ts new file mode 100644 index 00000000..2c0a83d6 --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/index.ts @@ -0,0 +1,6 @@ +export * from "./aggregation"; +export * from "./pivotEngine"; +export * from "./exportExcel"; +export * from "./conditionalFormat"; + + diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts new file mode 100644 index 00000000..4d3fecfd --- /dev/null +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts @@ -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, + 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[], + fields: PivotFieldConfig[], + expandedPaths: Set +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + // 첫 번째 필드로 그룹화 + const firstField = fields[0]; + const groups = new Map[]>(); + + 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[], + fields: PivotFieldConfig[], + parentPath: string[], + expandedPaths: Set, + level: number +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + const field = fields[0]; + const groups = new Map[]>(); + + 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[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): Map { + const matrix = new Map(); + + // 각 셀에 대해 해당하는 데이터 집계 + 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, + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][], + rowTotals: Map, + columnTotals: Map, + grandTotals: PivotCellValue[] +): Map { + // displayMode가 있는 데이터 필드가 있는지 확인 + const hasDisplayMode = dataFields.some( + (df) => df.summaryDisplayMode || df.showValuesAs + ); + if (!hasDisplayMode) return matrix; + + const newMatrix = new Map(); + + // 누계를 위한 추적 (행별, 열별) + const rowRunningTotals: Map = new Map(); // fieldIndex -> 누계 + const colRunningTotals: Map> = 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[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): { + row: Map; + column: Map; + grand: PivotCellValue[]; +} { + const rowTotals = new Map(); + const columnTotals = new Map(); + + // 행별 총합 (각 행의 모든 열 합계) + 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[], + 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, + }; +} + + diff --git a/frontend/lib/registry/components/v2-rack-structure/README.md b/frontend/lib/registry/components/v2-rack-structure/README.md new file mode 100644 index 00000000..bf03d8dc --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/README.md @@ -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 테이블에 저장됨 + +## 필수 필드 검증 + +미리보기 생성 시 다음 필드가 입력되어 있어야 합니다: +- 창고 코드 +- 층 +- 구역 + +필드가 비어있으면 경고 메시지가 표시됩니다. diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx new file mode 100644 index 00000000..ea946531 --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx @@ -0,0 +1,1013 @@ +"use client"; + +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import { Plus, X, Save, FolderOpen, RefreshCw, Eye, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { cn } from "@/lib/utils"; +import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; +import { DynamicFormApi } from "@/lib/api/dynamicForm"; +import { apiClient } from "@/lib/api/client"; +import { + RackStructureComponentProps, + RackLineCondition, + RackStructureTemplate, + GeneratedLocation, + RackStructureContext, +} from "./types"; + +// 기존 위치 데이터 타입 +interface ExistingLocation { + row_num: string; + level_num: string; + location_code: string; +} + +// 고유 ID 생성 +const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + +// 조건 카드 컴포넌트 +interface ConditionCardProps { + condition: RackLineCondition; + index: number; + onUpdate: (id: string, updates: Partial) => void; + onRemove: (id: string) => void; + maxRows: number; + maxLevels: number; + readonly?: boolean; +} + +const ConditionCard: React.FC = ({ + condition, + index, + onUpdate, + onRemove, + maxRows, + maxLevels, + readonly, +}) => { + // 로컬 상태로 입력값 관리 + const [localValues, setLocalValues] = useState({ + startRow: condition.startRow.toString(), + endRow: condition.endRow.toString(), + levels: condition.levels.toString(), + }); + + // condition이 변경되면 로컬 상태 동기화 + useEffect(() => { + setLocalValues({ + startRow: condition.startRow.toString(), + endRow: condition.endRow.toString(), + levels: condition.levels.toString(), + }); + }, [condition.startRow, condition.endRow, condition.levels]); + + // 계산된 위치 수 + const locationCount = useMemo(() => { + const start = parseInt(localValues.startRow) || 0; + const end = parseInt(localValues.endRow) || 0; + const levels = parseInt(localValues.levels) || 0; + if (start > 0 && end >= start && levels > 0) { + return (end - start + 1) * levels; + } + return 0; + }, [localValues]); + + // 입력값 변경 핸들러 + const handleChange = (field: keyof typeof localValues, value: string) => { + setLocalValues((prev) => ({ ...prev, [field]: value })); + }; + + // blur 시 실제 업데이트 + const handleBlur = (field: keyof typeof localValues) => { + const numValue = parseInt(localValues[field]) || 0; + const clampedValue = Math.max(0, Math.min(numValue, field === "levels" ? maxLevels : maxRows)); + + setLocalValues((prev) => ({ ...prev, [field]: clampedValue.toString() })); + + const updateField = field === "startRow" ? "startRow" : field === "endRow" ? "endRow" : "levels"; + onUpdate(condition.id, { [updateField]: clampedValue }); + }; + + return ( +
+ {/* 헤더 */} +
+ 조건 {index + 1} + {!readonly && ( + + )} +
+ + {/* 내용 */} +
+ {/* 열 범위 */} +
+
+ +
+ handleChange("startRow", e.target.value)} + onBlur={() => handleBlur("startRow")} + disabled={readonly} + className="h-9 text-center" + /> + ~ + handleChange("endRow", e.target.value)} + onBlur={() => handleBlur("endRow")} + disabled={readonly} + className="h-9 text-center" + /> +
+
+
+ + handleChange("levels", e.target.value)} + onBlur={() => handleBlur("levels")} + disabled={readonly} + className="h-9 text-center" + /> +
+
+ + {/* 계산 결과 */} +
+ {locationCount > 0 ? ( + <> + {localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "} + {locationCount}개 + + ) : ( + 값을 입력하세요 + )} +
+
+
+ ); +}; + +// 메인 컴포넌트 +export const RackStructureComponent: React.FC = ({ + config, + context: propContext, + formData, + onChange, + onConditionsChange, + isPreview = false, + tableName, +}) => { + // 조건 목록 + const [conditions, setConditions] = useState(config.initialConditions || []); + + // 템플릿 관련 상태 + const [templates, setTemplates] = useState([]); + const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false); + const [templateName, setTemplateName] = useState(""); + const [isSaveMode, setIsSaveMode] = useState(false); + + // 미리보기 데이터 + const [previewData, setPreviewData] = useState([]); + const [isPreviewGenerated, setIsPreviewGenerated] = useState(false); + + // 기존 데이터 중복 체크 관련 상태 + const [existingLocations, setExistingLocations] = useState([]); + const [isCheckingDuplicates, setIsCheckingDuplicates] = useState(false); + const [duplicateErrors, setDuplicateErrors] = useState<{ row: number; existingLevels: number[] }[]>([]); + + // 설정값 + const maxConditions = config.maxConditions || 10; + const maxRows = config.maxRows || 99; + const maxLevels = config.maxLevels || 20; + const readonly = config.readonly || isPreview; + const fieldMapping = config.fieldMapping || {}; + + // 카테고리 라벨 캐시 상태 + const [categoryLabels, setCategoryLabels] = useState>({}); + + // 카테고리 코드인지 확인 + const isCategoryCode = (value: string | undefined): boolean => { + return typeof value === "string" && value.startsWith("CATEGORY_"); + }; + + // 카테고리 라벨 조회 (비동기) + useEffect(() => { + const loadCategoryLabels = async () => { + if (!formData) return; + + // 카테고리 코드인 값들만 수집 + const valuesToLookup: string[] = []; + const fieldsToCheck = [ + fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined, + fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined, + fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined, + fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined, + ]; + + for (const value of fieldsToCheck) { + if (value && isCategoryCode(value) && !categoryLabels[value]) { + valuesToLookup.push(value); + } + } + + if (valuesToLookup.length === 0) return; + + try { + // 카테고리 코드로 라벨 일괄 조회 + const response = await getCategoryLabelsByCodes(valuesToLookup); + if (response.success && response.data) { + console.log("✅ 카테고리 라벨 조회 완료:", response.data); + setCategoryLabels((prev) => ({ ...prev, ...response.data })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }; + + loadCategoryLabels(); + }, [formData, fieldMapping]); + + // 카테고리 코드를 라벨로 변환하는 헬퍼 함수 + const getCategoryLabel = useCallback( + (value: string | undefined): string | undefined => { + if (!value) return undefined; + if (isCategoryCode(value)) { + return categoryLabels[value] || value; + } + return value; + }, + [categoryLabels], + ); + + // 필드 매핑을 통해 formData에서 컨텍스트 추출 + const context: RackStructureContext = useMemo(() => { + // propContext가 있으면 우선 사용 + if (propContext) return propContext; + + // formData와 fieldMapping을 사용하여 컨텍스트 생성 + if (!formData) return {}; + + const rawFloor = fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined; + const rawZone = fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined; + const rawLocationType = fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined; + const rawStatus = fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined; + + const ctx = { + warehouseCode: fieldMapping.warehouseCodeField ? formData[fieldMapping.warehouseCodeField] : undefined, + warehouseName: fieldMapping.warehouseNameField ? formData[fieldMapping.warehouseNameField] : undefined, + // 카테고리 값은 라벨로 변환 + floor: getCategoryLabel(rawFloor?.toString()), + zone: getCategoryLabel(rawZone), + locationType: getCategoryLabel(rawLocationType), + status: getCategoryLabel(rawStatus), + }; + + console.log("🏗️ [RackStructure] context 생성:", { + fieldMapping, + rawValues: { rawFloor, rawZone, rawLocationType, rawStatus }, + context: ctx, + }); + + return ctx; + }, [propContext, formData, fieldMapping, getCategoryLabel]); + + // 필수 필드 검증 + const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.floor) missing.push("층"); + if (!context.zone) missing.push("구역"); + return missing; + }, [context]); + + // 조건 변경 시 콜백 호출 + useEffect(() => { + onConditionsChange?.(conditions); + setIsPreviewGenerated(false); // 조건 변경 시 미리보기 초기화 + }, [conditions, onConditionsChange]); + + // 조건 추가 + const addCondition = useCallback(() => { + if (conditions.length >= maxConditions) return; + + // 마지막 조건의 다음 열부터 시작 + const lastCondition = conditions[conditions.length - 1]; + const startRow = lastCondition ? lastCondition.endRow + 1 : 1; + + const newCondition: RackLineCondition = { + id: generateId(), + startRow, + endRow: startRow + 2, + levels: 3, + }; + + setConditions((prev) => [...prev, newCondition]); + }, [conditions, maxConditions]); + + // 조건 업데이트 + const updateCondition = useCallback((id: string, updates: Partial) => { + setConditions((prev) => prev.map((cond) => (cond.id === id ? { ...cond, ...updates } : cond))); + }, []); + + // 조건 삭제 + const removeCondition = useCallback((id: string) => { + setConditions((prev) => prev.filter((cond) => cond.id !== id)); + }, []); + + // 열 범위 중복 검사 + const rowOverlapErrors = useMemo(() => { + const errors: { conditionIndex: number; overlappingWith: number; overlappingRows: number[] }[] = []; + + for (let i = 0; i < conditions.length; i++) { + const cond1 = conditions[i]; + if (cond1.startRow <= 0 || cond1.endRow < cond1.startRow) continue; + + for (let j = i + 1; j < conditions.length; j++) { + const cond2 = conditions[j]; + if (cond2.startRow <= 0 || cond2.endRow < cond2.startRow) continue; + + // 범위 겹침 확인 + const overlapStart = Math.max(cond1.startRow, cond2.startRow); + const overlapEnd = Math.min(cond1.endRow, cond2.endRow); + + if (overlapStart <= overlapEnd) { + // 겹치는 열 목록 + const overlappingRows: number[] = []; + for (let r = overlapStart; r <= overlapEnd; r++) { + overlappingRows.push(r); + } + + errors.push({ + conditionIndex: i, + overlappingWith: j, + overlappingRows, + }); + } + } + } + + return errors; + }, [conditions]); + + // 중복 열이 있는지 확인 + const hasRowOverlap = rowOverlapErrors.length > 0; + + // 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지) + const warehouseCodeForQuery = context.warehouseCode; + const floorForQuery = context.floor; // 라벨 값 (예: "1층") + const zoneForQuery = context.zone; // 라벨 값 (예: "A구역") + + // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) + useEffect(() => { + const loadExistingLocations = async () => { + console.log("🏗️ [RackStructure] 기존 데이터 조회 체크:", { + warehouseCode: warehouseCodeForQuery, + floor: floorForQuery, + zone: zoneForQuery, + }); + + // 필수 조건이 충족되지 않으면 기존 데이터 초기화 + // DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용 + if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + console.log("⚠️ [RackStructure] 필수 조건 미충족 - 조회 스킵"); + setExistingLocations([]); + setDuplicateErrors([]); + return; + } + + setIsCheckingDuplicates(true); + try { + // warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회 + // DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링 + // equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용) + const searchParams = { + warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, + floor: { value: floorForQuery, operator: "equals" }, + zone: { value: zoneForQuery, operator: "equals" }, + }; + console.log("🔍 기존 위치 데이터 조회 시작 (정확한 일치):", searchParams); + + // 직접 apiClient 사용하여 정확한 형식으로 요청 + // 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리 + // autoFilter: true로 회사별 데이터 필터링 적용 + const response = await apiClient.post("/table-management/tables/warehouse_location/data", { + page: 1, + size: 1000, // 충분히 큰 값 + search: searchParams, // 백엔드가 기대하는 형식 (equals 연산자로 정확한 일치) + autoFilter: true, // 회사별 데이터 필터링 (멀티테넌시) + }); + + console.log("🔍 기존 위치 데이터 응답:", response.data); + + // API 응답 구조: { success: true, data: { data: [...], total, ... } } + const responseData = response.data?.data || response.data; + const dataArray = Array.isArray(responseData) ? responseData : responseData?.data || []; + + if (dataArray.length > 0) { + const existing = dataArray.map((item: any) => ({ + row_num: item.row_num, + level_num: item.level_num, + location_code: item.location_code, + })); + setExistingLocations(existing); + console.log("✅ 기존 위치 데이터 조회 완료:", existing.length, "개", existing); + } else { + console.log("⚠️ 기존 위치 데이터 없음 또는 조회 실패"); + setExistingLocations([]); + } + } catch (error) { + console.error("기존 위치 데이터 조회 실패:", error); + setExistingLocations([]); + } finally { + setIsCheckingDuplicates(false); + } + }; + + loadExistingLocations(); + }, [warehouseCodeForQuery, floorForQuery, zoneForQuery]); + + // 조건 변경 시 기존 데이터와 중복 체크 + useEffect(() => { + if (existingLocations.length === 0) { + setDuplicateErrors([]); + return; + } + + // 현재 조건에서 생성될 열 목록 + const plannedRows = new Map(); // row -> levels + conditions.forEach((cond) => { + if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) { + for (let row = cond.startRow; row <= cond.endRow; row++) { + const levels: number[] = []; + for (let level = 1; level <= cond.levels; level++) { + levels.push(level); + } + plannedRows.set(row, levels); + } + } + }); + + // 기존 데이터와 중복 체크 + const errors: { row: number; existingLevels: number[] }[] = []; + plannedRows.forEach((levels, row) => { + const existingForRow = existingLocations.filter((loc) => parseInt(loc.row_num) === row); + if (existingForRow.length > 0) { + const existingLevels = existingForRow.map((loc) => parseInt(loc.level_num)); + const duplicateLevels = levels.filter((l) => existingLevels.includes(l)); + if (duplicateLevels.length > 0) { + errors.push({ row, existingLevels: duplicateLevels }); + } + } + }); + + setDuplicateErrors(errors); + }, [conditions, existingLocations]); + + // 기존 데이터와 중복이 있는지 확인 + const hasDuplicateWithExisting = duplicateErrors.length > 0; + + // 통계 계산 + const statistics = useMemo(() => { + let totalLocations = 0; + let totalRows = 0; + let maxLevel = 0; + const rowSet = new Set(); + + conditions.forEach((cond) => { + if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) { + const rowCount = cond.endRow - cond.startRow + 1; + totalLocations += rowCount * cond.levels; + for (let r = cond.startRow; r <= cond.endRow; r++) { + rowSet.add(r); + } + maxLevel = Math.max(maxLevel, cond.levels); + } + }); + + totalRows = rowSet.size; + return { totalLocations, totalRows, maxLevel }; + }, [conditions]); + + // 위치 코드 생성 + const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const warehouseCode = context?.warehouseCode || "WH001"; + const floor = context?.floor || "1"; + const zone = context?.zone || "A"; + + // 코드 생성 (예: WH001-1층D구역-01-1) + const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + + // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 + const zoneName = zone.includes("구역") ? zone : `${zone}구역`; + const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; + + return { code, name }; + }, + [context], + ); + + // 미리보기 생성 + const generatePreview = useCallback(() => { + console.log("🔍 [generatePreview] 검증 시작:", { + missingFields, + hasRowOverlap, + hasDuplicateWithExisting, + duplicateErrorsCount: duplicateErrors.length, + existingLocationsCount: existingLocations.length, + }); + + // 필수 필드 검증 + if (missingFields.length > 0) { + alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`); + return; + } + + // 열 범위 중복 검증 + if (hasRowOverlap) { + const overlapInfo = rowOverlapErrors + .map((err) => { + const rows = err.overlappingRows.join(", "); + return `조건 ${err.conditionIndex + 1}과 조건 ${err.overlappingWith + 1}의 ${rows}열`; + }) + .join("\n"); + alert(`열 범위가 중복됩니다:\n${overlapInfo}\n\n중복된 열을 수정해주세요.`); + return; + } + + // 기존 데이터와 중복 검증 - duplicateErrors 직접 체크 + if (duplicateErrors.length > 0) { + const duplicateInfo = duplicateErrors + .map((err) => { + return `${err.row}열 ${err.existingLevels.join(", ")}단`; + }) + .join(", "); + alert( + `이미 등록된 위치가 있습니다:\n${duplicateInfo}\n\n해당 열/단을 제외하고 등록하거나, 기존 데이터를 삭제해주세요.`, + ); + return; + } + + const locations: GeneratedLocation[] = []; + + conditions.forEach((cond) => { + if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) { + for (let row = cond.startRow; row <= cond.endRow; row++) { + for (let level = 1; level <= cond.levels; level++) { + const { code, name } = generateLocationCode(row, level); + // 테이블 컬럼명과 동일하게 생성 + locations.push({ + row_num: String(row), + level_num: String(level), + location_code: code, + location_name: name, + location_type: context?.locationType || "선반", + status: context?.status || "사용", + // 추가 필드 (테이블 컬럼명과 동일) + warehouse_code: context?.warehouseCode, + warehouse_name: context?.warehouseName, + floor: context?.floor, + zone: context?.zone, + }); + } + } + } + }); + + // 정렬: 열 -> 단 순서 + locations.sort((a, b) => { + if (a.row_num !== b.row_num) return parseInt(a.row_num) - parseInt(b.row_num); + return parseInt(a.level_num) - parseInt(b.level_num); + }); + + setPreviewData(locations); + setIsPreviewGenerated(true); + + console.log("🏗️ [RackStructure] 생성된 위치 데이터:", { + locationsCount: locations.length, + firstLocation: locations[0], + context: { + warehouseCode: context?.warehouseCode, + warehouseName: context?.warehouseName, + floor: context?.floor, + zone: context?.zone, + }, + }); + + onChange?.(locations); + }, [ + conditions, + context, + generateLocationCode, + onChange, + missingFields, + hasRowOverlap, + duplicateErrors, + existingLocations, + rowOverlapErrors, + ]); + + // 템플릿 저장 + const saveTemplate = useCallback(() => { + if (!templateName.trim()) return; + + const newTemplate: RackStructureTemplate = { + id: generateId(), + name: templateName.trim(), + conditions: [...conditions], + createdAt: new Date().toISOString(), + }; + + setTemplates((prev) => [...prev, newTemplate]); + setTemplateName(""); + setIsTemplateDialogOpen(false); + }, [templateName, conditions]); + + // 템플릿 불러오기 + const loadTemplate = useCallback((template: RackStructureTemplate) => { + setConditions(template.conditions.map((c) => ({ ...c, id: generateId() }))); + setIsTemplateDialogOpen(false); + }, []); + + // 템플릿 삭제 + const deleteTemplate = useCallback((templateId: string) => { + setTemplates((prev) => prev.filter((t) => t.id !== templateId)); + }, []); + + return ( +
+ {/* 렉 라인 구조 설정 섹션 */} + + + +
렉 라인 구조 설정 + + {!readonly && ( +
+ {config.showTemplates && ( + <> + + + )} + +
+ )} + + + {/* 필수 필드 경고 */} + {missingFields.length > 0 && ( + + + + 다음 필드를 먼저 입력해주세요: {missingFields.join(", ")} +
+ (설정 패널에서 필드 매핑을 확인하세요) +
+
+ )} + + {/* 열 범위 중복 경고 */} + {hasRowOverlap && ( + + + + 열 범위가 중복됩니다! +
    + {rowOverlapErrors.map((err, idx) => ( +
  • + 조건 {err.conditionIndex + 1}과 조건 {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}열 + 중복 +
  • + ))} +
+ 중복된 열 범위를 수정해주세요. +
+
+ )} + + {/* 기존 데이터 중복 경고 */} + {hasDuplicateWithExisting && ( + + + + 이미 등록된 위치가 있습니다! +
    + {duplicateErrors.map((err, idx) => ( +
  • + {err.row}열: {err.existingLevels.join(", ")}단 (이미 등록됨) +
  • + ))} +
+ 해당 열/단을 제외하거나 기존 데이터를 삭제해주세요. +
+
+ )} + + {/* 기존 데이터 로딩 중 표시 */} + {isCheckingDuplicates && ( + + + 기존 위치 데이터를 확인하는 중... + + )} + + {/* 기존 데이터 존재 알림 */} + {!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && ( + + + + 해당 창고/층/구역에 {existingLocations.length}개의 위치가 이미 등록되어 있습니다. + + + )} + + {/* 현재 매핑된 값 표시 */} + {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && ( +
+ {(context.warehouseCode || context.warehouseName) && ( + + 창고: {context.warehouseName || context.warehouseCode} + {context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`} + + )} + {context.floor && ( + 층: {context.floor} + )} + {context.zone && ( + 구역: {context.zone} + )} + {context.locationType && ( + + 유형: {context.locationType} + + )} + {context.status && ( + 상태: {context.status} + )} +
+ )} + + {/* 안내 메시지 */} +
+
    +
  1. + + 1 + + 조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요 +
  2. +
  3. + + 2 + + 각 조건마다 열 범위와 단 수를 입력하세요 +
  4. +
  5. + + 3 + + 예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단) +
  6. +
+
+ + {/* 조건 목록 또는 빈 상태 */} + {conditions.length === 0 ? ( +
+
📦
+

조건을 추가하여 렉 구조를 설정하세요

+ {!readonly && ( + + )} +
+ ) : ( +
+ {conditions.map((condition, index) => ( +
+ +
+ ))} +
+ )} +
+ + + {/* 등록 미리보기 섹션 */} + {config.showPreview && conditions.length > 0 && ( + + + + + 등록 미리보기 + + + + + {/* 통계 카드 */} + {config.showStatistics && ( +
+
+
총 위치
+
{statistics.totalLocations}개
+
+
+
열 수
+
{statistics.totalRows}개
+
+
+
최대 단
+
{statistics.maxLevel}단
+
+
+ )} + + {/* 미리보기 테이블 */} + {isPreviewGenerated && previewData.length > 0 ? ( +
+ + + + + No + 위치코드 + 위치명 + + 구역 + + + 유형 + 비고 + + + + {previewData.map((loc, idx) => ( + + {idx + 1} + {loc.location_code} + {loc.location_name} + {loc.floor || context?.floor || "1"} + {loc.zone || context?.zone || "A"} + {loc.row_num.padStart(2, "0")} + {loc.level_num} + {loc.location_type} + - + + ))} + +
+
+
+ ) : ( +
+ +

미리보기 생성 버튼을 클릭하여 결과를 확인하세요

+
+ )} +
+
+ )} + + {/* 템플릿 다이얼로그 */} + + + + {isSaveMode ? "템플릿 저장" : "템플릿 관리"} + + + {isSaveMode ? ( +
+
+ + setTemplateName(e.target.value)} + placeholder="템플릿 이름을 입력하세요" + /> +
+ + + + +
+ ) : ( +
+ {/* 저장 버튼 */} + {conditions.length > 0 && ( + + )} + + {/* 템플릿 목록 */} + {templates.length > 0 ? ( +
+
저장된 템플릿
+ + {templates.map((template) => ( +
+
+
{template.name}
+
{template.conditions.length}개 조건
+
+
+ + +
+
+ ))} +
+
+ ) : ( +
저장된 템플릿이 없습니다
+ )} +
+ )} +
+
+
+ ); +}; + +// Wrapper 컴포넌트 (레지스트리용) +export const RackStructureWrapper: React.FC = (props) => { + return ( +
+ +
+ ); +}; diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx new file mode 100644 index 00000000..8f0c8177 --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx @@ -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 = ({ + 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 ( +
+ {/* 필드 매핑 섹션 */} +
+
필드 매핑
+

+ 상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요 +

+ + {/* 창고 코드 필드 */} +
+ + +
+ + {/* 창고명 필드 */} +
+ + +
+ + {/* 층 필드 */} +
+ + +
+ + {/* 구역 필드 */} +
+ + +
+ + {/* 위치 유형 필드 */} +
+ + +
+ + {/* 사용 여부 필드 */} +
+ + +
+
+ + {/* 제한 설정 */} +
+
제한 설정
+ +
+ + handleChange("maxConditions", parseInt(e.target.value) || 10)} + className="h-8" + /> +
+ +
+ + handleChange("maxRows", parseInt(e.target.value) || 99)} + className="h-8" + /> +
+ +
+ + handleChange("maxLevels", parseInt(e.target.value) || 20)} + className="h-8" + /> +
+
+ + {/* UI 설정 */} +
+
UI 설정
+ +
+ + handleChange("showTemplates", checked)} + /> +
+ +
+ + handleChange("showPreview", checked)} + /> +
+ +
+ + handleChange("showStatistics", checked)} + /> +
+ +
+ + handleChange("readonly", checked)} + /> +
+
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureRenderer.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureRenderer.tsx new file mode 100644 index 00000000..594f5a53 --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureRenderer.tsx @@ -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; + + return ( + } + 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(); +} diff --git a/frontend/lib/registry/components/v2-rack-structure/config.ts b/frontend/lib/registry/components/v2-rack-structure/config.ts new file mode 100644 index 00000000..09d9d04b --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/config.ts @@ -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: [], +}; + + diff --git a/frontend/lib/registry/components/v2-rack-structure/index.ts b/frontend/lib/registry/components/v2-rack-structure/index.ts new file mode 100644 index 00000000..f4ff2b7d --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/index.ts @@ -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"; + + diff --git a/frontend/lib/registry/components/v2-rack-structure/types.ts b/frontend/lib/registry/components/v2-rack-structure/types.ts new file mode 100644 index 00000000..76214972 --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/types.ts @@ -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; // 상위 폼 데이터 (필드 매핑에 사용) + onChange?: (locations: GeneratedLocation[]) => void; + onConditionsChange?: (conditions: RackLineCondition[]) => void; + isPreview?: boolean; + tableName?: string; +} + + diff --git a/frontend/lib/registry/components/v2-repeat-container/RepeatContainerComponent.tsx b/frontend/lib/registry/components/v2-repeat-container/RepeatContainerComponent.tsx new file mode 100644 index 00000000..b51b2448 --- /dev/null +++ b/frontend/lib/registry/components/v2-repeat-container/RepeatContainerComponent.tsx @@ -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; + // formData 변경 콜백 + onFormDataChange?: (key: string, value: any) => void; + // 선택 변경 콜백 + onSelectionChange?: (selectedData: any[]) => void; + // 사용자 정보 + userId?: string; + userName?: string; + companyCode?: string; + // 화면 정보 + screenId?: number; + screenTableName?: string; + // 컴포넌트 업데이트 콜백 (디자인 모드에서 드래그앤드롭용) + onUpdateComponent?: (updates: Partial) => 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([]); + const [selectedIndices, setSelectedIndices] = useState([]); + 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 = {}; + 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, 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 => { + 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 ( +
+ 반복 아이템 #{context.index + 1} +
+ ); + } + + // 현재 아이템 데이터를 formData로 전달 + const itemFormData = { + ...formData, + ...context.data, + _repeatIndex: context.index, + _repeatTotal: context.totalCount, + _isFirst: context.isFirst, + _isLast: context.isLast, + }; + + // 슬롯에 배치된 컴포넌트들을 렌더링 (Flow 레이아웃으로 변경) + return ( +
+ {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 ( +
+ { + if (onFormDataChange) { + onFormDataChange(`_repeat_${context.index}_${key}`, value); + } + }} + /> +
+ ); + })} +
+ ); + }, + [ + 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 ( +
{ + // 시각적 상태만 리셋, 드롭 로직은 ScreenDesigner에서 처리 + setIsDragOver(false); + // 중요: preventDefault()를 호출하지 않아야 이벤트가 버블링됨 + // 하지만 필요하다면 호출해도 됨 - 버블링과 무관 + }} + > +
+
+ + 리피터 컨테이너 + ({previewData.length}개 미리보기) +
+ {isDragOver ? ( +
+ + 여기에 놓으세요 +
+ ) : !hasChildren ? ( +
+ + 컴포넌트를 드래그하세요 +
+ ) : null} +
+ +
+ {previewData.map((row, index) => { + const context: RepeatItemContext = { + index, + data: row, + totalCount: previewData.length, + isFirst: index === 0, + isLast: index === previewData.length - 1, + }; + + return ( +
+ {showItemTitle && (titleColumn || itemTitleTemplate) && ( +
+
+ {generateTitle(row, index)} +
+ {descriptionColumn && generateDescription(row) && ( +
+ {generateDescription(row)} +
+ )} +
+ )} + + {hasChildren ? ( +
+ {/* 디자인 모드: 배치된 자식 컴포넌트들을 시각적으로 표시 */} + {slotChildren!.map((child: SlotComponentConfig, childIdx: number) => ( +
+
+ {childIdx + 1} +
+
+
+ {child.label || child.componentType} +
+ {child.fieldName && ( +
+ {child.fieldName} +
+ )} +
+
+ {child.componentType} +
+
+ ))} +
+ 아이템 #{index + 1} - 실행 시 데이터 바인딩 +
+
+ ) : ( +
+
+ 반복 아이템 #{index + 1} +
+
+ 컴포넌트를 드래그하여 배치하세요 +
+
+ )} +
+ ); + })} +
+
+ ); + } + + // 빈 상태 + if (paginatedData.length === 0 && !isLoading) { + return ( +
+ +

{emptyMessage}

+
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+
+ 로딩 중... +
+ ); + } + + // 실제 렌더링 + return ( +
+
+ {paginatedData.map((row, index) => { + const context: RepeatItemContext = { + index, + data: row, + totalCount: filteredData.length, + isFirst: index === 0, + isLast: index === paginatedData.length - 1, + }; + + return ( +
handleItemClick(index, row)} + > + {showItemTitle && (titleColumn || itemTitleTemplate) && ( +
+
+ {generateTitle(row, index)} +
+ {descriptionColumn && generateDescription(row) && ( +
+ {generateDescription(row)} +
+ )} +
+ )} + + {renderSlotChildren(context)} +
+ ); + })} +
+ + {/* 페이징 */} + {usePaging && totalPages > 1 && ( +
+ + + + {currentPage} / {totalPages} + + + +
+ )} +
+ ); +} + +export const RepeatContainerWrapper = RepeatContainerComponent; diff --git a/frontend/lib/registry/components/v2-repeat-container/RepeatContainerConfigPanel.tsx b/frontend/lib/registry/components/v2-repeat-container/RepeatContainerConfigPanel.tsx new file mode 100644 index 00000000..2eddd234 --- /dev/null +++ b/frontend/lib/registry/components/v2-repeat-container/RepeatContainerConfigPanel.tsx @@ -0,0 +1,1173 @@ +"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 { Database, Table2, ChevronsUpDown, Check, LayoutGrid, LayoutList, Rows3, Plus, X, Type, Settings2, ChevronUp } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { RepeatContainerConfig, SlotComponentConfig } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; + +interface RepeatContainerConfigPanelProps { + config: RepeatContainerConfig; + onChange: (config: Partial) => void; + screenTableName?: string; +} + +/** + * 리피터 컨테이너 설정 패널 + */ +export function RepeatContainerConfigPanel({ + config, + onChange, + screenTableName, +}: RepeatContainerConfigPanelProps) { + const [availableTables, setAvailableTables] = useState>([]); + const [loadingTables, setLoadingTables] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + + // 컬럼 관련 상태 + const [availableColumns, setAvailableColumns] = useState>([]); + const [loadingColumns, setLoadingColumns] = useState(false); + + // 제목/설명 컬럼 콤보박스 상태 + const [titleColumnOpen, setTitleColumnOpen] = useState(false); + const [descriptionColumnOpen, setDescriptionColumnOpen] = 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(() => { + if (!targetTableName) { + setAvailableColumns([]); + return; + } + + const fetchColumns = async () => { + setLoadingColumns(true); + try { + const response = await tableManagementApi.getColumnList(targetTableName); + // API 응답이 { data: { columns: [...] } } 또는 { data: [...] } 형태일 수 있음 + const columnsData = response.data?.columns || response.data; + if (response.success && columnsData && Array.isArray(columnsData)) { + const columns = columnsData.map((col: any) => ({ + columnName: col.columnName, + displayName: col.displayName || col.columnLabel || col.columnName, + })); + setAvailableColumns(columns); + } + } catch (error) { + console.error("컬럼 목록 가져오기 실패:", error); + setAvailableColumns([]); + } finally { + setLoadingColumns(false); + } + }; + fetchColumns(); + }, [targetTableName, config.tableName, screenTableName, config.useCustomTable, config.customTableName]); + + return ( +
+
리피터 컨테이너 설정
+ + {/* 데이터 소스 테이블 설정 */} +
+
+

데이터 소스 테이블

+

반복 렌더링할 데이터의 테이블을 선택합니다

+
+
+ + {/* 현재 선택된 테이블 표시 (카드 형태) */} +
+ +
+
+ {config.customTableName || config.tableName || screenTableName || "테이블 미선택"} +
+
+ {config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"} +
+
+
+ + {/* 테이블 선택 Combobox */} + + + + + + + + + 테이블을 찾을 수 없습니다 + + {/* 그룹 1: 화면 기본 테이블 */} + {screenTableName && ( + + { + onChange({ + useCustomTable: false, + customTableName: undefined, + tableName: screenTableName, + }); + setTableComboboxOpen(false); + }} + className="text-xs cursor-pointer" + > + + + {screenTableName} + + + )} + + {/* 그룹 2: 전체 테이블 */} + + {availableTables + .filter((table) => table.tableName !== screenTableName) + .map((table) => ( + { + onChange({ + useCustomTable: true, + customTableName: table.tableName, + tableName: table.tableName, + }); + setTableComboboxOpen(false); + }} + className="text-xs cursor-pointer" + > + + + {table.displayName || table.tableName} + + ))} + + + + + +
+ + {/* 데이터 소스 컴포넌트 연결 */} +
+
+

데이터 소스 연결

+

+ 테이블 리스트에서 선택한 데이터를 받아올 수 있습니다 +

+
+
+ +
+ + +
+ + {config.dataSourceType === "table-list" && ( +
+ + onChange({ dataSourceComponentId: e.target.value })} + placeholder="비우면 테이블명으로 자동 매칭" + className="h-8 text-xs" + /> +

+ 비워두면 위에서 설정한 테이블명과 같은 테이블 리스트에서 데이터를 받습니다 +

+
+ )} +
+ + {/* 슬롯 컴포넌트 설정 */} + + + {/* 레이아웃 설정 */} +
+
+

레이아웃

+
+
+ + {/* 레이아웃 타입 선택 */} +
+ +
+ + + +
+
+ + {/* 그리드 컬럼 수 (grid 레이아웃일 때만) */} + {config.layout === "grid" && ( +
+ + +
+ )} + + {/* 간격 */} +
+ + onChange({ gap: e.target.value })} + placeholder="16px" + className="h-8 text-xs" + /> +
+
+ + {/* 아이템 카드 설정 */} +
+
+

아이템 카드 스타일

+
+
+ +
+
+ + onChange({ backgroundColor: e.target.value })} + className="h-8" + /> +
+ +
+ + onChange({ borderRadius: e.target.value })} + placeholder="8px" + className="h-8 text-xs" + /> +
+ +
+ + onChange({ padding: e.target.value })} + placeholder="16px" + className="h-8 text-xs" + /> +
+ +
+ + onChange({ itemHeight: e.target.value })} + placeholder="auto" + className="h-8 text-xs" + /> +
+
+ +
+
+ onChange({ showBorder: checked as boolean })} + /> + +
+ +
+ onChange({ showShadow: checked as boolean })} + /> + +
+
+
+ + {/* 아이템 제목/설명 설정 */} +
+
+ onChange({ showItemTitle: checked as boolean })} + /> + +
+
+ + {config.showItemTitle && ( +
+ {/* 제목 컬럼 선택 (Combobox) */} +
+ + + + + + + + + + 컬럼을 찾을 수 없습니다 + + { + onChange({ titleColumn: "" }); + setTitleColumnOpen(false); + }} + className="text-xs" + > + + 선택 안함 + + {availableColumns.map((col) => ( + { + onChange({ titleColumn: col.columnName }); + setTitleColumnOpen(false); + }} + className="text-xs" + > + +
+ {col.displayName || col.columnName} + {col.displayName && col.displayName !== col.columnName && ( + {col.columnName} + )} +
+
+ ))} +
+
+
+
+
+ {config.titleColumn && ( +

+ 각 아이템의 "{config.titleColumn}" 값이 제목으로 표시됩니다 +

+ )} +
+ + {/* 설명 컬럼 선택 (Combobox) */} +
+ + + + + + + + + + 컬럼을 찾을 수 없습니다 + + { + onChange({ descriptionColumn: "" }); + setDescriptionColumnOpen(false); + }} + className="text-xs" + > + + 선택 안함 + + {availableColumns.map((col) => ( + { + onChange({ descriptionColumn: col.columnName }); + setDescriptionColumnOpen(false); + }} + className="text-xs" + > + +
+ {col.displayName || col.columnName} + {col.displayName && col.displayName !== col.columnName && ( + {col.columnName} + )} +
+
+ ))} +
+
+
+
+
+ {config.descriptionColumn && ( +

+ 각 아이템의 "{config.descriptionColumn}" 값이 설명으로 표시됩니다 +

+ )} +
+ + {/* 제목 스타일 설정 */} +
+ +
+
+ + +
+
+ + onChange({ titleColor: e.target.value })} + className="h-7" + /> +
+
+ + +
+
+
+ + {/* 설명 스타일 설정 */} + {config.descriptionColumn && ( +
+ +
+
+ + +
+
+ + onChange({ descriptionColor: e.target.value })} + className="h-7" + /> +
+
+
+ )} +
+ )} +
+ + {/* 페이징 설정 */} +
+
+ onChange({ usePaging: checked as boolean })} + /> + +
+
+ + {config.usePaging && ( +
+ + +
+ )} +
+ + {/* 상호작용 설정 */} +
+
+

상호작용

+
+
+ +
+
+ onChange({ clickable: checked as boolean })} + /> + +
+ + {config.clickable && ( + <> +
+ onChange({ showSelectedState: checked as boolean })} + /> + +
+ +
+ + +
+ + )} +
+
+ + {/* 빈 상태 설정 */} +
+
+

빈 상태 메시지

+
+
+ +
+ + onChange({ emptyMessage: e.target.value })} + placeholder="데이터가 없습니다" + className="h-8 text-xs" + /> +
+
+
+ ); +} + +// ============================================================ +// 슬롯 자식 컴포넌트 관리 섹션 +// ============================================================ + +interface SlotChildrenSectionProps { + config: RepeatContainerConfig; + onChange: (config: Partial) => void; + availableColumns: Array<{ columnName: string; displayName?: string }>; + loadingColumns: boolean; + screenTableName?: string; +} + +function SlotChildrenSection({ + config, + onChange, + availableColumns, + loadingColumns, + screenTableName, +}: SlotChildrenSectionProps) { + const [columnComboboxOpen, setColumnComboboxOpen] = useState(false); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const children = config.children || []; + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const addComponent = (columnName: string, displayName: string) => { + const newChild: SlotComponentConfig = { + id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: "text-display", + label: displayName, + fieldName: columnName, + position: { x: 0, y: children.length * 40 }, + size: { width: 200, height: 32 }, + componentConfig: {}, + style: {}, + }; + + onChange({ + children: [...children, newChild], + }); + setColumnComboboxOpen(false); + }; + + const removeComponent = (id: string) => { + onChange({ + children: children.filter((c) => c.id !== id), + }); + setExpandedIds((prev) => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); + }; + + const updateComponentLabel = (id: string, label: string) => { + onChange({ + children: children.map((c) => (c.id === id ? { ...c, label } : c)), + }); + }; + + const updateComponentConfig = (id: string, key: string, value: any) => { + onChange({ + children: children.map((c) => + c.id === id + ? { ...c, componentConfig: { ...c.componentConfig, [key]: value } } + : c + ), + }); + }; + + const updateComponentStyle = (id: string, key: string, value: any) => { + onChange({ + children: children.map((c) => + c.id === id + ? { ...c, style: { ...c.style, [key]: value } } + : c + ), + }); + }; + + const updateComponentSize = (id: string, width: number | undefined, height: number | undefined) => { + onChange({ + children: children.map((c) => + c.id === id + ? { ...c, size: { width: width ?? c.size?.width ?? 200, height: height ?? c.size?.height ?? 32 } } + : c + ), + }); + }; + + return ( +
+
+

반복 표시 필드

+

+ 데이터 테이블의 컬럼을 선택하여 각 행에 표시할 필드를 추가합니다 +

+
+
+ + {/* 추가된 필드 목록 */} + {children.length > 0 ? ( +
+ {children.map((child, index) => { + const isExpanded = expandedIds.has(child.id); + return ( +
+ {/* 기본 정보 헤더 - 타입 선택 드롭다운 제거됨 */} +
+
+ {index + 1} +
+
+
+ {child.label || child.fieldName} +
+
+ 필드: {child.fieldName} +
+
+ + +
+ + {/* 상세 설정 패널 */} + {isExpanded && ( +
+ {hasComponentConfigPanel(child.componentType) ? ( + { + onChange({ + children: children.map((c) => + c.id === child.id + ? { ...c, componentConfig: { ...c.componentConfig, ...newConfig } } + : c + ), + }); + }} + onLabelChange={(label) => updateComponentLabel(child.id, label)} + /> + ) : ( + <> + {child.fieldName && ( +
+
+ + + 바인딩: {child.fieldName} + +
+

+ 각 아이템의 "{child.fieldName}" 값이 자동으로 표시됩니다 +

+
+ )} + +
+ + updateComponentLabel(child.id, e.target.value)} + placeholder="표시할 라벨" + className="h-7 text-xs" + /> +
+ +
+
+ + + updateComponentSize(child.id, parseInt(e.target.value) || 200, undefined) + } + className="h-7 text-xs" + /> +
+
+ + + updateComponentSize(child.id, undefined, parseInt(e.target.value) || 32) + } + className="h-7 text-xs" + /> +
+
+ +
+ +
+
+ + +
+
+ + updateComponentStyle(child.id, "color", e.target.value)} + className="h-7" + /> +
+
+
+ + )} +
+ )} +
+ ); + })} +
+ ) : ( +
+ +
표시할 필드가 없습니다
+
+ 아래 컬럼 목록에서 선택하세요 +
+
+ )} + + {/* 컬럼 선택 Combobox */} +
+ + + + + + + + + + 컬럼을 찾을 수 없습니다 + + {availableColumns.map((col) => { + const isAdded = children.some((c) => c.fieldName === col.columnName); + return ( + { + if (!isAdded) { + addComponent(col.columnName, col.displayName || col.columnName); + } + }} + disabled={isAdded} + className={cn( + "text-xs cursor-pointer", + isAdded && "opacity-50 cursor-not-allowed" + )} + > + +
+
{col.displayName || col.columnName}
+
+ {col.columnName} +
+
+ {isAdded && ( + + )} +
+ ); + })} +
+
+
+
+
+
+
+ ); +} + +// 슬롯 컴포넌트 상세 설정 패널 +interface SlotComponentDetailPanelProps { + child: SlotComponentConfig; + screenTableName?: string; + onConfigChange: (newConfig: Record) => void; + onLabelChange: (label: string) => void; +} + +function SlotComponentDetailPanel({ + child, + screenTableName, + onConfigChange, + onLabelChange, +}: SlotComponentDetailPanelProps) { + return ( +
+ {child.fieldName && ( +
+
+ + + 바인딩: {child.fieldName} + +
+

+ 각 아이템의 "{child.fieldName}" 값이 자동으로 표시됩니다 +

+
+ )} + +
+ + onLabelChange(e.target.value)} + placeholder="표시할 라벨" + className="h-7 text-xs" + /> +
+ +
+
+ {child.componentType} 상세 설정 +
+ +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-repeat-container/RepeatContainerRenderer.tsx b/frontend/lib/registry/components/v2-repeat-container/RepeatContainerRenderer.tsx new file mode 100644 index 00000000..da664b2f --- /dev/null +++ b/frontend/lib/registry/components/v2-repeat-container/RepeatContainerRenderer.tsx @@ -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 {}; + diff --git a/frontend/lib/registry/components/v2-repeat-container/index.ts b/frontend/lib/registry/components/v2-repeat-container/index.ts new file mode 100644 index 00000000..890a0bf9 --- /dev/null +++ b/frontend/lib/registry/components/v2-repeat-container/index.ts @@ -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, + 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"; + diff --git a/frontend/lib/registry/components/v2-repeat-container/types.ts b/frontend/lib/registry/components/v2-repeat-container/types.ts new file mode 100644 index 00000000..961c11e7 --- /dev/null +++ b/frontend/lib/registry/components/v2-repeat-container/types.ts @@ -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; + /** 스타일 설정 */ + style?: Record; +} + +/** + * 리피터 컨테이너 설정 + * 데이터 수만큼 내부 컴포넌트 또는 컨텐츠를 반복 렌더링하는 컨테이너 + */ +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[]; + /** 선택된 아이템 인덱스들 */ + selectedIndices?: number[]; + /** 현재 페이지 (페이징 사용 시) */ + currentPage?: number; +} + +/** + * 리피터 컨텍스트 (각 반복 아이템에서 사용) + */ +export interface RepeatItemContext { + /** 현재 아이템 인덱스 */ + index: number; + /** 현재 아이템 데이터 */ + data: Record; + /** 전체 데이터 수 */ + totalCount: number; + /** 첫 번째 아이템인지 */ + isFirst: boolean; + /** 마지막 아이템인지 */ + isLast: boolean; + /** 그룹 키 (그룹핑 사용 시) */ + groupKey?: string; + /** 그룹 내 인덱스 (그룹핑 사용 시) */ + groupIndex?: number; +} + diff --git a/frontend/lib/registry/components/v2-repeat-screen-modal/README.md b/frontend/lib/registry/components/v2-repeat-screen-modal/README.md new file mode 100644 index 00000000..cb22964d --- /dev/null +++ b/frontend/lib/registry/components/v2-repeat-screen-modal/README.md @@ -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; // 신규 행 기본값 ({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; + }; +} +``` + +### 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 분리 diff --git a/frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalComponent.tsx new file mode 100644 index 00000000..16cf7dfc --- /dev/null +++ b/frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -0,0 +1,3179 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw, Pencil } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + RepeatScreenModalProps, + CardData, + CardColumnConfig, + GroupedCardData, + CardRowData, + AggregationConfig, + TableColumnConfig, + CardContentRowConfig, + AggregationDisplayConfig, + FooterConfig, + FooterButtonConfig, + TableDataSourceConfig, + TableCrudConfig, +} from "./types"; +import { ComponentRendererProps } from "@/types/component"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; + +export interface RepeatScreenModalComponentProps extends ComponentRendererProps { + config?: RepeatScreenModalProps; + groupedData?: Record[]; // EditModal에서 전달하는 그룹 데이터 +} + +export function RepeatScreenModalComponent({ + component, + isDesignMode = false, + formData, + onFormDataChange, + config, + className, + groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터 + // DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지) + _initialData, + _originalData: _propsOriginalData, + _groupedData, + ...props +}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) { + // props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음) + // DynamicComponentRenderer에서는 _groupedData로 전달됨 + const groupedData = propsGroupedData || (props as any).groupedData || _groupedData; + const componentConfig = { + ...config, + ...component?.config, + }; + + // 설정 값 추출 + const dataSource = componentConfig?.dataSource; + const saveMode = componentConfig?.saveMode || "all"; + const cardSpacing = componentConfig?.cardSpacing || "24px"; + const showCardBorder = componentConfig?.showCardBorder ?? true; + const showCardTitle = componentConfig?.showCardTitle ?? true; + const cardTitle = componentConfig?.cardTitle || "카드 {index}"; + const grouping = componentConfig?.grouping; + + // 🆕 v3: 자유 레이아웃 + const contentRows = componentConfig?.contentRows || []; + + // 🆕 v3.1: Footer 설정 + const footerConfig = componentConfig?.footerConfig; + + // (레거시 호환) + const cardLayout = componentConfig?.cardLayout || []; + const cardMode = componentConfig?.cardMode || "simple"; + const tableLayout = componentConfig?.tableLayout; + + // 상태 + const [rawData, setRawData] = useState([]); // 원본 데이터 + const [cardsData, setCardsData] = useState([]); // simple 모드용 + const [groupedCardsData, setGroupedCardsData] = useState([]); // withTable 모드용 + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // 🆕 v3.1: 외부 테이블 데이터 (테이블 행별로 관리) + const [externalTableData, setExternalTableData] = useState>({}); + // 🆕 v3.1: 삭제 확인 다이얼로그 + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [pendingDeleteInfo, setPendingDeleteInfo] = useState<{ + cardId: string; + rowId: string; + contentRowId: string; + } | null>(null); + + // 🆕 v3.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가 + useEffect(() => { + const handleTriggerSave = async (event: Event) => { + if (!(event instanceof CustomEvent)) return; + + console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신"); + + try { + setIsSaving(true); + + // 기존 데이터 저장 + if (cardMode === "withTable") { + await saveGroupedData(); + } else { + await saveSimpleData(); + } + + // 외부 테이블 데이터 저장 + await saveExternalTableData(); + + // 연동 저장 처리 (syncSaves) + await processSyncSaves(); + + console.log("[RepeatScreenModal] 외부 트리거 저장 완료"); + + // 저장 완료 이벤트 발생 + window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { + detail: { success: true } + })); + + // 성공 콜백 실행 + if (event.detail?.onSuccess) { + event.detail.onSuccess(); + } + } catch (error: any) { + console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error); + + // 저장 실패 이벤트 발생 + window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { + detail: { success: false, error: error.message } + })); + + // 실패 콜백 실행 + if (event.detail?.onError) { + event.detail.onError(error); + } + } finally { + setIsSaving(false); + } + }; + + window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener); + return () => { + window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener); + }; + }, [cardMode, groupedCardsData, externalTableData, contentRows]); + + // 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합 + useEffect(() => { + const handleBeforeFormSave = (event: Event) => { + if (!(event instanceof CustomEvent) || !event.detail?.formData) return; + + console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신"); + console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData); + console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드"); + + // 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비 + const saveDataByTable: Record = {}; + + for (const [key, rows] of Object.entries(externalTableData)) { + // key 형식: cardId-contentRowId + const keyParts = key.split("-"); + const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId + + // contentRow 찾기 + const contentRow = contentRows.find((r) => key.includes(r.id)); + if (!contentRow?.tableDataSource?.enabled) continue; + + // 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해) + const card = groupedCardsData.find((c) => c._cardId === cardId); + const representativeData = card?._representativeData || {}; + + const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; + + // dirty 행 또는 새로운 행 필터링 (삭제된 행 제외) + // 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음) + const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted); + + console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, { + totalRows: rows.length, + dirtyRows: dirtyRows.length, + rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted })) + }); + + if (dirtyRows.length === 0) continue; + + // 저장할 필드만 추출 + const editableFields = (contentRow.tableColumns || []) + .filter((col) => col.editable) + .map((col) => col.field); + + // 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출 + const joinConditions = contentRow.tableDataSource.joinConditions || []; + const joinKeys = joinConditions.map((cond) => cond.sourceKey); + + const allowedFields = [...new Set([...editableFields, ...joinKeys])]; + + if (!saveDataByTable[targetTable]) { + saveDataByTable[targetTable] = []; + } + + for (const row of dirtyRows) { + const saveData: Record = {}; + + // 허용된 필드만 포함 + for (const field of allowedFields) { + if (row[field] !== undefined) { + saveData[field] = row[field]; + } + } + + // 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기 + // 예: sales_order_id (sourceKey) = card의 id (targetKey) + for (const joinCond of joinConditions) { + const { sourceKey, targetKey } = joinCond; + // sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴 + if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) { + saveData[sourceKey] = representativeData[targetKey]; + console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`); + } + } + + // _isNew 플래그 유지 + saveData._isNew = row._isNew; + saveData._targetTable = targetTable; + + // 기존 레코드의 경우 id 포함 + if (!row._isNew && row._originalData?.id) { + saveData.id = row._originalData.id; + } + + saveDataByTable[targetTable].push(saveData); + } + } + + // formData에 테이블별 저장 데이터 추가 + for (const [tableName, rows] of Object.entries(saveDataByTable)) { + const fieldKey = `_repeatScreenModal_${tableName}`; + event.detail.formData[fieldKey] = rows; + console.log(`[RepeatScreenModal] beforeFormSave - ${tableName} 저장 데이터:`, rows); + } + + // 🆕 v3.9: 집계 저장 설정 정보도 formData에 추가 + if (grouping?.aggregations && groupedCardsData.length > 0) { + const aggregationSaveConfigs: Array<{ + resultField: string; + aggregatedValue: number; + targetTable: string; + targetColumn: string; + joinKey: { sourceField: string; targetField: string }; + sourceValue: any; // 조인 키 값 + }> = []; + + for (const card of groupedCardsData) { + for (const agg of grouping.aggregations) { + if (agg.saveConfig?.enabled) { + const { saveConfig, resultField } = agg; + const { targetTable, targetColumn, joinKey } = saveConfig; + + if (!targetTable || !targetColumn || !joinKey?.sourceField || !joinKey?.targetField) { + continue; + } + + const aggregatedValue = card._aggregations?.[resultField] ?? 0; + const sourceValue = card._representativeData?.[joinKey.sourceField]; + + if (sourceValue !== undefined) { + aggregationSaveConfigs.push({ + resultField, + aggregatedValue, + targetTable, + targetColumn, + joinKey, + sourceValue, + }); + } + } + } + } + + if (aggregationSaveConfigs.length > 0) { + event.detail.formData._repeatScreenModal_aggregations = aggregationSaveConfigs; + console.log("[RepeatScreenModal] beforeFormSave - 집계 저장 설정:", aggregationSaveConfigs); + } + } + }; + + window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + return () => { + window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + }; + }, [externalTableData, contentRows, grouping, groupedCardsData]); + + // 초기 데이터 로드 + useEffect(() => { + const loadInitialData = async () => { + console.log("[RepeatScreenModal] 데이터 로드 시작"); + console.log("[RepeatScreenModal] groupedData (from EditModal):", groupedData); + console.log("[RepeatScreenModal] formData:", formData); + console.log("[RepeatScreenModal] dataSource:", dataSource); + + setIsLoading(true); + setLoadError(null); + + try { + let loadedData: any[] = []; + + // 🆕 우선순위 1: EditModal에서 전달받은 groupedData 사용 + if (groupedData && groupedData.length > 0) { + console.log("[RepeatScreenModal] groupedData 사용:", groupedData.length, "건"); + loadedData = groupedData; + } + // 우선순위 2: API 호출 + else if (dataSource && dataSource.sourceTable) { + // 필터 조건 생성 + const filters: Record = {}; + + // formData에서 선택된 행 ID 가져오기 + let selectedIds: any[] = []; + + if (formData) { + // 1. 명시적으로 설정된 filterField 확인 + if (dataSource.filterField) { + const filterValue = formData[dataSource.filterField]; + if (filterValue) { + selectedIds = Array.isArray(filterValue) ? filterValue : [filterValue]; + } + } + + // 2. 일반적인 선택 필드 확인 (fallback) + if (selectedIds.length === 0) { + const commonFields = ['selectedRows', 'selectedIds', 'checkedRows', 'checkedIds', 'ids']; + for (const field of commonFields) { + if (formData[field]) { + const value = formData[field]; + selectedIds = Array.isArray(value) ? value : [value]; + console.log(`[RepeatScreenModal] ${field}에서 선택된 ID 발견:`, selectedIds); + break; + } + } + } + + // 3. formData에 id가 있으면 단일 행 + if (selectedIds.length === 0 && formData.id) { + selectedIds = [formData.id]; + console.log("[RepeatScreenModal] formData.id 사용:", selectedIds); + } + } + + console.log("[RepeatScreenModal] 최종 선택된 ID:", selectedIds); + + // 선택된 ID가 있으면 필터 적용 + if (selectedIds.length > 0) { + filters.id = selectedIds; + } else { + console.warn("[RepeatScreenModal] 선택된 데이터가 없습니다."); + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + console.log("[RepeatScreenModal] API 필터:", filters); + + // API 호출 + const response = await apiClient.post(`/table-management/tables/${dataSource.sourceTable}/data`, { + search: filters, + page: 1, + size: 1000, + }); + + if (response.data.success && response.data.data?.data) { + loadedData = response.data.data.data; + } + } else { + console.log("[RepeatScreenModal] 데이터 소스 없음"); + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + console.log("[RepeatScreenModal] 로드된 데이터:", loadedData.length, "건"); + + if (loadedData.length === 0) { + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + setRawData(loadedData); + + // 🆕 v3: contentRows가 있으면 새로운 방식 사용 + const useNewLayout = contentRows && contentRows.length > 0; + + // 그룹핑 모드 확인 (groupByField가 없어도 enabled면 그룹핑 모드로 처리) + const useGrouping = grouping?.enabled; + + if (useGrouping) { + // 그룹핑 모드 + const grouped = processGroupedData(loadedData, grouping); + setGroupedCardsData(grouped); + } else { + // 단순 모드: 각 행이 하나의 카드 + const initialCards: CardData[] = await Promise.all( + loadedData.map(async (row: any, index: number) => ({ + _cardId: `card-${index}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + ...(await loadCardData(row)), + })) + ); + setCardsData(initialCards); + } + } catch (error: any) { + console.error("데이터 로드 실패:", error); + setLoadError(error.message || "데이터 로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + loadInitialData(); + }, [dataSource, formData, groupedData, contentRows, grouping?.enabled, grouping?.groupByField]); + + // 🆕 v3.1: 외부 테이블 데이터 로드 + useEffect(() => { + const loadExternalTableData = async () => { + // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 + const tableRowsWithExternalSource = contentRows.filter( + (row) => row.type === "table" && row.tableDataSource?.enabled + ); + + if (tableRowsWithExternalSource.length === 0) return; + if (groupedCardsData.length === 0 && cardsData.length === 0) return; + + const newExternalData: Record = {}; + + for (const contentRow of tableRowsWithExternalSource) { + const dataSourceConfig = contentRow.tableDataSource!; + const cards = groupedCardsData.length > 0 ? groupedCardsData : cardsData; + + for (const card of cards) { + const cardId = card._cardId; + const representativeData = (card as GroupedCardData)._representativeData || card; + + try { + // 조인 조건 생성 + const filters: Record = {}; + for (const condition of dataSourceConfig.joinConditions) { + let refValue = representativeData[condition.referenceKey]; + if (refValue !== undefined && refValue !== null) { + // 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189) + // 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로 + // 정확한 ID 매칭을 위해 숫자로 변환해야 함 + if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') { + const numValue = Number(refValue); + if (!isNaN(numValue)) { + refValue = numValue; + } + } + filters[condition.sourceKey] = refValue; + } + } + + if (Object.keys(filters).length === 0) { + console.warn(`[RepeatScreenModal] 조인 조건이 없습니다: ${contentRow.id}`); + continue; + } + + console.log(`[RepeatScreenModal] 외부 테이블 API 호출:`, { + sourceTable: dataSourceConfig.sourceTable, + filters, + joinConditions: dataSourceConfig.joinConditions, + representativeDataId: representativeData.id, + representativeDataIdType: typeof representativeData.id, + }); + + // API 호출 - 메인 테이블 데이터 + const response = await apiClient.post( + `/table-management/tables/${dataSourceConfig.sourceTable}/data`, + { + search: filters, + page: 1, + size: dataSourceConfig.limit || 100, + sort: dataSourceConfig.orderBy + ? { + column: dataSourceConfig.orderBy.column, + direction: dataSourceConfig.orderBy.direction, + } + : undefined, + } + ); + + if (response.data.success && response.data.data?.data) { + let tableData = response.data.data.data; + + console.log(`[RepeatScreenModal] 소스 테이블 데이터 로드 완료:`, { + sourceTable: dataSourceConfig.sourceTable, + rowCount: tableData.length, + sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], + firstRowData: tableData[0], + // 디버그: plan_date 필드 확인 + plan_date_value: tableData[0]?.plan_date, + }); + + // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 + if (dataSourceConfig.additionalJoins && dataSourceConfig.additionalJoins.length > 0) { + console.log(`[RepeatScreenModal] 조인 설정:`, dataSourceConfig.additionalJoins); + tableData = await loadAndMergeJoinData(tableData, dataSourceConfig.additionalJoins); + console.log(`[RepeatScreenModal] 조인 후 데이터:`, { + rowCount: tableData.length, + sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], + firstRowData: tableData[0], + }); + } + + // 🆕 v3.4: 필터 조건 적용 + if (dataSourceConfig.filterConfig?.enabled) { + const { filterField, filterType, referenceField, referenceSource } = dataSourceConfig.filterConfig; + + // 비교 값 가져오기 + let referenceValue: any; + if (referenceSource === "formData") { + referenceValue = formData?.[referenceField]; + } else { + // representativeData + referenceValue = representativeData[referenceField]; + } + + if (referenceValue !== undefined && referenceValue !== null) { + tableData = tableData.filter((row: any) => { + const rowValue = row[filterField]; + if (filterType === "equals") { + return rowValue === referenceValue; + } else { + // notEquals + return rowValue !== referenceValue; + } + }); + + console.log(`[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`); + } + } + + const key = `${cardId}-${contentRow.id}`; + newExternalData[key] = tableData.map((row: any, idx: number) => ({ + _rowId: `ext-row-${cardId}-${contentRow.id}-${idx}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + _isNew: false, + _isEditing: false, // 🆕 v3.8: 로드된 데이터는 읽기 전용 + _isDeleted: false, + ...row, + })); + + // 디버그: 저장된 외부 테이블 데이터 확인 + console.log(`[RepeatScreenModal] 외부 테이블 데이터 저장:`, { + key, + rowCount: newExternalData[key].length, + firstRow: newExternalData[key][0], + plan_date_in_firstRow: newExternalData[key][0]?.plan_date, + }); + } + } catch (error) { + console.error(`[RepeatScreenModal] 외부 테이블 데이터 로드 실패:`, error); + } + } + } + + setExternalTableData((prev) => { + // 이전 데이터와 동일하면 업데이트하지 않음 (무한 루프 방지) + const prevKeys = Object.keys(prev).sort().join(","); + const newKeys = Object.keys(newExternalData).sort().join(","); + if (prevKeys === newKeys) { + // 키가 같으면 데이터 내용 비교 + const isSame = Object.keys(newExternalData).every( + (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]) + ); + if (isSame) return prev; + } + + // 🆕 v3.2: 외부 테이블 데이터 로드 후 집계 재계산 + // 비동기적으로 처리하여 무한 루프 방지 + setTimeout(() => { + recalculateAggregationsWithExternalData(newExternalData); + }, 0); + + return newExternalData; + }); + }; + + loadExternalTableData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contentRows, groupedCardsData.length, cardsData.length]); + + // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 + const loadAndMergeJoinData = async ( + mainData: any[], + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[] + ): Promise => { + if (mainData.length === 0) return mainData; + + // 각 조인 테이블별로 필요한 키 값들 수집 + for (const joinConfig of additionalJoins) { + if (!joinConfig.joinTable || !joinConfig.sourceKey || !joinConfig.targetKey) continue; + + // 메인 데이터에서 조인 키 값들 추출 + const joinKeyValues = [...new Set(mainData.map((row) => row[joinConfig.sourceKey]).filter(Boolean))]; + + if (joinKeyValues.length === 0) continue; + + try { + // 조인 테이블 데이터 조회 + const joinResponse = await apiClient.post( + `/table-management/tables/${joinConfig.joinTable}/data`, + { + search: { [joinConfig.targetKey]: joinKeyValues }, + page: 1, + size: 1000, // 충분히 큰 값 + } + ); + + if (joinResponse.data.success && joinResponse.data.data?.data) { + const joinData = joinResponse.data.data.data; + + // 조인 데이터를 맵으로 변환 (빠른 조회를 위해) + const joinDataMap = new Map(); + for (const joinRow of joinData) { + joinDataMap.set(joinRow[joinConfig.targetKey], joinRow); + } + + // 메인 데이터에 조인 데이터 병합 + mainData = mainData.map((row) => { + const joinKey = row[joinConfig.sourceKey]; + const joinRow = joinDataMap.get(joinKey); + + if (joinRow) { + // 조인 테이블의 컬럼들을 메인 데이터에 추가 (접두사 없이) + const mergedRow = { ...row }; + for (const [key, value] of Object.entries(joinRow)) { + // 이미 존재하는 키가 아닌 경우에만 추가 (메인 테이블 우선) + if (!(key in mergedRow)) { + mergedRow[key] = value; + } else { + // 충돌하는 경우 조인 테이블명을 접두사로 사용 + mergedRow[`${joinConfig.joinTable}_${key}`] = value; + } + } + return mergedRow; + } + return row; + }); + } + } catch (error) { + console.error(`[RepeatScreenModal] 조인 테이블 데이터 로드 실패 (${joinConfig.joinTable}):`, error); + } + } + + return mainData; + }; + + // 🆕 v3.2: 외부 테이블 데이터가 로드된 후 집계 재계산 + const recalculateAggregationsWithExternalData = (extData: Record) => { + if (!grouping?.aggregations || grouping.aggregations.length === 0) return; + if (groupedCardsData.length === 0) return; + + // 외부 테이블 집계 또는 formula가 있는지 확인 + const hasExternalAggregation = grouping.aggregations.some((agg) => { + const sourceType = agg.sourceType || "column"; + if (sourceType === "formula") return true; // formula는 외부 테이블 참조 가능 + if (sourceType === "column") { + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + return sourceTable && sourceTable !== dataSource?.sourceTable; + } + return false; + }); + + if (!hasExternalAggregation) return; + + // contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기 + const tableRowsWithExternalSource = contentRows.filter( + (row) => row.type === "table" && row.tableDataSource?.enabled + ); + + if (tableRowsWithExternalSource.length === 0) return; + + // 각 카드의 집계 재계산 + const updatedCards = groupedCardsData.map((card) => { + // 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장 + const externalRowsByTableId: Record = {}; + const allExternalRows: any[] = []; + + for (const tableRow of tableRowsWithExternalSource) { + const key = `${card._cardId}-${tableRow.id}`; + // 🆕 v3.7: 삭제된 행은 집계에서 제외 + const rows = (extData[key] || []).filter((row) => !row._isDeleted); + externalRowsByTableId[tableRow.id] = rows; + allExternalRows.push(...rows); + } + + // 집계 재계산 + const newAggregations: Record = {}; + + grouping.aggregations!.forEach((agg) => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + if (isExternalTable) { + // 외부 테이블 집계 + newAggregations[agg.resultField] = calculateColumnAggregation( + allExternalRows, + agg.sourceField || "", + agg.type || "sum" + ); + } else { + // 기본 테이블 집계 (기존 값 유지) + newAggregations[agg.resultField] = card._aggregations[agg.resultField] || + calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); + } + } else if (sourceType === "formula" && agg.formula) { + // 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용 + let filteredExternalRows: any[]; + + if (agg.externalTableRefs && agg.externalTableRefs.length > 0) { + // 특정 테이블만 참조 + filteredExternalRows = []; + for (const tableId of agg.externalTableRefs) { + if (externalRowsByTableId[tableId]) { + filteredExternalRows.push(...externalRowsByTableId[tableId]); + } + } + } else { + // 모든 외부 테이블 데이터 사용 (기존 동작) + filteredExternalRows = allExternalRows; + } + + // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 + newAggregations[agg.resultField] = evaluateFormulaWithContext( + agg.formula, + card._representativeData, + card._rows, + filteredExternalRows, + newAggregations // 이전 집계 결과 참조 + ); + } + }); + + return { + ...card, + _aggregations: newAggregations, + }; + }); + + // 변경된 경우에만 업데이트 (무한 루프 방지) + setGroupedCardsData((prev) => { + const hasChanges = updatedCards.some((card, idx) => { + const prevCard = prev[idx]; + if (!prevCard) return true; + return JSON.stringify(card._aggregations) !== JSON.stringify(prevCard._aggregations); + }); + return hasChanges ? updatedCards : prev; + }); + }; + + // 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가) + const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + const key = `${cardId}-${contentRowId}`; + const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId); + const representativeData = (card as GroupedCardData)?._representativeData || card || {}; + + // 기본값 생성 + const newRowData: Record = { + _rowId: `new-row-${Date.now()}`, + _originalData: {}, + _isDirty: true, + _isNew: true, + }; + + // 🆕 v3.5: 카드 대표 데이터에서 조인 테이블 컬럼 값 자동 채우기 + // tableColumns에서 정의된 필드들 중 representativeData에 있는 값을 자동으로 채움 + if (contentRow.tableColumns) { + for (const col of contentRow.tableColumns) { + // representativeData에 해당 필드가 있으면 자동으로 채움 + if (representativeData[col.field] !== undefined && representativeData[col.field] !== null) { + newRowData[col.field] = representativeData[col.field]; + } + } + } + + // 🆕 v3.5: 조인 조건의 키 값도 자동으로 채움 (예: sales_order_id) + if (contentRow.tableDataSource?.joinConditions) { + for (const condition of contentRow.tableDataSource.joinConditions) { + // sourceKey는 소스 테이블(예: shipment_plan)의 컬럼 + // referenceKey는 카드 대표 데이터의 컬럼 (예: id) + const refValue = representativeData[condition.referenceKey]; + if (refValue !== undefined && refValue !== null) { + newRowData[condition.sourceKey] = refValue; + } + } + } + + // newRowDefaults 적용 (사용자 정의 기본값이 우선) + if (contentRow.tableCrud?.newRowDefaults) { + for (const [field, template] of Object.entries(contentRow.tableCrud.newRowDefaults)) { + // {fieldName} 형식의 템플릿 치환 + let value = template; + const matches = template.match(/\{(\w+)\}/g); + if (matches) { + for (const match of matches) { + const fieldName = match.slice(1, -1); + value = value.replace(match, String(representativeData[fieldName] || "")); + } + } + newRowData[field] = value; + } + } + + // 🆕 v3.13: 자동 채번 처리 + const rowNumbering = contentRow.tableCrud?.rowNumbering; + console.log("[RepeatScreenModal] 채번 설정 확인:", { + tableCrud: contentRow.tableCrud, + rowNumbering, + enabled: rowNumbering?.enabled, + targetColumn: rowNumbering?.targetColumn, + numberingRuleId: rowNumbering?.numberingRuleId, + }); + if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) { + try { + console.log("[RepeatScreenModal] 자동 채번 시작:", { + targetColumn: rowNumbering.targetColumn, + numberingRuleId: rowNumbering.numberingRuleId, + }); + + // 채번 API 호출 (allocate: 실제 시퀀스 증가) + const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + const response = await allocateNumberingCode(rowNumbering.numberingRuleId); + + if (response.success && response.data) { + newRowData[rowNumbering.targetColumn] = response.data.generatedCode; + + console.log("[RepeatScreenModal] 자동 채번 완료:", { + column: rowNumbering.targetColumn, + generatedCode: response.data.generatedCode, + }); + } else { + console.warn("[RepeatScreenModal] 채번 실패:", response); + } + } catch (error) { + console.error("[RepeatScreenModal] 채번 API 호출 실패:", error); + } + } + + console.log("[RepeatScreenModal] 새 행 추가:", { + cardId, + contentRowId, + representativeData, + newRowData, + }); + + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: [...(prev[key] || []), newRowData], + }; + + // 🆕 v3.5: 새 행 추가 시 집계 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + }; + + // 🆕 v3.6: 테이블 영역 저장 기능 + const saveTableAreaData = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + const key = `${cardId}-${contentRowId}`; + const rows = externalTableData[key] || []; + + console.log("[RepeatScreenModal] saveTableAreaData 시작:", { + key, + rowsCount: rows.length, + contentRowId, + tableDataSource: contentRow?.tableDataSource, + tableCrud: contentRow?.tableCrud, + }); + + if (!contentRow?.tableDataSource?.enabled) { + console.warn("[RepeatScreenModal] 외부 테이블 데이터 소스가 설정되지 않음"); + return { success: false, message: "데이터 소스가 설정되지 않았습니다." }; + } + + const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; + const dirtyRows = rows.filter((row) => row._isDirty); + + console.log("[RepeatScreenModal] 저장 대상:", { + targetTable, + dirtyRowsCount: dirtyRows.length, + dirtyRows: dirtyRows.map(r => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })), + }); + + if (dirtyRows.length === 0) { + return { success: true, message: "저장할 변경사항이 없습니다.", savedCount: 0 }; + } + + const savePromises: Promise[] = []; + const savedIds: number[] = []; + + // 🆕 v3.6: editable한 컬럼 + 조인 키만 추출 (읽기 전용 컬럼은 제외) + const allowedFields = new Set(); + + // tableColumns에서 editable: true인 필드만 추가 (읽기 전용 컬럼 제외) + if (contentRow.tableColumns) { + contentRow.tableColumns.forEach((col) => { + // editable이 명시적으로 true이거나, editable이 undefined가 아니고 false가 아닌 경우 + // 또는 inputType이 있는 경우 (입력 가능한 컬럼) + if (col.field && (col.editable === true || col.inputType)) { + allowedFields.add(col.field); + } + }); + } + + // 조인 조건의 sourceKey 추가 (예: sales_order_id) - 이건 항상 필요 + if (contentRow.tableDataSource?.joinConditions) { + contentRow.tableDataSource.joinConditions.forEach((cond) => { + if (cond.sourceKey) allowedFields.add(cond.sourceKey); + }); + } + + console.log("[RepeatScreenModal] 저장 허용 필드 (editable + 조인키):", Array.from(allowedFields)); + console.log("[RepeatScreenModal] tableColumns 정보:", contentRow.tableColumns?.map(c => ({ + field: c.field, + editable: c.editable, + inputType: c.inputType + }))); + + // 삭제할 행 (기존 데이터 중 _isDeleted가 true인 것) + const deletedRows = dirtyRows.filter((row) => row._isDeleted && row._originalData?.id); + // 저장할 행 (삭제되지 않은 것) + const rowsToSave = dirtyRows.filter((row) => !row._isDeleted); + + console.log("[RepeatScreenModal] 삭제 대상:", deletedRows.length, "건"); + console.log("[RepeatScreenModal] 저장 대상:", rowsToSave.length, "건"); + + // 🆕 v3.7: 삭제 처리 (배열 형태로 body에 전달) + for (const row of deletedRows) { + const deleteId = row._originalData.id; + console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [{ id: deleteId }]); + savePromises.push( + apiClient.request({ + method: "DELETE", + url: `/table-management/tables/${targetTable}/delete`, + data: [{ id: deleteId }], + }).then((res) => { + console.log("[RepeatScreenModal] DELETE 응답:", res.data); + return { type: "delete", id: deleteId }; + }).catch((err) => { + console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message); + throw err; + }) + ); + } + + for (const row of rowsToSave) { + const { _rowId, _originalData, _isDirty, _isNew, _isDeleted, ...allData } = row; + + // 허용된 필드만 필터링 + const dataToSave: Record = {}; + for (const field of allowedFields) { + if (allData[field] !== undefined) { + dataToSave[field] = allData[field]; + } + } + + console.log("[RepeatScreenModal] 저장할 데이터:", { + _isNew, + _originalData, + allData, + dataToSave, + }); + + if (_isNew) { + // INSERT - /add 엔드포인트 사용 + console.log(`[RepeatScreenModal] INSERT 요청: /table-management/tables/${targetTable}/add`, dataToSave); + savePromises.push( + apiClient.post(`/table-management/tables/${targetTable}/add`, dataToSave).then((res) => { + console.log("[RepeatScreenModal] INSERT 응답:", res.data); + if (res.data?.data?.id) { + savedIds.push(res.data.data.id); + } + return res; + }).catch((err) => { + console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message); + throw err; + }) + ); + } else if (_originalData?.id) { + // UPDATE - /edit 엔드포인트 사용 (originalData와 updatedData 형식) + const updatePayload = { + originalData: _originalData, + updatedData: { ...dataToSave, id: _originalData.id }, + }; + console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updatePayload); + savePromises.push( + apiClient.put(`/table-management/tables/${targetTable}/edit`, updatePayload).then((res) => { + console.log("[RepeatScreenModal] UPDATE 응답:", res.data); + savedIds.push(_originalData.id); + return res; + }).catch((err) => { + console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message); + throw err; + }) + ); + } + } + + try { + await Promise.all(savePromises); + + // 저장 후: 삭제된 행은 제거, 나머지는 dirty/editing 플래그 초기화 + setExternalTableData((prev) => { + const updated = { ...prev }; + if (updated[key]) { + // 삭제된 행은 완전히 제거 + updated[key] = updated[key] + .filter((row) => !row._isDeleted) + .map((row) => ({ + ...row, + _isDirty: false, + _isNew: false, + _isEditing: false, // 🆕 v3.8: 수정 모드 해제 + _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined, _isDeleted: undefined, _isEditing: undefined }, + })); + } + return updated; + }); + + const savedCount = rowsToSave.length; + const deletedCount = deletedRows.length; + const message = deletedCount > 0 + ? `${savedCount}건 저장, ${deletedCount}건 삭제 완료` + : `${savedCount}건 저장 완료`; + + return { success: true, message, savedCount, deletedCount, savedIds }; + } catch (error: any) { + console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", error); + return { success: false, message: error.message || "저장 중 오류가 발생했습니다." }; + } + }; + + // 🆕 v3.6: 테이블 영역 저장 핸들러 + const handleTableAreaSave = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + setIsSaving(true); + try { + const result = await saveTableAreaData(cardId, contentRowId, contentRow); + if (result.success) { + console.log("[RepeatScreenModal] 테이블 영역 저장 성공:", result); + + // 🆕 v3.9: 집계 저장 설정이 있는 경우 연관 테이블 동기화 + const card = groupedCardsData.find((c) => c._cardId === cardId); + if (card && grouping?.aggregations) { + await saveAggregationsToRelatedTables(card, contentRowId); + } + } else { + console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", result.message); + } + } finally { + setIsSaving(false); + } + }; + + // 🆕 v3.9: 집계 결과를 연관 테이블에 저장 + const saveAggregationsToRelatedTables = async (card: GroupedCardData, contentRowId: string) => { + if (!grouping?.aggregations) return; + + const savePromises: Promise[] = []; + + for (const agg of grouping.aggregations) { + const saveConfig = agg.saveConfig; + + // 저장 설정이 없거나 비활성화된 경우 스킵 + if (!saveConfig?.enabled) continue; + + // 자동 저장이 아닌 경우, 레이아웃에 연결되어 있는지 확인 필요 + // (현재는 자동 저장과 동일하게 처리 - 추후 레이아웃 연결 체크 추가 가능) + + // 집계 결과 값 가져오기 + const aggregatedValue = card._aggregations[agg.resultField]; + + if (aggregatedValue === undefined) { + console.warn(`[RepeatScreenModal] 집계 결과 없음: ${agg.resultField}`); + continue; + } + + // 조인 키로 대상 레코드 식별 + const sourceKeyValue = card._representativeData[saveConfig.joinKey.sourceField]; + + if (!sourceKeyValue) { + console.warn(`[RepeatScreenModal] 조인 키 값 없음: ${saveConfig.joinKey.sourceField}`); + continue; + } + + console.log(`[RepeatScreenModal] 집계 저장 시작:`, { + aggregation: agg.resultField, + value: aggregatedValue, + targetTable: saveConfig.targetTable, + targetColumn: saveConfig.targetColumn, + joinKey: `${saveConfig.joinKey.sourceField}=${sourceKeyValue} -> ${saveConfig.joinKey.targetField}`, + }); + + // UPDATE API 호출 + const updatePayload = { + originalData: { [saveConfig.joinKey.targetField]: sourceKeyValue }, + updatedData: { + [saveConfig.targetColumn]: aggregatedValue, + [saveConfig.joinKey.targetField]: sourceKeyValue, + }, + }; + + savePromises.push( + apiClient.put(`/table-management/tables/${saveConfig.targetTable}/edit`, updatePayload) + .then((res) => { + console.log(`[RepeatScreenModal] 집계 저장 성공: ${agg.resultField} -> ${saveConfig.targetTable}.${saveConfig.targetColumn}`); + return res; + }) + .catch((err) => { + console.error(`[RepeatScreenModal] 집계 저장 실패: ${agg.resultField}`, err.response?.data || err.message); + throw err; + }) + ); + } + + if (savePromises.length > 0) { + try { + await Promise.all(savePromises); + console.log(`[RepeatScreenModal] 모든 집계 저장 완료: ${savePromises.length}건`); + } catch (error) { + console.error("[RepeatScreenModal] 일부 집계 저장 실패:", error); + } + } + }; + + // 🆕 v3.1: 외부 테이블 행 삭제 요청 + const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { + // 삭제 확인 팝업 표시 + setPendingDeleteInfo({ cardId, rowId, contentRowId }); + setDeleteConfirmOpen(true); + } else { + // 바로 삭제 + handleDeleteExternalRow(cardId, rowId, contentRowId); + } + }; + + // 🆕 v3.14: 외부 테이블 행 삭제 실행 (즉시 DELETE API 호출) + const handleDeleteExternalRow = async (cardId: string, rowId: string, contentRowId: string) => { + const key = `${cardId}-${contentRowId}`; + const rows = externalTableData[key] || []; + const targetRow = rows.find((row) => row._rowId === rowId); + + // 기존 DB 데이터인 경우 (id가 있는 경우) 즉시 삭제 + if (targetRow?._originalData?.id) { + try { + const contentRow = contentRows.find((r) => r.id === contentRowId); + const targetTable = contentRow?.tableCrud?.targetTable || contentRow?.tableDataSource?.sourceTable; + + if (!targetTable) { + console.error("[RepeatScreenModal] 삭제 대상 테이블을 찾을 수 없습니다."); + return; + } + + console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`); + + // 백엔드는 배열 형태의 데이터를 기대함 + await apiClient.request({ + method: "DELETE", + url: `/table-management/tables/${targetTable}/delete`, + data: [{ id: targetRow._originalData.id }], + }); + + console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`); + + // 성공 시 UI에서 완전히 제거 + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: prev[key].filter((row) => row._rowId !== rowId), + }; + + // 행 삭제 시 집계 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + } catch (error: any) { + console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message); + // 에러 시에도 다이얼로그 닫기 + } + } else { + // 새로 추가된 행 (아직 DB에 없음) - UI에서만 제거 + console.log(`[RepeatScreenModal] 새 행 삭제 (DB 없음): rowId=${rowId}`); + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: prev[key].filter((row) => row._rowId !== rowId), + }; + + // 행 삭제 시 집계 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + } + + setDeleteConfirmOpen(false); + setPendingDeleteInfo(null); + }; + + // 🆕 v3.7: 삭제 취소 (소프트 삭제 복원) + const handleRestoreExternalRow = (cardId: string, rowId: string, contentRowId: string) => { + const key = `${cardId}-${contentRowId}`; + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: (prev[key] || []).map((row) => + row._rowId === rowId + ? { ...row, _isDeleted: false, _isDirty: true } + : row + ), + }; + + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + }; + + // 🆕 v3.8: 수정 모드 전환 + const handleEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => { + const key = `${cardId}-${contentRowId}`; + setExternalTableData((prev) => ({ + ...prev, + [key]: (prev[key] || []).map((row) => + row._rowId === rowId + ? { ...row, _isEditing: true } + : row + ), + })); + }; + + // 🆕 v3.8: 수정 취소 + const handleCancelEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => { + const key = `${cardId}-${contentRowId}`; + setExternalTableData((prev) => ({ + ...prev, + [key]: (prev[key] || []).map((row) => + row._rowId === rowId + ? { + ...row._originalData, + _rowId: row._rowId, + _originalData: row._originalData, + _isEditing: false, + _isDirty: false, + _isNew: false, + _isDeleted: false, + } + : row + ), + })); + }; + + // 🆕 v3.1: 외부 테이블 행 데이터 변경 + const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => { + const key = `${cardId}-${contentRowId}`; + + // 데이터 업데이트 + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: (prev[key] || []).map((row) => + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + ), + }; + + // 🆕 v3.5: 데이터 변경 시 집계 실시간 재계산 + // setTimeout으로 비동기 처리하여 상태 업데이트 후 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + }; + + // 그룹화된 데이터 처리 + const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => { + if (!groupingConfig?.enabled) { + return []; + } + + const groupByField = groupingConfig.groupByField; + const groupMap = new Map(); + + // groupByField가 없으면 각 행을 개별 그룹으로 처리 + if (!groupByField) { + // 각 행이 하나의 카드 (그룹) + data.forEach((row, index) => { + const groupKey = `row-${index}`; + groupMap.set(groupKey, [row]); + }); + } else { + // 그룹별로 데이터 분류 + data.forEach((row) => { + const groupKey = String(row[groupByField] || ""); + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, []); + } + groupMap.get(groupKey)!.push(row); + }); + } + + // GroupedCardData 생성 + const result: GroupedCardData[] = []; + let cardIndex = 0; + + groupMap.forEach((rows, groupKey) => { + // 행 데이터 생성 + const cardRows: CardRowData[] = rows.map((row, idx) => ({ + _rowId: `row-${cardIndex}-${idx}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + ...row, + })); + + const representativeData = rows[0] || {}; + + // 🆕 v3.2: 집계 계산 (순서대로 - 이전 집계 결과 참조 가능) + // 1단계: 기본 테이블 컬럼 집계만 (외부 테이블 데이터는 아직 없음) + const aggregations: Record = {}; + if (groupingConfig.aggregations) { + groupingConfig.aggregations.forEach((agg) => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + // 컬럼 집계 (기본 테이블만 - 외부 테이블은 나중에 처리) + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + if (!isExternalTable) { + // 기본 테이블 집계 + aggregations[agg.resultField] = calculateColumnAggregation( + rows, + agg.sourceField || "", + agg.type || "sum" + ); + } else { + // 외부 테이블 집계는 나중에 계산 (placeholder) + aggregations[agg.resultField] = 0; + } + } else if (sourceType === "formula") { + // 가상 집계 (연산식) - 외부 테이블 없이 먼저 계산 시도 + // 외부 테이블 데이터가 필요한 경우 나중에 재계산됨 + if (agg.formula) { + aggregations[agg.resultField] = evaluateFormulaWithContext( + agg.formula, + representativeData, + rows, + [], // 외부 테이블 데이터 없음 + aggregations // 이전 집계 결과 참조 + ); + } else { + aggregations[agg.resultField] = 0; + } + } + }); + } + + // 안정적인 _cardId 생성 (Date.now() 대신 groupKey 사용) + // groupKey가 없으면 대표 데이터의 id 사용 + const stableId = groupKey || representativeData.id || cardIndex; + result.push({ + _cardId: `grouped-card-${cardIndex}-${stableId}`, + _groupKey: groupKey, + _groupField: groupByField || "", + _aggregations: aggregations, + _rows: cardRows, + _representativeData: representativeData, + }); + + cardIndex++; + }); + + return result; + }; + + // 집계 계산 (컬럼 집계용) + const calculateColumnAggregation = ( + rows: any[], + sourceField: string, + type: "sum" | "count" | "avg" | "min" | "max" + ): number => { + const values = rows.map((row) => Number(row[sourceField]) || 0); + + switch (type) { + case "sum": + return values.reduce((a, b) => a + b, 0); + case "count": + return values.length; + case "avg": + return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; + case "min": + return values.length > 0 ? Math.min(...values) : 0; + case "max": + return values.length > 0 ? Math.max(...values) : 0; + default: + return 0; + } + }; + + // 🆕 v3.2: 집계 계산 (다중 테이블 및 formula 지원) + const calculateAggregation = ( + agg: AggregationConfig, + cardRows: any[], // 기본 테이블 행들 + externalRows: any[], // 외부 테이블 행들 + previousAggregations: Record, // 이전 집계 결과들 + representativeData: Record // 카드 대표 데이터 + ): number => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + // 컬럼 집계 + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + // 외부 테이블인 경우 externalRows 사용, 아니면 cardRows 사용 + const targetRows = isExternalTable ? externalRows : cardRows; + + return calculateColumnAggregation( + targetRows, + agg.sourceField || "", + agg.type || "sum" + ); + } else if (sourceType === "formula") { + // 가상 집계 (연산식) + if (!agg.formula) return 0; + + return evaluateFormulaWithContext( + agg.formula, + representativeData, + cardRows, + externalRows, + previousAggregations + ); + } + + return 0; + }; + + // 🆕 v3.1: 집계 표시값 계산 (formula, external 등 지원) + const calculateAggregationDisplayValue = ( + aggField: AggregationDisplayConfig, + card: GroupedCardData + ): number | string => { + const sourceType = aggField.sourceType || "aggregation"; + + switch (sourceType) { + case "aggregation": + // 기존 집계 결과 참조 + return card._aggregations?.[aggField.aggregationResultField || ""] || 0; + + case "formula": + // 컬럼 간 연산 + if (!aggField.formula) return 0; + return evaluateFormula(aggField.formula, card._representativeData, card._rows); + + case "external": + // 외부 테이블 값 (별도 로드 필요 - 현재는 placeholder) + // TODO: 외부 테이블 값 로드 구현 + return 0; + + case "externalFormula": + // 외부 테이블 + 연산 (별도 로드 필요 - 현재는 placeholder) + // TODO: 외부 테이블 값 로드 후 연산 구현 + return 0; + + default: + return 0; + } + }; + + // 🆕 v3.2: 연산식 평가 (다중 테이블, 이전 집계 결과 참조 지원) + const evaluateFormulaWithContext = ( + formula: string, + representativeData: Record, + cardRows: any[], // 기본 테이블 행들 + externalRows: any[], // 외부 테이블 행들 + previousAggregations: Record // 이전 집계 결과들 + ): number => { + try { + let expression = formula; + + // 1. 외부 테이블 집계 함수 처리: SUM_EXT({field}), COUNT_EXT({field}) 등 + const extAggFunctions = ["SUM_EXT", "COUNT_EXT", "AVG_EXT", "MIN_EXT", "MAX_EXT"]; + for (const fn of extAggFunctions) { + const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g"); + expression = expression.replace(regex, (match, fieldName) => { + if (!externalRows || externalRows.length === 0) { + console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`); + return "0"; + } + const values = externalRows.map((row) => Number(row[fieldName]) || 0); + const sum = values.reduce((a, b) => a + b, 0); + console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`); + const baseFn = fn.replace("_EXT", ""); + switch (baseFn) { + case "SUM": + return String(values.reduce((a, b) => a + b, 0)); + case "COUNT": + return String(values.length); + case "AVG": + return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); + case "MIN": + return String(values.length > 0 ? Math.min(...values) : 0); + case "MAX": + return String(values.length > 0 ? Math.max(...values) : 0); + default: + return "0"; + } + }); + } + + // 2. 기본 테이블 집계 함수 처리: SUM({field}), COUNT({field}) 등 + const aggFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + for (const fn of aggFunctions) { + // SUM_EXT는 이미 처리했으므로 제외 + const regex = new RegExp(`(? { + if (!cardRows || cardRows.length === 0) return "0"; + const values = cardRows.map((row) => Number(row[fieldName]) || 0); + switch (fn) { + case "SUM": + return String(values.reduce((a, b) => a + b, 0)); + case "COUNT": + return String(values.length); + case "AVG": + return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); + case "MIN": + return String(values.length > 0 ? Math.min(...values) : 0); + case "MAX": + return String(values.length > 0 ? Math.max(...values) : 0); + default: + return "0"; + } + }); + } + + // 3. 단순 필드 참조 치환 (이전 집계 결과 또는 대표 데이터) + const fieldRegex = /\{(\w+)\}/g; + expression = expression.replace(fieldRegex, (match, fieldName) => { + // 먼저 이전 집계 결과에서 찾기 + if (previousAggregations && fieldName in previousAggregations) { + return String(previousAggregations[fieldName]); + } + // 대표 데이터에서 값 가져오기 + const value = representativeData[fieldName]; + return String(Number(value) || 0); + }); + + // 4. 안전한 수식 평가 (사칙연산만 허용) + // 허용 문자: 숫자, 소수점, 사칙연산, 괄호, 공백 + if (!/^[\d\s+\-*/().]+$/.test(expression)) { + console.warn("[RepeatScreenModal] 허용되지 않는 연산식:", expression); + return 0; + } + + // eval 대신 Function 사용 (더 안전) + const result = new Function(`return ${expression}`)(); + return Number(result) || 0; + } catch (error) { + console.error("[RepeatScreenModal] 연산식 평가 실패:", formula, error); + return 0; + } + }; + + // 레거시 호환: 기존 evaluateFormula 유지 + const evaluateFormula = ( + formula: string, + representativeData: Record, + rows?: any[] + ): number => { + return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {}); + }; + + // 카드 데이터 로드 (소스 설정에 따라) + const loadCardData = async (originalData: any): Promise> => { + const cardData: Record = {}; + + // 🆕 v3: contentRows 사용 + if (contentRows && contentRows.length > 0) { + for (const contentRow of contentRows) { + // 헤더/필드 타입의 컬럼 처리 + if ((contentRow.type === "header" || contentRow.type === "fields") && contentRow.columns) { + for (const col of contentRow.columns) { + if (col.sourceConfig) { + if (col.sourceConfig.type === "direct") { + cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; + } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { + cardData[col.field] = null; // 조인은 나중에 일괄 처리 + } else if (col.sourceConfig.type === "manual") { + cardData[col.field] = null; + } + } else { + // sourceConfig가 없으면 원본 데이터에서 직접 가져옴 + cardData[col.field] = originalData[col.field]; + } + } + } + + // 테이블 타입의 컬럼 처리 + if (contentRow.type === "table" && contentRow.tableColumns) { + for (const col of contentRow.tableColumns) { + cardData[col.field] = originalData[col.field]; + } + } + } + } else { + // 레거시: cardLayout 사용 + for (const row of cardLayout) { + for (const col of row.columns) { + if (col.sourceConfig) { + if (col.sourceConfig.type === "direct") { + cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; + } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { + cardData[col.field] = null; // 조인은 나중에 일괄 처리 + } else if (col.sourceConfig.type === "manual") { + cardData[col.field] = null; + } + } else { + cardData[col.field] = originalData[col.field]; + } + } + } + } + + return cardData; + }; + + // Simple 모드: 카드 데이터 변경 + const handleCardDataChange = (cardId: string, field: string, value: any) => { + setCardsData((prev) => + prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)) + ); + }; + + // WithTable 모드: 행 데이터 변경 + const handleRowDataChange = (cardId: string, rowId: string, field: string, value: any) => { + setGroupedCardsData((prev) => + prev.map((card) => { + if (card._cardId !== cardId) return card; + + const updatedRows = card._rows.map((row) => + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + ); + + // 집계값 재계산 + const newAggregations: Record = {}; + if (grouping?.aggregations) { + grouping.aggregations.forEach((agg) => { + newAggregations[agg.resultField] = calculateAggregation(updatedRows, agg); + }); + } + + return { + ...card, + _rows: updatedRows, + _aggregations: newAggregations, + }; + }) + ); + }; + + // 카드 제목 생성 + const getCardTitle = (data: Record, index: number): string => { + let title = cardTitle; + title = title.replace("{index}", String(index + 1)); + + const matches = title.match(/\{(\w+)\}/g); + if (matches) { + matches.forEach((match) => { + const field = match.slice(1, -1); + const value = data[field] || ""; + title = title.replace(match, String(value)); + }); + } + + return title; + }; + + // 전체 저장 + const handleSaveAll = async () => { + setIsSaving(true); + + try { + // 기존 데이터 저장 + if (cardMode === "withTable") { + await saveGroupedData(); + } else { + await saveSimpleData(); + } + + // 🆕 v3.1: 외부 테이블 데이터 저장 + await saveExternalTableData(); + + // 🆕 v3.12: 연동 저장 처리 (syncSaves) + await processSyncSaves(); + + alert("저장되었습니다."); + } catch (error: any) { + console.error("저장 실패:", error); + alert(`저장 중 오류가 발생했습니다: ${error.message}`); + } finally { + setIsSaving(false); + } + }; + + // 🆕 v3.1: 외부 테이블 데이터 저장 + const saveExternalTableData = async () => { + const savePromises: Promise[] = []; + + for (const [key, rows] of Object.entries(externalTableData)) { + // key 형식: cardId-contentRowId + const [cardId, contentRowId] = key.split("-").slice(0, 2); + const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id)); + + if (!contentRow?.tableDataSource?.enabled) continue; + + const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; + const dirtyRows = rows.filter((row) => row._isDirty); + + for (const row of dirtyRows) { + const { _rowId, _originalData, _isDirty, _isNew, ...dataToSave } = row; + + if (_isNew) { + // INSERT + savePromises.push( + apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {}) + ); + } else if (_originalData?.id) { + // UPDATE + savePromises.push( + apiClient.put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave).then(() => {}) + ); + } + } + } + + await Promise.all(savePromises); + + // 저장 후 dirty 플래그 초기화 + setExternalTableData((prev) => { + const updated: Record = {}; + for (const [key, rows] of Object.entries(prev)) { + updated[key] = rows.map((row) => ({ + ...row, + _isDirty: false, + _isNew: false, + _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, + })); + } + return updated; + }); + }; + + // 🆕 v3.12: 연동 저장 처리 (syncSaves) + const processSyncSaves = async () => { + const syncPromises: Promise[] = []; + + // contentRows에서 syncSaves가 설정된 테이블 행 찾기 + for (const contentRow of contentRows) { + if (contentRow.type !== "table") continue; + if (!contentRow.tableCrud?.syncSaves?.length) continue; + + const sourceTable = contentRow.tableDataSource?.sourceTable; + if (!sourceTable) continue; + + // 이 테이블 행의 모든 카드 데이터 수집 + for (const card of groupedCardsData) { + const key = `${card._cardId}-${contentRow.id}`; + const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted); + + // 각 syncSave 설정 처리 + for (const syncSave of contentRow.tableCrud.syncSaves) { + if (!syncSave.enabled) continue; + if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue; + + // 조인 키 값 수집 (중복 제거) + const joinKeyValues = new Set(); + for (const row of rows) { + const keyValue = row[syncSave.joinKey.sourceField]; + if (keyValue !== undefined && keyValue !== null) { + joinKeyValues.add(keyValue); + } + } + + // 각 조인 키별로 집계 계산 및 업데이트 + for (const keyValue of joinKeyValues) { + // 해당 조인 키에 해당하는 행들만 필터링 + const filteredRows = rows.filter( + (row) => row[syncSave.joinKey.sourceField] === keyValue + ); + + // 집계 계산 + let aggregatedValue: number = 0; + const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0); + + switch (syncSave.aggregationType) { + case "sum": + aggregatedValue = values.reduce((a, b) => a + b, 0); + break; + case "count": + aggregatedValue = values.length; + break; + case "avg": + aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; + break; + case "min": + aggregatedValue = values.length > 0 ? Math.min(...values) : 0; + break; + case "max": + aggregatedValue = values.length > 0 ? Math.max(...values) : 0; + break; + case "latest": + aggregatedValue = values.length > 0 ? values[values.length - 1] : 0; + break; + } + + console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, { + joinKey: keyValue, + aggregationType: syncSave.aggregationType, + values, + aggregatedValue, + }); + + // 대상 테이블 업데이트 + syncPromises.push( + apiClient + .put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, { + [syncSave.targetColumn]: aggregatedValue, + }) + .then(() => { + console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`); + }) + .catch((err) => { + console.error(`[SyncSave] 업데이트 실패:`, err); + throw err; + }) + ); + } + } + } + } + + if (syncPromises.length > 0) { + console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`); + await Promise.all(syncPromises); + console.log(`[SyncSave] 연동 저장 완료`); + } + }; + + // 🆕 v3.1: Footer 버튼 클릭 핸들러 + const handleFooterButtonClick = async (btn: FooterButtonConfig) => { + switch (btn.action) { + case "save": + await handleSaveAll(); + break; + case "cancel": + case "close": + // 모달 닫기 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeScreenModal")); + break; + case "reset": + // 데이터 초기화 + if (confirm("변경 사항을 모두 취소하시겠습니까?")) { + // 외부 테이블 데이터 초기화 + setExternalTableData({}); + // 기존 데이터 재로드 + setCardsData([]); + setGroupedCardsData([]); + } + break; + case "custom": + // 커스텀 액션 이벤트 발생 + if (btn.customAction) { + window.dispatchEvent( + new CustomEvent("repeatScreenModalCustomAction", { + detail: { + actionType: btn.customAction.type, + config: btn.customAction.config, + componentId: component?.id, + }, + }) + ); + } + break; + } + }; + + // Simple 모드 저장 + const saveSimpleData = async () => { + const dirtyCards = cardsData.filter((card) => card._isDirty); + + if (dirtyCards.length === 0) { + alert("변경된 데이터가 없습니다."); + return; + } + + const groupedData: Record = {}; + + for (const card of dirtyCards) { + for (const row of cardLayout) { + for (const col of row.columns) { + if (col.targetConfig && col.targetConfig.saveEnabled !== false) { + const targetTable = col.targetConfig.targetTable; + const targetColumn = col.targetConfig.targetColumn; + const value = card[col.field]; + + if (!groupedData[targetTable]) { + groupedData[targetTable] = []; + } + + let existingRow = groupedData[targetTable].find((r) => r._cardId === card._cardId); + + if (!existingRow) { + existingRow = { + _cardId: card._cardId, + _originalData: card._originalData, + }; + groupedData[targetTable].push(existingRow); + } + + existingRow[targetColumn] = value; + } + } + } + } + + await saveToTables(groupedData); + + setCardsData((prev) => prev.map((card) => ({ ...card, _isDirty: false }))); + }; + + // WithTable 모드 저장 + const saveGroupedData = async () => { + const dirtyCards = groupedCardsData.filter((card) => card._rows.some((row) => row._isDirty)); + + if (dirtyCards.length === 0) { + alert("변경된 데이터가 없습니다."); + return; + } + + const groupedData: Record = {}; + + for (const card of dirtyCards) { + const dirtyRows = card._rows.filter((row) => row._isDirty); + + for (const row of dirtyRows) { + // 테이블 컬럼에서 저장 대상 추출 + if (tableLayout?.tableColumns) { + for (const col of tableLayout.tableColumns) { + if (col.editable && col.targetConfig && col.targetConfig.saveEnabled !== false) { + const targetTable = col.targetConfig.targetTable; + const targetColumn = col.targetConfig.targetColumn; + const value = row[col.field]; + + if (!groupedData[targetTable]) { + groupedData[targetTable] = []; + } + + let existingRow = groupedData[targetTable].find((r) => r._rowId === row._rowId); + + if (!existingRow) { + existingRow = { + _rowId: row._rowId, + _originalData: row._originalData, + }; + groupedData[targetTable].push(existingRow); + } + + existingRow[targetColumn] = value; + } + } + } + } + } + + await saveToTables(groupedData); + + setGroupedCardsData((prev) => + prev.map((card) => ({ + ...card, + _rows: card._rows.map((row) => ({ ...row, _isDirty: false })), + })) + ); + }; + + // 테이블별 저장 + const saveToTables = async (groupedData: Record) => { + const savePromises = Object.entries(groupedData).map(async ([tableName, rows]) => { + return Promise.all( + rows.map(async (row) => { + const { _cardId, _rowId, _originalData, ...dataToSave } = row; + const id = _originalData?.id; + + if (id) { + await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, dataToSave); + } else { + await apiClient.post(`/table-management/tables/${tableName}/data`, dataToSave); + } + }) + ); + }); + + await Promise.all(savePromises); + }; + + // 수정 여부 확인 + const hasDirtyData = useMemo(() => { + // 기존 데이터 수정 여부 + let hasBaseDirty = false; + if (cardMode === "withTable") { + hasBaseDirty = groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + } else { + hasBaseDirty = cardsData.some((c) => c._isDirty); + } + + // 🆕 v3.1: 외부 테이블 데이터 수정 여부 + const hasExternalDirty = Object.values(externalTableData).some((rows) => + rows.some((row) => row._isDirty) + ); + + return hasBaseDirty || hasExternalDirty; + }, [cardMode, cardsData, groupedCardsData, externalTableData]); + + // 디자인 모드 렌더링 + if (isDesignMode) { + // 행 타입별 개수 계산 + const rowTypeCounts = { + header: contentRows.filter((r) => r.type === "header").length, + aggregation: contentRows.filter((r) => r.type === "aggregation").length, + table: contentRows.filter((r) => r.type === "table").length, + fields: contentRows.filter((r) => r.type === "fields").length, + }; + + return ( +
+
+ {/* 아이콘 */} +
+ +
+ + {/* 제목 */} +
+
Repeat Screen Modal
+
반복 화면 모달
+ v3 자유 레이아웃 +
+ + {/* 행 구성 정보 */} +
+ {contentRows.length > 0 ? ( + <> + {rowTypeCounts.header > 0 && ( + + 헤더 {rowTypeCounts.header}개 + + )} + {rowTypeCounts.aggregation > 0 && ( + + 집계 {rowTypeCounts.aggregation}개 + + )} + {rowTypeCounts.table > 0 && ( + + 테이블 {rowTypeCounts.table}개 + + )} + {rowTypeCounts.fields > 0 && ( + + 필드 {rowTypeCounts.fields}개 + + )} + + ) : ( + 행 없음 + )} +
+ + {/* 통계 정보 */} +
+
+
{contentRows.length}
+
행 (Rows)
+
+
+
+
{grouping?.aggregations?.length || 0}
+
집계 설정
+
+
+
+
{dataSource?.sourceTable ? 1 : 0}
+
데이터 소스
+
+
+ + {/* 데이터 소스 정보 */} + {dataSource?.sourceTable && ( +
+ 소스 테이블: {dataSource.sourceTable} + {dataSource.filterField && (필터: {dataSource.filterField})} +
+ )} + + {/* 그룹핑 정보 */} + {grouping?.enabled && ( +
+ 그룹핑: {grouping.groupByField} +
+ )} + + {/* 카드 제목 정보 */} + {showCardTitle && cardTitle && ( +
+ 카드 제목: {cardTitle} +
+ )} + + {/* 설정 안내 */} +
+ 오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요 +
+
+
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+ + 데이터를 불러오는 중... +
+ ); + } + + // 오류 상태 + if (loadError) { + return ( +
+
+ + 데이터 로드 실패 +
+

{loadError}

+
+ ); + } + + // 🆕 v3: 자유 레이아웃 렌더링 (contentRows 사용) + const useNewLayout = contentRows && contentRows.length > 0; + const useGrouping = grouping?.enabled; + + // 그룹핑 모드 렌더링 + if (useGrouping) { + return ( +
+
+ {groupedCardsData.map((card, cardIndex) => ( + r._isDirty) && "border-primary shadow-lg" + )} + > + {/* 카드 제목 (선택사항) */} + {showCardTitle && ( + + + {getCardTitle(card._representativeData, cardIndex)} + {card._rows.some((r) => r._isDirty) && ( + + 수정됨 + + )} + + + )} + + {/* 🆕 v3: contentRows 기반 렌더링 */} + {useNewLayout ? ( + contentRows.map((contentRow, rowIndex) => ( +
+ {contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( + // 🆕 v3.1: 외부 테이블 데이터 소스 사용 +
+ {/* 테이블 헤더 영역: 제목 + 버튼들 */} + {(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && ( +
+ {contentRow.tableTitle || ""} +
+ {/* 추가 버튼 */} + {contentRow.tableCrud?.allowCreate && ( + + )} +
+
+ )} + + {contentRow.showTableHeader !== false && ( + + + {/* 🆕 v3.13: hidden 컬럼 필터링 */} + {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( + + {col.label} + + ))} + {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && ( + 작업 + )} + + + )} + + {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( + + !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)} + className="text-center py-8 text-muted-foreground" + > + 데이터가 없습니다. + + + ) : ( + (externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => ( + + {/* 🆕 v3.13: hidden 컬럼 필터링 */} + {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( + + {renderTableCell( + col, + row, + (value) => handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value), + row._isNew || row._isEditing // 신규 행이거나 수정 모드일 때만 편집 가능 + )} + + ))} + {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && ( + +
+ {/* 수정 버튼: 저장된 행(isNew가 아닌)이고 편집 모드가 아닐 때만 표시 */} + {contentRow.tableCrud?.allowUpdate && !row._isNew && !row._isEditing && !row._isDeleted && ( + + )} + {/* 수정 취소 버튼: 편집 모드일 때만 표시 */} + {row._isEditing && !row._isNew && ( + + )} + {/* 삭제/복원 버튼 */} + {contentRow.tableCrud?.allowDelete && ( + row._isDeleted ? ( + + ) : ( + + ) + )} +
+
+ )} +
+ )) + )} +
+
+
+ ) : ( + // 기존 renderContentRow 사용 + renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange) + )} +
+ )) + ) : ( + // 레거시: tableLayout 사용 + <> + {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && ( +
+ {tableLayout.headerRows.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderHeaderColumn(col, card, grouping?.aggregations || [])} +
+ ))} +
+ ))} +
+ )} + + {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( +
+ + + + {tableLayout.tableColumns.map((col) => ( + + {col.label} + + ))} + + + + {card._rows.map((row) => ( + + {tableLayout.tableColumns.map((col) => ( + + {renderTableCell( + col, + row, + (value) => handleRowDataChange(card._cardId, row._rowId, col.field, value), + row._isNew || row._isEditing + )} + + ))} + + ))} + +
+
+ )} + + )} +
+
+ ))} +
+ + {/* 🆕 v3.1: Footer 버튼 영역 */} + {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( +
+ {footerConfig.buttons.map((btn) => ( + + ))} +
+ ) : null} + + {/* 데이터 없음 */} + {groupedCardsData.length === 0 && !isLoading && ( +
표시할 데이터가 없습니다.
+ )} + + {/* 🆕 v3.1: 삭제 확인 다이얼로그 */} + + + + 삭제 확인 + + 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + { + if (pendingDeleteInfo) { + handleDeleteExternalRow( + pendingDeleteInfo.cardId, + pendingDeleteInfo.rowId, + pendingDeleteInfo.contentRowId + ); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 삭제 + + + + +
+ ); + } + + // 단순 모드 렌더링 (그룹핑 없음) + return ( +
+
+ {cardsData.map((card, cardIndex) => ( + + {/* 카드 제목 (선택사항) */} + {showCardTitle && ( + + + {getCardTitle(card, cardIndex)} + {card._isDirty && (수정됨)} + + + )} + + {/* 🆕 v3: contentRows 기반 렌더링 */} + {useNewLayout ? ( + contentRows.map((contentRow, rowIndex) => ( +
+ {renderSimpleContentRow(contentRow, card, (value, field) => + handleCardDataChange(card._cardId, field, value) + )} +
+ )) + ) : ( + // 레거시: cardLayout 사용 + cardLayout.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} +
+ ))} +
+ )) + )} +
+
+ ))} +
+ + {/* 🆕 v3.1: Footer 버튼 영역 */} + {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( +
+ {footerConfig.buttons.map((btn) => ( + + ))} +
+ ) : null} + + {/* 데이터 없음 */} + {cardsData.length === 0 && !isLoading && ( +
표시할 데이터가 없습니다.
+ )} +
+ ); +} + +// 🆕 v3: contentRow 렌더링 (그룹핑 모드) +function renderContentRow( + contentRow: CardContentRowConfig, + card: GroupedCardData, + aggregations: AggregationConfig[], + onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void +) { + switch (contentRow.type) { + case "header": + case "fields": + // contentRow에서 직접 columns 가져오기 (v3 구조) + const headerColumns = contentRow.columns || []; + + if (headerColumns.length === 0) { + return ( +
+ 헤더 컬럼이 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {headerColumns.map((col, colIndex) => ( +
+ {renderHeaderColumn(col, card, aggregations)} +
+ ))} +
+ ); + + case "aggregation": + // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) + const aggFields = contentRow.aggregationFields || []; + + if (aggFields.length === 0) { + return ( +
+ 집계 필드가 설정되지 않았습니다. (레이아웃 탭에서 집계 필드를 추가하세요) +
+ ); + } + + return ( +
+ {aggFields.map((aggField, fieldIndex) => { + // 집계 결과에서 값 가져오기 (aggregationResultField 사용) + const value = card._aggregations?.[aggField.aggregationResultField] || 0; + return ( +
+
{aggField.label || aggField.aggregationResultField}
+
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} +
+
+ ); + })} +
+ ); + + case "table": + // contentRow에서 직접 tableColumns 가져오기 (v3 구조) + const tableColumns = contentRow.tableColumns || []; + + if (tableColumns.length === 0) { + return ( +
+ 테이블 컬럼이 설정되지 않았습니다. (레이아웃 탭에서 테이블 컬럼을 추가하세요) +
+ ); + } + + return ( +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {tableColumns.map((col) => ( + + {col.label} + + ))} + + + )} + + {card._rows.map((row) => ( + + {tableColumns.map((col) => ( + + {renderTableCell( + col, + row, + (value) => onRowDataChange(card._cardId, row._rowId, col.field, value), + row._isNew || row._isEditing + )} + + ))} + + ))} + +
+
+ ); + + default: + return null; + } +} + +// 🆕 v3: contentRow 렌더링 (단순 모드) +function renderSimpleContentRow( + contentRow: CardContentRowConfig, + card: CardData, + onChange: (value: any, field: string) => void +) { + switch (contentRow.type) { + case "header": + case "fields": + return ( +
+ {(contentRow.columns || []).map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => onChange(value, col.field))} +
+ ))} +
+ ); + + case "aggregation": + // 단순 모드에서도 집계 표시 (단일 카드 기준) + // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) + const aggFields = contentRow.aggregationFields || []; + + if (aggFields.length === 0) { + return ( +
+ 집계 필드가 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {aggFields.map((aggField, fieldIndex) => { + // 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용) + const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; + return ( +
+
{aggField.label || aggField.aggregationResultField}
+
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} +
+
+ ); + })} +
+ ); + + case "table": + // 단순 모드에서도 테이블 표시 (단일 행) + // contentRow에서 직접 tableColumns 가져오기 (v3 구조) + const tableColumns = contentRow.tableColumns || []; + + if (tableColumns.length === 0) { + return ( +
+ 테이블 컬럼이 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {tableColumns.map((col) => ( + + {col.label} + + ))} + + + )} + + {/* 단순 모드: 카드 자체가 하나의 행 */} + + {tableColumns.map((col) => ( + + {renderSimpleTableCell(col, card, (value) => onChange(value, col.field))} + + ))} + + +
+
+ ); + + default: + return null; + } +} + +// 단순 모드 테이블 셀 렌더링 +function renderSimpleTableCell( + col: TableColumnConfig, + card: CardData, + onChange: (value: any) => void +) { + const value = card[col.field] || card._originalData?.[col.field]; + + if (!col.editable) { + // 읽기 전용 + if (col.type === "number") { + return typeof value === "number" ? value.toLocaleString() : value || "-"; + } + return value || "-"; + } + + // 편집 가능 + switch (col.type) { + case "number": + return ( + onChange(parseFloat(e.target.value) || 0)} + className="h-8 text-sm" + /> + ); + case "date": + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + case "select": + return ( + + ); + default: + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + } +} + +// 배경색 클래스 변환 +function getBackgroundClass(color: string): string { + const colorMap: Record = { + blue: "bg-blue-50 dark:bg-blue-950", + green: "bg-green-50 dark:bg-green-950", + purple: "bg-purple-50 dark:bg-purple-950", + orange: "bg-orange-50 dark:bg-orange-950", + }; + return colorMap[color] || ""; +} + +// 헤더 컬럼 렌더링 (집계값 포함) +function renderHeaderColumn( + col: CardColumnConfig, + card: GroupedCardData, + aggregations: AggregationConfig[] +) { + let value: any; + + // 집계값 타입이면 집계 결과에서 가져옴 + if (col.type === "aggregation" && col.aggregationField) { + value = card._aggregations[col.aggregationField]; + const aggConfig = aggregations.find((a) => a.resultField === col.aggregationField); + + return ( +
+ +
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} + {aggConfig && ({aggConfig.type})} +
+
+ ); + } + + // 일반 필드는 대표 데이터에서 가져옴 + value = card._representativeData[col.field]; + + return ( +
+ +
+ {value || "-"} +
+
+ ); +} + +// 테이블 셀 렌더링 +// 🆕 v3.8: isRowEditable 파라미터 추가 - 행이 편집 가능한 상태인지 (신규 행이거나 수정 모드) +function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void, isRowEditable?: boolean) { + const value = row[col.field]; + + // Badge 타입 + if (col.type === "badge") { + const badgeColor = col.badgeColorMap?.[value] || col.badgeVariant || "default"; + return {value || "-"}; + } + + // 🆕 v3.8: 행 수준 편집 가능 여부 체크 + // isRowEditable이 false이면 컬럼 설정과 관계없이 읽기 전용 + const canEdit = col.editable && (isRowEditable !== false); + + // 읽기 전용 + if (!canEdit) { + if (col.type === "number") { + return {typeof value === "number" ? value.toLocaleString() : value || "-"}; + } + if (col.type === "date") { + // ISO 8601 형식을 표시용으로 변환 + const displayDate = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "-"; + return {displayDate}; + } + return {value || "-"}; + } + + // 편집 가능 + switch (col.type) { + case "text": + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + case "number": + return ( + onChange(Number(e.target.value) || 0)} + className="h-8 text-sm text-right" + /> + ); + case "date": + // ISO 8601 형식('2025-12-02T00:00:00.000Z')을 'YYYY-MM-DD' 형식으로 변환 + const dateValue = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : ""; + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + default: + return {value || "-"}; + } +} + +// 컬럼 렌더링 함수 (Simple 모드) +function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) { + const value = card[col.field]; + const isReadOnly = !col.editable; + + return ( +
+ + + {isReadOnly && ( +
+ {value || "-"} +
+ )} + + {!isReadOnly && ( + <> + {col.type === "text" && ( + onChange(e.target.value)} + placeholder={col.placeholder} + className="h-10 text-sm" + /> + )} + + {col.type === "number" && ( + onChange(e.target.value)} + placeholder={col.placeholder} + className="h-10 text-sm" + /> + )} + + {col.type === "date" && ( + onChange(e.target.value)} + className="h-10 text-sm" + /> + )} + + {col.type === "select" && ( + + )} + + {col.type === "textarea" && ( +