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 || "라벨"}
+
+ );
+ })}
+
+
+ )}
+
+
);
};