diff --git a/frontend/components/v2/config-panels/V2StatusCountConfigPanel.tsx b/frontend/components/v2/config-panels/V2StatusCountConfigPanel.tsx index 5f8f793c..8e4f9f26 100644 --- a/frontend/components/v2/config-panels/V2StatusCountConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2StatusCountConfigPanel.tsx @@ -2,16 +2,79 @@ /** * V2StatusCount 설정 패널 - * 기존 StatusCountConfigPanel의 모든 로직(테이블/컬럼 Combobox, 동적 아이템 관리, - * 카테고리 값 자동 로드 등)을 유지하면서 componentConfigChanged 이벤트를 추가하여 - * 실시간 업데이트 지원 + * 토스식 단계별 UX: 데이터 소스 -> 컬럼 매핑 -> 상태 항목 관리 -> 표시 설정(접힘) + * 기존 StatusCountConfigPanel의 모든 기능을 자체 UI로 완전 구현 */ -import React from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +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 { Separator } from "@/components/ui/separator"; import { - StatusCountConfigPanel, -} from "@/lib/registry/components/v2-status-count/StatusCountConfigPanel"; -import type { StatusCountConfig } from "@/lib/registry/components/v2-status-count/types"; + Table2, + Columns3, + Check, + ChevronsUpDown, + Loader2, + Link2, + Plus, + Trash2, + BarChart3, + Type, + Maximize2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi, type EntityJoinConfig } from "@/lib/api/entityJoin"; +import { apiClient } from "@/lib/api/client"; +import type { StatusCountConfig, StatusCountItem } from "@/lib/registry/components/v2-status-count/types"; +import { STATUS_COLOR_MAP } from "@/lib/registry/components/v2-status-count/types"; + +const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP); + +// ─── 카드 크기 선택 카드 ─── +const SIZE_CARDS = [ + { value: "sm", title: "작게", description: "컴팩트" }, + { value: "md", title: "보통", description: "기본 크기" }, + { value: "lg", title: "크게", description: "넓은 카드" }, +] as const; + +// ─── 섹션 헤더 컴포넌트 ─── +function SectionHeader({ icon: Icon, title, description }: { + icon: React.ComponentType<{ className?: string }>; + title: string; + description?: string; +}) { + return ( +
+
+ +

{title}

+
+ {description &&

{description}

} +
+ ); +} + +// ─── 수평 라벨 + 컨트롤 Row ─── +function LabeledRow({ label, description, children }: { + label: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+ {children} +
+ ); +} interface V2StatusCountConfigPanelProps { config: StatusCountConfig; @@ -22,9 +85,9 @@ export const V2StatusCountConfigPanel: React.FC = config, onChange, }) => { - const handleChange = (newConfig: Partial) => { + // componentConfigChanged 이벤트 발행 래퍼 + const handleChange = useCallback((newConfig: Partial) => { onChange(newConfig); - if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent("componentConfigChanged", { @@ -32,13 +95,578 @@ export const V2StatusCountConfigPanel: React.FC = }) ); } - }; + }, [onChange, config]); + const updateField = useCallback((key: keyof StatusCountConfig, value: any) => { + handleChange({ [key]: value }); + }, [handleChange]); + + // ─── 상태 ─── + const [tables, setTables] = useState>([]); + const [columns, setColumns] = useState>([]); + const [entityJoins, setEntityJoins] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingJoins, setLoadingJoins] = useState(false); + + const [statusCategoryValues, setStatusCategoryValues] = useState>([]); + const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [statusColumnOpen, setStatusColumnOpen] = useState(false); + const [relationOpen, setRelationOpen] = useState(false); + const items = config.items || []; + + // ─── 테이블 목록 로드 ─── + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const result = await tableTypeApi.getTables(); + setTables( + (result || []).map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.tableName || t.table_name, + })) + ); + } catch (err) { + console.error("테이블 목록 로드 실패:", err); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // ─── 선택된 테이블의 컬럼 + 엔티티 조인 로드 ─── + useEffect(() => { + if (!config.tableName) { + setColumns([]); + setEntityJoins([]); + return; + } + + const loadColumns = async () => { + setLoadingColumns(true); + try { + const result = await tableTypeApi.getColumns(config.tableName); + setColumns( + (result || []).map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name, + })) + ); + } catch (err) { + console.error("컬럼 목록 로드 실패:", err); + } finally { + setLoadingColumns(false); + } + }; + + const loadEntityJoins = async () => { + setLoadingJoins(true); + try { + const result = await entityJoinApi.getEntityJoinConfigs(config.tableName); + setEntityJoins(result?.joinConfigs || []); + } catch (err) { + console.error("엔티티 조인 설정 로드 실패:", err); + setEntityJoins([]); + } finally { + setLoadingJoins(false); + } + }; + + loadColumns(); + loadEntityJoins(); + }, [config.tableName]); + + // ─── 상태 컬럼의 카테고리 값 로드 ─── + useEffect(() => { + if (!config.tableName || !config.statusColumn) { + setStatusCategoryValues([]); + return; + } + + const loadCategoryValues = async () => { + setLoadingCategoryValues(true); + try { + const response = await apiClient.get( + `/table-categories/${config.tableName}/${config.statusColumn}/values` + ); + if (response.data?.success && response.data?.data) { + const flatValues: Array<{ value: string; label: string }> = []; + const flatten = (categoryItems: any[]) => { + for (const item of categoryItems) { + flatValues.push({ + value: item.valueCode || item.value_code, + label: item.valueLabel || item.value_label, + }); + if (item.children?.length > 0) flatten(item.children); + } + }; + flatten(response.data.data); + setStatusCategoryValues(flatValues); + } + } catch { + setStatusCategoryValues([]); + } finally { + setLoadingCategoryValues(false); + } + }; + + loadCategoryValues(); + }, [config.tableName, config.statusColumn]); + + // ─── 엔티티 관계 Combobox 아이템 ─── + const relationComboItems = useMemo(() => { + return entityJoins.map((ej) => { + const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable; + return { + value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`, + label: `${ej.sourceColumn} -> ${refTableLabel}`, + sublabel: `${ej.referenceTable}.${ej.referenceColumn}`, + }; + }); + }, [entityJoins, tables]); + + const currentRelationValue = useMemo(() => { + if (!config.relationColumn) return ""; + return relationComboItems.find((item) => { + const [srcCol] = item.value.split("::"); + return srcCol === config.relationColumn; + })?.value || ""; + }, [config.relationColumn, relationComboItems]); + + // ─── 상태 항목 관리 ─── + const addItem = useCallback(() => { + updateField("items", [...items, { value: "", label: "새 상태", color: "gray" }]); + }, [items, updateField]); + + const removeItem = useCallback((index: number) => { + updateField("items", items.filter((_: StatusCountItem, i: number) => i !== index)); + }, [items, updateField]); + + const updateItem = useCallback((index: number, key: keyof StatusCountItem, value: string) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], [key]: value }; + updateField("items", newItems); + }, [items, updateField]); + + // ─── 테이블 변경 핸들러 ─── + const handleTableChange = useCallback((newTableName: string) => { + handleChange({ tableName: newTableName, statusColumn: "", relationColumn: "", parentColumn: "" }); + setTableComboboxOpen(false); + }, [handleChange]); + + // ─── 렌더링 ─── return ( - +
+ {/* ═══════════════════════════════════════ */} + {/* 1단계: 데이터 소스 (테이블 선택) */} + {/* ═══════════════════════════════════════ */} +
+ + + + {/* 제목 */} +
+
+ + 제목 +
+ updateField("title", e.target.value)} + placeholder="예: 일련번호 현황" + className="h-7 text-xs" + /> +
+ + {/* 테이블 선택 */} + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + handleTableChange(table.tableName)} + className="text-xs" + > + +
+ {table.displayName} + {table.displayName !== table.tableName && ( + {table.tableName} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* ═══════════════════════════════════════ */} + {/* 2단계: 컬럼 매핑 */} + {/* ═══════════════════════════════════════ */} + {config.tableName && ( +
+ + + + {/* 상태 컬럼 */} +
+ 상태 컬럼 * + + + + + + + + + 컬럼을 찾을 수 없습니다. + + {columns.map((col) => ( + { + updateField("statusColumn", col.columnName); + setStatusColumnOpen(false); + }} + className="text-xs" + > + +
+ {col.columnLabel} + {col.columnLabel !== col.columnName && ( + {col.columnName} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 엔티티 관계 */} +
+
+ + 엔티티 관계 +
+ + {loadingJoins ? ( +
+ 로딩중... +
+ ) : entityJoins.length > 0 ? ( + + + + + + + + + 엔티티 관계가 없습니다. + + {relationComboItems.map((item) => ( + { + if (item.value === currentRelationValue) { + handleChange({ relationColumn: "", parentColumn: "" }); + } else { + const [sourceCol, refPart] = item.value.split("::"); + const [, refCol] = refPart.split("."); + handleChange({ relationColumn: sourceCol, parentColumn: refCol }); + } + setRelationOpen(false); + }} + className="text-xs" + > + +
+ {item.label} + {item.sublabel} +
+
+ ))} +
+
+
+
+
+ ) : ( +
+

설정된 엔티티 관계가 없습니다

+
+ )} + + {config.relationColumn && config.parentColumn && ( +
+ 자식 FK: {config.relationColumn} + {" -> "} + 부모 매칭: {config.parentColumn} +
+ )} +
+
+ )} + + {/* 테이블 미선택 안내 */} + {!config.tableName && ( +
+ +

테이블이 선택되지 않았습니다

+

위 데이터 소스에서 테이블을 선택하세요

+
+ )} + + {/* ═══════════════════════════════════════ */} + {/* 3단계: 카드 크기 (카드 선택 UI) */} + {/* ═══════════════════════════════════════ */} +
+ + + +
+ {SIZE_CARDS.map((card) => { + const isSelected = (config.cardSize || "md") === card.value; + return ( + + ); + })} +
+
+ + {/* ═══════════════════════════════════════ */} + {/* 4단계: 상태 항목 관리 */} + {/* ═══════════════════════════════════════ */} +
+
+ + +
+ + + {loadingCategoryValues && ( +
+ 카테고리 값 로딩... +
+ )} + + {items.length === 0 ? ( +
+ +

아직 상태 항목이 없어요

+

위의 추가 버튼으로 항목을 만들어보세요

+
+ ) : ( +
+ {items.map((item: StatusCountItem, i: number) => ( +
+ {/* 첫 번째 줄: 상태값 + 삭제 */} +
+ {statusCategoryValues.length > 0 ? ( + + ) : ( + updateItem(i, "value", e.target.value)} + placeholder="상태값 (예: IN_USE)" + className="h-7 text-xs" + /> + )} + +
+ + {/* 두 번째 줄: 라벨 + 색상 */} +
+ updateItem(i, "label", e.target.value)} + placeholder="표시 라벨" + className="h-7 text-xs" + /> + +
+
+ ))} +
+ )} + + {!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && ( +
+ 카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요. +
+ )} + + {/* 미리보기 */} + {items.length > 0 && ( +
+ 미리보기 +
+ {items.map((item, i) => { + const colors = STATUS_COLOR_MAP[item.color] || STATUS_COLOR_MAP.gray; + return ( +
+ 0 + {item.label || "라벨"} +
+ ); + })} +
+
+ )} +
+
); };