From 5eb794177e74c63200ba7defdb0b51684439adb3 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Mar 2026 01:40:47 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260311155325-udmh round-5 --- .../V2ApprovalStepConfigPanel.tsx | 379 +++++++++ .../config-panels/V2PivotGridConfigPanel.tsx | 757 ++++++++++++++++++ .../components/v2-approval-step/index.ts | 2 +- .../components/v2-pivot-grid/index.ts | 2 +- 4 files changed, 1138 insertions(+), 2 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2ApprovalStepConfigPanel.tsx create mode 100644 frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx diff --git a/frontend/components/v2/config-panels/V2ApprovalStepConfigPanel.tsx b/frontend/components/v2/config-panels/V2ApprovalStepConfigPanel.tsx new file mode 100644 index 00000000..5645629c --- /dev/null +++ b/frontend/components/v2/config-panels/V2ApprovalStepConfigPanel.tsx @@ -0,0 +1,379 @@ +"use client"; + +/** + * V2 결재 단계 설정 패널 + * 토스식 단계별 UX: 데이터 소스(Combobox) -> 표시 모드 -> 표시 옵션(Collapsible) + */ + +import React, { useState, useEffect } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Check, ChevronsUpDown, Database, ChevronDown, Settings } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import type { ApprovalStepConfig } from "@/lib/registry/components/v2-approval-step/types"; + +interface V2ApprovalStepConfigPanelProps { + config: ApprovalStepConfig; + onChange: (config: Partial) => void; + screenTableName?: string; +} + +export const V2ApprovalStepConfigPanel: React.FC = ({ + config, + onChange, + screenTableName, +}) => { + const [availableTables, setAvailableTables] = useState>([]); + const [loadingTables, setLoadingTables] = useState(false); + const [tableOpen, setTableOpen] = useState(false); + + const [availableColumns, setAvailableColumns] = useState>([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [columnOpen, setColumnOpen] = useState(false); + + const [displayOpen, setDisplayOpen] = useState(false); + + const targetTableName = config.targetTable || screenTableName; + + const handleChange = (key: keyof ApprovalStepConfig, value: any) => { + onChange({ [key]: value }); + }; + + 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 { + /* ignore */ + } finally { + setLoadingTables(false); + } + }; + fetchTables(); + }, []); + + useEffect(() => { + if (!targetTableName) { + setAvailableColumns([]); + return; + } + const fetchColumns = async () => { + setLoadingColumns(true); + try { + const result = await tableManagementApi.getColumnList(targetTableName); + if (result.success && result.data) { + const columns = Array.isArray(result.data) ? result.data : result.data.columns; + if (columns && Array.isArray(columns)) { + setAvailableColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + label: + col.displayName || + col.columnLabel || + col.column_label || + col.columnName || + col.column_name || + col.name, + })) + ); + } + } + } catch { + setAvailableColumns([]); + } finally { + setLoadingColumns(false); + } + }; + fetchColumns(); + }, [targetTableName]); + + const handleTableChange = (newTableName: string) => { + if (newTableName === targetTableName) return; + handleChange("targetTable", newTableName); + handleChange("targetRecordIdField", ""); + setTableOpen(false); + }; + + return ( +
+ {/* ─── 1단계: 데이터 소스 ─── */} +
+
+ +

데이터 소스

+
+

+ 결재 상태를 조회할 대상 테이블을 설정해요 +

+
+ +
+ {/* 대상 테이블 */} +
+ 대상 테이블 + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {availableTables.map((table) => ( + handleTableChange(table.tableName)} + className="text-xs" + > + +
+ {table.displayName} + {table.displayName !== table.tableName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+
+ + {screenTableName && targetTableName !== screenTableName && ( +
+ + 화면 기본 테이블({screenTableName})과 다른 테이블 사용 중 + + +
+ )} +
+ + {/* 레코드 ID 필드 */} +
+ 레코드 ID 필드 + {targetTableName ? ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {availableColumns.map((col) => ( + { + handleChange("targetRecordIdField", col.columnName); + setColumnOpen(false); + }} + className="text-xs" + > + +
+ {col.label} + {col.label !== col.columnName && ( + + {col.columnName} + + )} +
+
+ ))} +
+
+
+
+
+ ) : ( +

대상 테이블을 먼저 선택하세요

+ )} +

+ 결재 대상 레코드를 식별할 PK 컬럼 +

+
+
+ + {/* ─── 2단계: 표시 모드 ─── */} +
+

표시 모드

+

결재 단계의 방향을 설정해요

+
+ +
+ +
+ + {/* ─── 3단계: 표시 옵션 (Collapsible) ─── */} + + + + + +
+
+
+

부서/직급 표시

+

+ 결재자의 부서와 직급을 보여줘요 +

+
+ handleChange("showDept", checked)} + /> +
+ +
+
+

결재 코멘트

+

+ 결재자가 남긴 의견을 표시해요 +

+
+ handleChange("showComment", checked)} + /> +
+ +
+
+

처리 시각

+

+ 각 단계의 처리 일시를 보여줘요 +

+
+ handleChange("showTimestamp", checked)} + /> +
+ +
+
+

콤팩트 모드

+

+ 좁은 공간에 맞게 작게 표시해요 +

+
+ handleChange("compact", checked)} + /> +
+
+
+
+
+ ); +}; + +export default V2ApprovalStepConfigPanel; diff --git a/frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx b/frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx new file mode 100644 index 00000000..1fac6844 --- /dev/null +++ b/frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx @@ -0,0 +1,757 @@ +"use client"; + +/** + * V2 피벗 그리드 설정 패널 + * 토스식 단계별 UX: 테이블 선택(Combobox) -> 필드 배치(AreaDropZone) -> 고급 설정(Collapsible) + */ + +import React, { useState, useEffect, useCallback } from "react"; +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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { + Rows, Columns, Calculator, X, Plus, GripVertical, + Check, ChevronsUpDown, ChevronDown, ChevronUp, + Settings, Database, Info, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { tableTypeApi } from "@/lib/api/screen"; +import type { + PivotGridComponentConfig, + PivotFieldConfig, + PivotAreaType, + AggregationType, + FieldDataType, +} from "@/lib/registry/components/v2-pivot-grid/types"; + +interface TableInfo { + tableName: string; + displayName: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; +} + +interface V2PivotGridConfigPanelProps { + config: PivotGridComponentConfig; + onChange: (config: PivotGridComponentConfig) => void; +} + +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 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 V2PivotGridConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [tableOpen, setTableOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const tableList = await tableTypeApi.getTables(); + setTables( + tableList.map((t: any) => ({ + tableName: t.tableName, + displayName: t.tableLabel || t.displayName || t.tableName, + })) + ); + } catch { + /* ignore */ + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + useEffect(() => { + if (!config.dataSource?.tableName) { + setColumns([]); + return; + } + const loadColumns = async () => { + setLoadingColumns(true); + try { + const columnList = await tableTypeApi.getColumns(config.dataSource!.tableName!); + setColumns( + 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, + })) + ); + } catch { + /* ignore */ + } 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) => + f.area === area && f.areaIndex === index ? { ...f, ...updates } : f + ); + updateConfig({ fields: newFields }); + }; + + const getFieldsByArea = (area: PivotAreaType) => + (config.fields || []).filter((f) => f.area === area).sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const handleTableChange = (tableName: string) => { + updateConfig({ + dataSource: { ...config.dataSource, type: "table", tableName }, + fields: [], + }); + setTableOpen(false); + }; + + return ( +
+ {/* ─── 안내 ─── */} +
+
+ +
+

피벗 테이블 설정 방법

+
    +
  1. 데이터를 가져올 테이블을 선택
  2. +
  3. 행 그룹에 그룹화 컬럼 추가 (예: 지역, 부서)
  4. +
  5. 열 그룹에 가로 펼칠 컬럼 추가 (예: 월, 분기)
  6. +
  7. 에 집계할 숫자 컬럼 추가 (예: 매출, 수량)
  8. +
+
+
+
+ + {/* ─── 1단계: 테이블 선택 ─── */} +
+
+ +

테이블 선택

+
+

+ 피벗 분석에 사용할 데이터 테이블을 골라요 +

+
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + handleTableChange(table.tableName)} + className="text-xs" + > + +
+ {table.displayName} + {table.displayName !== table.tableName && ( + {table.tableName} + )} +
+
+ ))} +
+
+
+
+
+ + {/* ─── 2단계: 필드 배치 ─── */} + {config.dataSource?.tableName && ( +
+
+ +

필드 배치

+ {loadingColumns && ( + (컬럼 로딩 중...) + )} +
+

+ 각 영역에 컬럼을 추가하여 피벗 구조를 만들어요 +

+ +
+ } + 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-primary/20 bg-primary/5" + /> + } + 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" + /> +
+
+ )} + + {/* ─── 3단계: 고급 설정 (Collapsible) ─── */} + + + + + +
+ {/* 총계 설정 */} +
+

총계 설정

+
+
+ 행 총계 + + 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({ height: e.target.value })} + placeholder="400px" + className="h-7 text-xs" + /> +
+
+ 최대 높이 + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-7 text-xs" + /> +
+
+
+ + {/* 기능 설정 */} +
+

기능 설정

+
+
+

CSV 내보내기

+

데이터를 CSV 파일로 내보낼 수 있어요

+
+ + updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) + } + /> +
+
+
+

상태 저장

+

사용자가 설정한 피벗 상태를 기억해요

+
+ updateConfig({ saveState: v })} + /> +
+
+ + {/* 조건부 서식 */} +
+

조건부 서식

+
+ {(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="h-6 w-6 cursor-pointer rounded" + 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="h-6 w-6 cursor-pointer rounded" + title="최대값 색상" + /> +
+ )} + + {rule.type === "dataBar" && ( + { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, dataBar: { color: e.target.value } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="h-6 w-6 cursor-pointer rounded" + title="바 색상" + /> + )} + + {rule.type === "iconSet" && ( + + )} + + +
+ ))} + + +
+
+
+
+
+
+ ); +}; + +export default V2PivotGridConfigPanel; diff --git a/frontend/lib/registry/components/v2-approval-step/index.ts b/frontend/lib/registry/components/v2-approval-step/index.ts index 01225a23..84475b90 100644 --- a/frontend/lib/registry/components/v2-approval-step/index.ts +++ b/frontend/lib/registry/components/v2-approval-step/index.ts @@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition import { ComponentCategory } from "@/types/component"; import type { WebType } from "@/types/screen"; import { ApprovalStepWrapper } from "./ApprovalStepComponent"; -import { ApprovalStepConfigPanel } from "./ApprovalStepConfigPanel"; +import { V2ApprovalStepConfigPanel as ApprovalStepConfigPanel } from "@/components/v2/config-panels/V2ApprovalStepConfigPanel"; import { ApprovalStepConfig } from "./types"; /** diff --git a/frontend/lib/registry/components/v2-pivot-grid/index.ts b/frontend/lib/registry/components/v2-pivot-grid/index.ts index b1bbe99b..50d5691e 100644 --- a/frontend/lib/registry/components/v2-pivot-grid/index.ts +++ b/frontend/lib/registry/components/v2-pivot-grid/index.ts @@ -43,7 +43,7 @@ export type { // 컴포넌트 내보내기 export { PivotGridComponent } from "./PivotGridComponent"; -export { PivotGridConfigPanel } from "./PivotGridConfigPanel"; +export { V2PivotGridConfigPanel as PivotGridConfigPanel } from "@/components/v2/config-panels/V2PivotGridConfigPanel"; // 유틸리티 export {