@@ -419,20 +515,3 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
);
}
-// ========================================
-// 데이터 바인딩 플레이스홀더
-// ========================================
-
-function DataBindingPlaceholder() {
- return (
-
-
-
-
데이터 바인딩
-
- Phase 4에서 구현 예정
-
-
-
- );
-}
diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx
index 05db0aab..42b1ee06 100644
--- a/frontend/components/pop/designer/panels/ComponentPalette.tsx
+++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx
@@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
-import { Square, FileText } from "lucide-react";
+import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@@ -27,6 +27,42 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: FileText,
description: "텍스트, 시간, 이미지 표시",
},
+ {
+ type: "pop-icon",
+ label: "아이콘",
+ icon: MousePointer,
+ description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
+ },
+ {
+ type: "pop-dashboard",
+ label: "대시보드",
+ icon: BarChart3,
+ description: "KPI, 차트, 게이지, 통계 집계",
+ },
+ {
+ type: "pop-card-list",
+ label: "카드 목록",
+ icon: LayoutGrid,
+ description: "테이블 데이터를 카드 형태로 표시",
+ },
+ {
+ type: "pop-button",
+ label: "버튼",
+ icon: MousePointerClick,
+ description: "액션 버튼 (저장/삭제/API/모달)",
+ },
+ {
+ type: "pop-string-list",
+ label: "리스트 목록",
+ icon: List,
+ description: "테이블 데이터를 리스트/카드로 표시",
+ },
+ {
+ type: "pop-search",
+ label: "검색",
+ icon: Search,
+ description: "조건 입력 (텍스트/날짜/선택/모달)",
+ },
];
// 드래그 가능한 컴포넌트 아이템
diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx
new file mode 100644
index 00000000..2e92d602
--- /dev/null
+++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx
@@ -0,0 +1,623 @@
+"use client";
+
+import React from "react";
+import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ PopComponentDefinitionV5,
+ PopDataConnection,
+} from "../types/pop-layout";
+import {
+ PopComponentRegistry,
+ type ComponentConnectionMeta,
+} from "@/lib/registry/PopComponentRegistry";
+import { getTableColumns } from "@/lib/api/tableManagement";
+
+// ========================================
+// Props
+// ========================================
+
+interface ConnectionEditorProps {
+ component: PopComponentDefinitionV5;
+ allComponents: PopComponentDefinitionV5[];
+ connections: PopDataConnection[];
+ onAddConnection?: (conn: Omit
) => void;
+ onUpdateConnection?: (connectionId: string, conn: Omit) => void;
+ onRemoveConnection?: (connectionId: string) => void;
+}
+
+// ========================================
+// ConnectionEditor
+// ========================================
+
+export default function ConnectionEditor({
+ component,
+ allComponents,
+ connections,
+ onAddConnection,
+ onUpdateConnection,
+ onRemoveConnection,
+}: ConnectionEditorProps) {
+ const registeredComp = PopComponentRegistry.getComponent(component.type);
+ const meta = registeredComp?.connectionMeta;
+
+ const outgoing = connections.filter(
+ (c) => c.sourceComponent === component.id
+ );
+ const incoming = connections.filter(
+ (c) => c.targetComponent === component.id
+ );
+
+ const hasSendable = meta?.sendable && meta.sendable.length > 0;
+ const hasReceivable = meta?.receivable && meta.receivable.length > 0;
+
+ if (!hasSendable && !hasReceivable) {
+ return (
+
+
+
+
연결 없음
+
+ 이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다
+
+
+
+ );
+ }
+
+ return (
+
+ {hasSendable && (
+
+ )}
+
+ {hasReceivable && (
+
+ )}
+
+ );
+}
+
+// ========================================
+// 대상 컴포넌트에서 정보 추출
+// ========================================
+
+/** 화면에 표시 중인 컬럼만 추출 */
+function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
+ if (!comp?.config) return [];
+ const cfg = comp.config as Record;
+ const cols: string[] = [];
+
+ if (Array.isArray(cfg.listColumns)) {
+ (cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
+ if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
+ });
+ }
+
+ if (Array.isArray(cfg.selectedColumns)) {
+ (cfg.selectedColumns as string[]).forEach((c) => {
+ if (!cols.includes(c)) cols.push(c);
+ });
+ }
+
+ return cols;
+}
+
+/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
+function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
+ if (!comp?.config) return "";
+ const cfg = comp.config as Record;
+ const ds = cfg.dataSource as { tableName?: string } | undefined;
+ return ds?.tableName || "";
+}
+
+// ========================================
+// 보내기 섹션
+// ========================================
+
+interface SendSectionProps {
+ component: PopComponentDefinitionV5;
+ meta: ComponentConnectionMeta;
+ allComponents: PopComponentDefinitionV5[];
+ outgoing: PopDataConnection[];
+ onAddConnection?: (conn: Omit) => void;
+ onUpdateConnection?: (connectionId: string, conn: Omit) => void;
+ onRemoveConnection?: (connectionId: string) => void;
+}
+
+function SendSection({
+ component,
+ meta,
+ allComponents,
+ outgoing,
+ onAddConnection,
+ onUpdateConnection,
+ onRemoveConnection,
+}: SendSectionProps) {
+ const [editingId, setEditingId] = React.useState(null);
+
+ return (
+
+
+
+ 이때 (보내기)
+
+
+ {/* 기존 연결 목록 */}
+ {outgoing.map((conn) => (
+
+ {editingId === conn.id ? (
+
{
+ onUpdateConnection?.(conn.id, data);
+ setEditingId(null);
+ }}
+ onCancel={() => setEditingId(null)}
+ submitLabel="수정"
+ />
+ ) : (
+
+
+ {conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
+
+
setEditingId(conn.id)}
+ className="shrink-0 p-0.5 text-muted-foreground hover:text-primary"
+ >
+
+
+ {onRemoveConnection && (
+
onRemoveConnection(conn.id)}
+ className="shrink-0 p-0.5 text-muted-foreground hover:text-destructive"
+ >
+
+
+ )}
+
+ )}
+
+ ))}
+
+ {/* 새 연결 추가 */}
+
onAddConnection?.(data)}
+ submitLabel="연결 추가"
+ />
+
+ );
+}
+
+// ========================================
+// 연결 폼 (추가/수정 공용)
+// ========================================
+
+interface ConnectionFormProps {
+ component: PopComponentDefinitionV5;
+ meta: ComponentConnectionMeta;
+ allComponents: PopComponentDefinitionV5[];
+ initial?: PopDataConnection;
+ onSubmit: (data: Omit) => void;
+ onCancel?: () => void;
+ submitLabel: string;
+}
+
+function ConnectionForm({
+ component,
+ meta,
+ allComponents,
+ initial,
+ onSubmit,
+ onCancel,
+ submitLabel,
+}: ConnectionFormProps) {
+ const [selectedOutput, setSelectedOutput] = React.useState(
+ initial?.sourceOutput || meta.sendable[0]?.key || ""
+ );
+ const [selectedTargetId, setSelectedTargetId] = React.useState(
+ initial?.targetComponent || ""
+ );
+ const [selectedTargetInput, setSelectedTargetInput] = React.useState(
+ initial?.targetInput || ""
+ );
+ const [filterColumns, setFilterColumns] = React.useState(
+ initial?.filterConfig?.targetColumns ||
+ (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
+ );
+ const [filterMode, setFilterMode] = React.useState<
+ "equals" | "contains" | "starts_with" | "range"
+ >(initial?.filterConfig?.filterMode || "contains");
+
+ const targetCandidates = allComponents.filter((c) => {
+ if (c.id === component.id) return false;
+ const reg = PopComponentRegistry.getComponent(c.type);
+ return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
+ });
+
+ const targetComp = selectedTargetId
+ ? allComponents.find((c) => c.id === selectedTargetId)
+ : null;
+
+ const targetMeta = targetComp
+ ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
+ : null;
+
+ // 화면에 표시 중인 컬럼
+ const displayColumns = React.useMemo(
+ () => extractDisplayColumns(targetComp || undefined),
+ [targetComp]
+ );
+
+ // DB 테이블 전체 컬럼 (비동기 조회)
+ const tableName = React.useMemo(
+ () => extractTableName(targetComp || undefined),
+ [targetComp]
+ );
+ const [allDbColumns, setAllDbColumns] = React.useState([]);
+ const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!tableName) {
+ setAllDbColumns([]);
+ return;
+ }
+ let cancelled = false;
+ setDbColumnsLoading(true);
+ getTableColumns(tableName).then((res) => {
+ if (cancelled) return;
+ if (res.success && res.data?.columns) {
+ setAllDbColumns(res.data.columns.map((c) => c.columnName));
+ } else {
+ setAllDbColumns([]);
+ }
+ setDbColumnsLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [tableName]);
+
+ // 표시 컬럼과 데이터 전용 컬럼 분리
+ const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
+ const dataOnlyColumns = React.useMemo(
+ () => allDbColumns.filter((c) => !displaySet.has(c)),
+ [allDbColumns, displaySet]
+ );
+ const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
+
+ const toggleColumn = (col: string) => {
+ setFilterColumns((prev) =>
+ prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
+ );
+ };
+
+ const handleSubmit = () => {
+ if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
+
+ onSubmit({
+ sourceComponent: component.id,
+ sourceField: "",
+ sourceOutput: selectedOutput,
+ targetComponent: selectedTargetId,
+ targetField: "",
+ targetInput: selectedTargetInput,
+ filterConfig:
+ filterColumns.length > 0
+ ? {
+ targetColumn: filterColumns[0],
+ targetColumns: filterColumns,
+ filterMode,
+ }
+ : undefined,
+ label: buildConnectionLabel(
+ component,
+ selectedOutput,
+ allComponents.find((c) => c.id === selectedTargetId),
+ selectedTargetInput,
+ filterColumns
+ ),
+ });
+
+ if (!initial) {
+ setSelectedTargetId("");
+ setSelectedTargetInput("");
+ setFilterColumns([]);
+ }
+ };
+
+ return (
+
+ {onCancel && (
+
+ )}
+ {!onCancel && (
+
새 연결 추가
+ )}
+
+ {/* 보내는 값 */}
+
+ 보내는 값
+
+
+
+
+
+ {meta.sendable.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+ {/* 받는 컴포넌트 */}
+
+ 받는 컴포넌트
+ {
+ setSelectedTargetId(v);
+ setSelectedTargetInput("");
+ setFilterColumns([]);
+ }}
+ >
+
+
+
+
+ {targetCandidates.map((c) => (
+
+ {c.label || c.id}
+
+ ))}
+
+
+
+
+ {/* 받는 방식 */}
+ {targetMeta && (
+
+ 받는 방식
+
+
+
+
+
+ {targetMeta.receivable.map((r) => (
+
+ {r.label}
+
+ ))}
+
+
+
+ )}
+
+ {/* 필터 설정 */}
+ {selectedTargetInput && (
+
+
필터할 컬럼
+
+ {dbColumnsLoading ? (
+
+
+ 컬럼 조회 중...
+
+ ) : hasAnyColumns ? (
+
+ {/* 표시 컬럼 그룹 */}
+ {displayColumns.length > 0 && (
+
+
화면 표시 컬럼
+ {displayColumns.map((col) => (
+
+ toggleColumn(col)}
+ />
+
+ {col}
+
+
+ ))}
+
+ )}
+
+ {/* 데이터 전용 컬럼 그룹 */}
+ {dataOnlyColumns.length > 0 && (
+
+ {displayColumns.length > 0 && (
+
+ )}
+
데이터 전용 컬럼
+ {dataOnlyColumns.map((col) => (
+
+ toggleColumn(col)}
+ />
+
+ {col}
+
+
+ ))}
+
+ )}
+
+ ) : (
+
setFilterColumns(e.target.value ? [e.target.value] : [])}
+ placeholder="컬럼명 입력"
+ className="h-7 text-xs"
+ />
+ )}
+
+ {filterColumns.length > 0 && (
+
+ {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시
+
+ )}
+
+ {/* 필터 방식 */}
+
+
필터 방식
+
setFilterMode(v)}>
+
+
+
+
+ 포함
+ 일치
+ 시작
+ 범위
+
+
+
+
+ )}
+
+ {/* 제출 버튼 */}
+
+ {!initial && }
+ {submitLabel}
+
+
+ );
+}
+
+// ========================================
+// 받기 섹션 (읽기 전용)
+// ========================================
+
+interface ReceiveSectionProps {
+ component: PopComponentDefinitionV5;
+ meta: ComponentConnectionMeta;
+ allComponents: PopComponentDefinitionV5[];
+ incoming: PopDataConnection[];
+}
+
+function ReceiveSection({
+ component,
+ meta,
+ allComponents,
+ incoming,
+}: ReceiveSectionProps) {
+ return (
+
+
+
+ 이렇게 (받기)
+
+
+
+ {meta.receivable.map((r) => (
+
+
{r.label}
+ {r.description && (
+
+ {r.description}
+
+ )}
+
+ ))}
+
+
+ {incoming.length > 0 ? (
+
+
연결된 소스
+ {incoming.map((conn) => {
+ const sourceComp = allComponents.find(
+ (c) => c.id === conn.sourceComponent
+ );
+ return (
+
+
+
+ {sourceComp?.label || conn.sourceComponent}
+
+
+ );
+ })}
+
+ ) : (
+
+ 아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
+
+ )}
+
+ );
+}
+
+// ========================================
+// 유틸
+// ========================================
+
+function buildConnectionLabel(
+ source: PopComponentDefinitionV5,
+ _outputKey: string,
+ target: PopComponentDefinitionV5 | undefined,
+ _inputKey: string,
+ columns?: string[]
+): string {
+ const srcLabel = source.label || source.id;
+ const tgtLabel = target?.label || target?.id || "?";
+ const colInfo = columns && columns.length > 0
+ ? ` [${columns.join(", ")}]`
+ : "";
+ return `${srcLabel} -> ${tgtLabel}${colInfo}`;
+}
diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx
index b0299813..88100d27 100644
--- a/frontend/components/pop/designer/renderers/PopRenderer.tsx
+++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx
@@ -48,12 +48,18 @@ interface PopRendererProps {
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
onComponentResizeEnd?: (componentId: string) => void;
+ /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */
+ onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
overrideGap?: number;
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
overridePadding?: number;
/** 추가 className */
className?: string;
+ /** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
+ currentScreenId?: number;
+ /** 대시보드 페이지 미리보기 인덱스 */
+ previewPageIndex?: number;
}
// ========================================
@@ -62,6 +68,13 @@ interface PopRendererProps {
const COMPONENT_TYPE_LABELS: Record = {
"pop-sample": "샘플",
+ "pop-text": "텍스트",
+ "pop-icon": "아이콘",
+ "pop-dashboard": "대시보드",
+ "pop-card-list": "카드 목록",
+ "pop-button": "버튼",
+ "pop-string-list": "리스트 목록",
+ "pop-search": "검색",
};
// ========================================
@@ -80,9 +93,12 @@ export default function PopRenderer({
onComponentMove,
onComponentResize,
onComponentResizeEnd,
+ onRequestResize,
overrideGap,
overridePadding,
className,
+ currentScreenId,
+ previewPageIndex,
}: PopRendererProps) {
const { gridConfig, components, overrides } = layout;
@@ -110,18 +126,27 @@ export default function PopRenderer({
return Math.max(10, maxRowEnd + 3);
}, [components, overrides, mode, hiddenIds]);
- // CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준)
+ // CSS Grid 스타일
+ // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
+ // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
+ const rowTemplate = isDesignMode
+ ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
+ : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`;
+ const autoRowHeight = isDesignMode
+ ? `${breakpoint.rowHeight}px`
+ : `minmax(${breakpoint.rowHeight}px, auto)`;
+
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
- gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`,
- gridAutoRows: `${breakpoint.rowHeight}px`,
+ gridTemplateRows: rowTemplate,
+ gridAutoRows: autoRowHeight,
gap: `${finalGap}px`,
padding: `${finalPadding}px`,
minHeight: "100%",
backgroundColor: "#ffffff",
position: "relative",
- }), [breakpoint, finalGap, finalPadding, dynamicRowCount]);
+ }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
// 그리드 가이드 셀 생성 (동적 행 수)
const gridCells = useMemo(() => {
@@ -248,15 +273,17 @@ export default function PopRenderer({
onComponentMove={onComponentMove}
onComponentResize={onComponentResize}
onComponentResizeEnd={onComponentResizeEnd}
+ onRequestResize={onRequestResize}
+ previewPageIndex={previewPageIndex}
/>
);
}
- // 뷰어 모드: 드래그 없는 일반 렌더링
+ // 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용)
return (
);
@@ -291,6 +320,8 @@ interface DraggableComponentProps {
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResizeEnd?: (componentId: string) => void;
+ onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
+ previewPageIndex?: number;
}
function DraggableComponent({
@@ -308,6 +339,8 @@ function DraggableComponent({
onComponentMove,
onComponentResize,
onComponentResizeEnd,
+ onRequestResize,
+ previewPageIndex,
}: DraggableComponentProps) {
const [{ isDragging }, drag] = useDrag(
() => ({
@@ -346,6 +379,9 @@ function DraggableComponent({
effectivePosition={position}
isDesignMode={isDesignMode}
isSelected={isSelected}
+ previewPageIndex={previewPageIndex}
+ onRequestResize={onRequestResize}
+ screenId={undefined}
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
@@ -496,66 +532,99 @@ interface ComponentContentProps {
effectivePosition: PopGridPosition;
isDesignMode: boolean;
isSelected: boolean;
+ previewPageIndex?: number;
+ onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
+ /** 화면 ID (이벤트 버스/액션 실행용) */
+ screenId?: string;
}
-function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
+function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, onRequestResize, screenId }: ComponentContentProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const PreviewComponent = registeredComp?.preview;
- // 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시
+ // 디자인 모드: 실제 컴포넌트 또는 미리보기 표시 (헤더 없음 - 뷰어와 동일하게)
if (isDesignMode) {
+ const ActualComp = registeredComp?.component;
+
+ // 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
+ if (ActualComp) {
+ // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
+ // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
+ const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
+
+ return (
+
+ );
+ }
+
+ // 미등록: preview 컴포넌트 또는 기본 플레이스홀더
return (
-
- {/* 헤더 */}
-
-
- {component.label || typeLabel}
+
+ {PreviewComponent ? (
+
+ ) : (
+
+ {typeLabel}
-
-
- {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
-
- {PreviewComponent ? (
-
- ) : (
-
- {typeLabel}
-
- )}
-
-
- {/* 위치 정보 표시 (유효 위치 사용) */}
-
- {effectivePosition.col},{effectivePosition.row}
- ({effectivePosition.colSpan}×{effectivePosition.rowSpan})
-
+ )}
);
}
- // 실제 모드: 컴포넌트 렌더링
- return renderActualComponent(component);
+ // 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원)
+ return renderActualComponent(component, effectivePosition, onRequestResize, screenId);
}
// ========================================
// 실제 컴포넌트 렌더링 (뷰어 모드)
// ========================================
-function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
- const typeLabel = COMPONENT_TYPE_LABELS[component.type];
-
- // 샘플 박스 렌더링
+function renderActualComponent(
+ component: PopComponentDefinitionV5,
+ effectivePosition?: PopGridPosition,
+ onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void,
+ screenId?: string,
+): React.ReactNode {
+ // 레지스트리에서 등록된 실제 컴포넌트 조회
+ const registeredComp = PopComponentRegistry.getComponent(component.type);
+ const ActualComp = registeredComp?.component;
+
+ if (ActualComp) {
+ return (
+
+ );
+ }
+
+ // 미등록 컴포넌트: 플레이스홀더 (fallback)
+ const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
return (
{component.label || typeLabel}
diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts
index 1a8335ec..15e70c65 100644
--- a/frontend/components/pop/designer/types/pop-layout.ts
+++ b/frontend/components/pop/designer/types/pop-layout.ts
@@ -9,7 +9,7 @@
/**
* POP 컴포넌트 타입
*/
-export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
+export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search";
/**
* 데이터 흐름 정의
@@ -25,6 +25,16 @@ export interface PopDataConnection {
targetComponent: string;
targetField: string;
transformType?: "direct" | "calculate" | "lookup";
+
+ // v2: 연결 시스템 전용
+ sourceOutput?: string;
+ targetInput?: string;
+ filterConfig?: {
+ targetColumn: string;
+ targetColumns?: string[];
+ filterMode: "equals" | "contains" | "starts_with" | "range";
+ };
+ label?: string;
}
/**
@@ -208,6 +218,9 @@ export interface PopLayoutDataV5 {
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
+
+ // 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
+ modals?: PopModalDefinition[];
}
/**
@@ -342,6 +355,12 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
export const DEFAULT_COMPONENT_GRID_SIZE: Record
= {
"pop-sample": { colSpan: 2, rowSpan: 1 },
"pop-text": { colSpan: 3, rowSpan: 1 },
+ "pop-icon": { colSpan: 1, rowSpan: 2 },
+ "pop-dashboard": { colSpan: 6, rowSpan: 3 },
+ "pop-card-list": { colSpan: 4, rowSpan: 3 },
+ "pop-button": { colSpan: 2, rowSpan: 1 },
+ "pop-string-list": { colSpan: 4, rowSpan: 3 },
+ "pop-search": { colSpan: 4, rowSpan: 2 },
};
/**
@@ -380,6 +399,95 @@ export const addComponentToV5Layout = (
return newLayout;
};
+// ========================================
+// 모달 캔버스 정의
+// ========================================
+
+// ========================================
+// 모달 사이즈 시스템
+// ========================================
+
+/** 모달 사이즈 프리셋 */
+export type ModalSizePreset = "sm" | "md" | "lg" | "xl" | "full";
+
+/** 모달 사이즈 프리셋별 픽셀 값 */
+export const MODAL_SIZE_PRESETS: Record = {
+ sm: { width: 400, label: "Small (400px)" },
+ md: { width: 600, label: "Medium (600px)" },
+ lg: { width: 800, label: "Large (800px)" },
+ xl: { width: 1000, label: "XLarge (1000px)" },
+ full: { width: 9999, label: "Full (화면 꽉 참)" },
+};
+
+/** 모달 사이즈 설정 (모드별 독립 설정 가능) */
+export interface ModalSizeConfig {
+ /** 기본 사이즈 (모든 모드 공통, 기본값: "md") */
+ default: ModalSizePreset;
+ /** 모드별 오버라이드 (미설정 시 default 사용) */
+ modeOverrides?: {
+ mobile_portrait?: ModalSizePreset;
+ mobile_landscape?: ModalSizePreset;
+ tablet_portrait?: ModalSizePreset;
+ tablet_landscape?: ModalSizePreset;
+ };
+}
+
+/**
+ * 주어진 모드에서 모달의 실제 픽셀 너비를 계산
+ * - 뷰포트보다 모달이 크면 자동으로 뷰포트에 맞춤 (full 승격)
+ */
+export function resolveModalWidth(
+ sizeConfig: ModalSizeConfig | undefined,
+ mode: GridMode,
+ viewportWidth: number,
+): number {
+ const preset = sizeConfig?.modeOverrides?.[mode] ?? sizeConfig?.default ?? "md";
+ const presetEntry = MODAL_SIZE_PRESETS[preset] ?? MODAL_SIZE_PRESETS.md;
+ const presetWidth = presetEntry.width;
+ // full이면 뷰포트 전체, 아니면 프리셋과 뷰포트 중 작은 값
+ if (preset === "full") return viewportWidth;
+ return Math.min(presetWidth, viewportWidth);
+}
+
+/**
+ * 모달 캔버스 정의
+ *
+ * 버튼의 "모달 열기" 액션이 참조하는 모달 화면.
+ * 메인 캔버스와 동일한 그리드 시스템을 사용.
+ * 중첩 모달: parentId로 부모-자식 관계 표현.
+ */
+export interface PopModalDefinition {
+ /** 모달 고유 ID (예: "modal-1", "modal-1-1") */
+ id: string;
+ /** 부모 모달 ID (최상위 모달은 undefined) */
+ parentId?: string;
+ /** 모달 제목 (다이얼로그 헤더에 표시) */
+ title: string;
+ /** 이 모달을 연 버튼의 컴포넌트 ID */
+ sourceButtonId: string;
+ /** 모달 내부 그리드 설정 */
+ gridConfig: PopGridConfig;
+ /** 모달 내부 컴포넌트 */
+ components: Record;
+ /** 모드별 오버라이드 */
+ overrides?: {
+ mobile_portrait?: PopModeOverrideV5;
+ mobile_landscape?: PopModeOverrideV5;
+ tablet_portrait?: PopModeOverrideV5;
+ };
+ /** 모달 프레임 설정 (닫기 방식) */
+ frameConfig?: {
+ /** 닫기(X) 버튼 표시 여부 (기본 true) */
+ showCloseButton?: boolean;
+ /** 오버레이 클릭으로 닫기 (기본 true) */
+ closeOnOverlay?: boolean;
+ /** ESC 키로 닫기 (기본 true) */
+ closeOnEsc?: boolean;
+ };
+ /** 모달 사이즈 설정 (미설정 시 md 기본) */
+ sizeConfig?: ModalSizeConfig;
+}
+
// ========================================
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
// ========================================
diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx
new file mode 100644
index 00000000..8d6e4227
--- /dev/null
+++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx
@@ -0,0 +1,203 @@
+/**
+ * PopViewerWithModals - 뷰어 모드에서 모달 렌더링 래퍼
+ *
+ * PopRenderer를 감싸서:
+ * 1. __pop_modal_open__ 이벤트 구독 → Dialog 열기
+ * 2. __pop_modal_close__ 이벤트 구독 → Dialog 닫기
+ * 3. 모달 스택 관리 (중첩 모달 지원)
+ *
+ * 모달 내부는 또 다른 PopRenderer로 렌더링 (독립 그리드).
+ */
+
+"use client";
+
+import { useState, useCallback, useEffect, useMemo } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import PopRenderer from "../designer/renderers/PopRenderer";
+import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
+import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
+import { usePopEvent } from "@/hooks/pop/usePopEvent";
+import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
+
+// ========================================
+// 타입
+// ========================================
+
+interface PopViewerWithModalsProps {
+ /** 전체 레이아웃 (모달 정의 포함) */
+ layout: PopLayoutDataV5;
+ /** 뷰포트 너비 */
+ viewportWidth: number;
+ /** 화면 ID (이벤트 버스용) */
+ screenId: string;
+ /** 현재 그리드 모드 (PopRenderer 전달용) */
+ currentMode?: GridMode;
+ /** Gap 오버라이드 */
+ overrideGap?: number;
+ /** Padding 오버라이드 */
+ overridePadding?: number;
+}
+
+/** 열린 모달 상태 */
+interface OpenModal {
+ definition: PopModalDefinition;
+ returnTo?: string;
+}
+
+// ========================================
+// 메인 컴포넌트
+// ========================================
+
+export default function PopViewerWithModals({
+ layout,
+ viewportWidth,
+ screenId,
+ currentMode,
+ overrideGap,
+ overridePadding,
+}: PopViewerWithModalsProps) {
+ const [modalStack, setModalStack] = useState([]);
+ const { subscribe, publish } = usePopEvent(screenId);
+
+ // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
+ const stableConnections = useMemo(
+ () => layout.dataFlow?.connections ?? [],
+ [layout.dataFlow?.connections]
+ );
+ useConnectionResolver({
+ screenId,
+ connections: stableConnections,
+ });
+
+ // 모달 열기/닫기 이벤트 구독
+ useEffect(() => {
+ const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
+ const data = payload as {
+ modalId?: string;
+ title?: string;
+ mode?: string;
+ returnTo?: string;
+ };
+
+ if (data?.modalId) {
+ const modalDef = layout.modals?.find(m => m.id === data.modalId);
+ if (modalDef) {
+ setModalStack(prev => [...prev, {
+ definition: modalDef,
+ returnTo: data.returnTo,
+ }]);
+ }
+ }
+ });
+
+ const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => {
+ const data = payload as { selectedRow?: Record } | undefined;
+
+ setModalStack(prev => {
+ if (prev.length === 0) return prev;
+ const topModal = prev[prev.length - 1];
+
+ // 결과 데이터가 있고, 반환 대상이 지정된 경우 결과 이벤트 발행
+ if (data?.selectedRow && topModal.returnTo) {
+ publish("__pop_modal_result__", {
+ selectedRow: data.selectedRow,
+ returnTo: topModal.returnTo,
+ });
+ }
+
+ return prev.slice(0, -1);
+ });
+ });
+
+ return () => {
+ unsubOpen();
+ unsubClose();
+ };
+ }, [subscribe, publish, layout.modals]);
+
+ // 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
+ const handleCloseTopModal = useCallback(() => {
+ setModalStack(prev => prev.slice(0, -1));
+ }, []);
+
+ return (
+ <>
+ {/* 메인 화면 렌더링 */}
+
+
+ {/* 모달 스택 렌더링 */}
+ {modalStack.map((modal, index) => {
+ const { definition } = modal;
+ const isTopModal = index === modalStack.length - 1;
+ const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
+ const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
+
+ const modalLayout: PopLayoutDataV5 = {
+ ...layout,
+ gridConfig: definition.gridConfig,
+ components: definition.components,
+ overrides: definition.overrides,
+ };
+
+ const detectedMode = currentMode || detectGridMode(viewportWidth);
+ const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
+ const isFull = modalWidth >= viewportWidth;
+ const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
+
+ return (
+ {
+ if (!open && isTopModal) handleCloseTopModal();
+ }}
+ >
+ {
+ // 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
+ if (!isTopModal || !closeOnOverlay) e.preventDefault();
+ }}
+ onEscapeKeyDown={(e) => {
+ if (!isTopModal || !closeOnEsc) e.preventDefault();
+ }}
+ >
+
+
+ {definition.title}
+
+
+
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts
new file mode 100644
index 00000000..5800125f
--- /dev/null
+++ b/frontend/hooks/pop/executePopAction.ts
@@ -0,0 +1,199 @@
+/**
+ * executePopAction - POP 액션 실행 순수 함수
+ *
+ * pop-button, pop-string-list 등 여러 컴포넌트에서 재사용 가능한
+ * 액션 실행 코어 로직. React 훅에 의존하지 않음.
+ *
+ * 사용처:
+ * - usePopAction 훅 (pop-button용 래퍼)
+ * - pop-string-list 카드 버튼 (직접 호출)
+ * - 향후 pop-table 행 액션 등
+ */
+
+import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button";
+import { apiClient } from "@/lib/api/client";
+import { dataApi } from "@/lib/api/data";
+
+// ========================================
+// 타입 정의
+// ========================================
+
+/** 액션 실행 결과 */
+export interface ActionResult {
+ success: boolean;
+ data?: unknown;
+ error?: string;
+}
+
+/** 이벤트 발행 함수 시그니처 (usePopEvent의 publish와 동일) */
+type PublishFn = (eventName: string, payload?: unknown) => void;
+
+/** executePopAction 옵션 */
+interface ExecuteOptions {
+ /** 필드 매핑 (소스 컬럼명 → 타겟 컬럼명) */
+ fieldMapping?: Record;
+ /** 화면 ID (이벤트 발행 시 사용) */
+ screenId?: string;
+ /** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */
+ publish?: PublishFn;
+}
+
+// ========================================
+// 내부 헬퍼
+// ========================================
+
+/**
+ * 필드 매핑 적용
+ * 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환
+ */
+function applyFieldMapping(
+ rowData: Record,
+ mapping?: Record
+): Record {
+ if (!mapping || Object.keys(mapping).length === 0) {
+ return { ...rowData };
+ }
+
+ const result: Record = {};
+ for (const [sourceKey, value] of Object.entries(rowData)) {
+ // 매핑이 있으면 타겟 키로 변환, 없으면 원본 키 유지
+ const targetKey = mapping[sourceKey] || sourceKey;
+ result[targetKey] = value;
+ }
+ return result;
+}
+
+/**
+ * rowData에서 PK 추출
+ * id > pk 순서로 시도, 없으면 rowData 전체를 복합키로 사용
+ */
+function extractPrimaryKey(
+ rowData: Record
+): string | number | Record {
+ if (rowData.id != null) return rowData.id as string | number;
+ if (rowData.pk != null) return rowData.pk as string | number;
+ // 복합키: rowData 전체를 Record로 전달 (dataApi.deleteRecord가 object 지원)
+ return rowData as Record;
+}
+
+// ========================================
+// 메인 함수
+// ========================================
+
+/**
+ * POP 액션 실행 (순수 함수)
+ *
+ * @param action - 버튼 메인 액션 설정
+ * @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달)
+ * @param options - 필드 매핑, screenId, publish 함수
+ * @returns 실행 결과
+ */
+export async function executePopAction(
+ action: ButtonMainAction,
+ rowData?: Record,
+ options?: ExecuteOptions
+): Promise {
+ const { fieldMapping, publish } = options || {};
+
+ try {
+ switch (action.type) {
+ // ── 저장 ──
+ case "save": {
+ if (!action.targetTable) {
+ return { success: false, error: "저장 대상 테이블이 설정되지 않았습니다." };
+ }
+ const data = rowData
+ ? applyFieldMapping(rowData, fieldMapping)
+ : {};
+ const result = await dataApi.createRecord(action.targetTable, data);
+ return { success: !!result?.success, data: result?.data, error: result?.message };
+ }
+
+ // ── 삭제 ──
+ case "delete": {
+ if (!action.targetTable) {
+ return { success: false, error: "삭제 대상 테이블이 설정되지 않았습니다." };
+ }
+ if (!rowData) {
+ return { success: false, error: "삭제할 데이터가 없습니다." };
+ }
+ const mappedData = applyFieldMapping(rowData, fieldMapping);
+ const pk = extractPrimaryKey(mappedData);
+ const result = await dataApi.deleteRecord(action.targetTable, pk);
+ return { success: !!result?.success, error: result?.message };
+ }
+
+ // ── API 호출 ──
+ case "api": {
+ if (!action.apiEndpoint) {
+ return { success: false, error: "API 엔드포인트가 설정되지 않았습니다." };
+ }
+ const body = rowData
+ ? applyFieldMapping(rowData, fieldMapping)
+ : undefined;
+ const method = (action.apiMethod || "POST").toUpperCase();
+
+ let response;
+ switch (method) {
+ case "GET":
+ response = await apiClient.get(action.apiEndpoint, { params: body });
+ break;
+ case "POST":
+ response = await apiClient.post(action.apiEndpoint, body);
+ break;
+ case "PUT":
+ response = await apiClient.put(action.apiEndpoint, body);
+ break;
+ case "DELETE":
+ response = await apiClient.delete(action.apiEndpoint, { data: body });
+ break;
+ default:
+ response = await apiClient.post(action.apiEndpoint, body);
+ }
+
+ const resData = response?.data;
+ return {
+ success: resData?.success !== false,
+ data: resData?.data ?? resData,
+ };
+ }
+
+ // ── 모달 열기 ──
+ case "modal": {
+ if (!publish) {
+ return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." };
+ }
+ publish("__pop_modal_open__", {
+ modalId: action.modalScreenId,
+ title: action.modalTitle,
+ mode: action.modalMode,
+ items: action.modalItems,
+ rowData,
+ });
+ return { success: true };
+ }
+
+ // ── 이벤트 발행 ──
+ case "event": {
+ if (!publish) {
+ return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." };
+ }
+ if (!action.eventName) {
+ return { success: false, error: "이벤트 이름이 설정되지 않았습니다." };
+ }
+ publish(action.eventName, {
+ ...(action.eventPayload || {}),
+ row: rowData,
+ });
+ return { success: true };
+ }
+
+ default:
+ return { success: false, error: `알 수 없는 액션 타입: ${action.type}` };
+ }
+ } catch (err: unknown) {
+ const message =
+ err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다.";
+ return { success: false, error: message };
+ }
+}
diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts
new file mode 100644
index 00000000..3a6c792b
--- /dev/null
+++ b/frontend/hooks/pop/index.ts
@@ -0,0 +1,26 @@
+/**
+ * POP 공통 훅 배럴 파일
+ *
+ * 사용법: import { usePopEvent, useDataSource } from "@/hooks/pop";
+ */
+
+// 이벤트 통신 훅
+export { usePopEvent, cleanupScreen } from "./usePopEvent";
+
+// 데이터 CRUD 훅
+export { useDataSource } from "./useDataSource";
+export type { MutationResult, DataSourceResult } from "./useDataSource";
+
+// 액션 실행 순수 함수
+export { executePopAction } from "./executePopAction";
+export type { ActionResult } from "./executePopAction";
+
+// 액션 실행 React 훅
+export { usePopAction } from "./usePopAction";
+export type { PendingConfirmState } from "./usePopAction";
+
+// 연결 해석기
+export { useConnectionResolver } from "./useConnectionResolver";
+
+// SQL 빌더 유틸 (고급 사용 시)
+export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
diff --git a/frontend/hooks/pop/popSqlBuilder.ts b/frontend/hooks/pop/popSqlBuilder.ts
new file mode 100644
index 00000000..bd9fd599
--- /dev/null
+++ b/frontend/hooks/pop/popSqlBuilder.ts
@@ -0,0 +1,195 @@
+/**
+ * POP 공통 SQL 빌더
+ *
+ * DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티.
+ * 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일).
+ *
+ * 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고,
+ * 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다.
+ * 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정.
+ *
+ * 보안:
+ * - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프)
+ * - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거
+ */
+
+import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types";
+
+// ===== SQL 값 이스케이프 =====
+
+/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */
+function escapeSQL(value: unknown): string {
+ if (value === null || value === undefined) return "NULL";
+ if (typeof value === "number") return String(value);
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
+ // 문자열: 작은따옴표 이스케이프
+ const str = String(value).replace(/'/g, "''");
+ return `'${str}'`;
+}
+
+// ===== 식별자 검증 (테이블명, 컬럼명) =====
+
+/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */
+function sanitizeIdentifier(name: string): string {
+ // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용
+ return name.replace(/[^a-zA-Z0-9_.]/g, "");
+}
+
+// ===== 설정 완료 여부 검증 =====
+
+/**
+ * DataSourceConfig의 필수값이 모두 채워졌는지 검증
+ * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
+ * SQL을 생성하지 않도록 사전 차단
+ *
+ * @returns null이면 유효, 문자열이면 미완료 사유
+ */
+export function validateDataSourceConfig(config: DataSourceConfig): string | null {
+ // 테이블명 필수
+ if (!config.tableName || !config.tableName.trim()) {
+ return "테이블이 선택되지 않았습니다";
+ }
+
+ // 집계 함수가 설정되었으면 대상 컬럼도 필수
+ // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
+ if (config.aggregation) {
+ const aggType = config.aggregation.type?.toLowerCase();
+ const aggCol = config.aggregation.column?.trim();
+ if (aggType !== "count" && !aggCol) {
+ return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
+ }
+ }
+
+ // 조인이 있으면 조인 조건 필수
+ if (config.joins?.length) {
+ for (const join of config.joins) {
+ if (!join.targetTable?.trim()) {
+ return "조인 대상 테이블이 선택되지 않았습니다";
+ }
+ if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
+ return "조인 조건 컬럼이 설정되지 않았습니다";
+ }
+ }
+ }
+
+ return null;
+}
+
+// ===== 필터 조건 SQL 생성 =====
+
+/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
+function buildWhereClause(filters: DataSourceFilter[]): string {
+ // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
+ const validFilters = filters.filter((f) => f.column?.trim());
+ if (!validFilters.length) return "";
+
+ const conditions = validFilters.map((f) => {
+ const col = sanitizeIdentifier(f.column);
+
+ switch (f.operator) {
+ case "between": {
+ const arr = Array.isArray(f.value) ? f.value : [f.value, f.value];
+ return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`;
+ }
+ case "in": {
+ const arr = Array.isArray(f.value) ? f.value : [f.value];
+ const vals = arr.map(escapeSQL).join(", ");
+ return `${col} IN (${vals})`;
+ }
+ case "like":
+ return `${col} LIKE ${escapeSQL(f.value)}`;
+ default:
+ return `${col} ${f.operator} ${escapeSQL(f.value)}`;
+ }
+ });
+
+ return `WHERE ${conditions.join(" AND ")}`;
+}
+
+// ===== 집계 SQL 빌더 =====
+
+/**
+ * DataSourceConfig를 SELECT SQL로 변환
+ *
+ * @param config - 데이터 소스 설정
+ * @returns SQL 문자열
+ */
+export function buildAggregationSQL(config: DataSourceConfig): string {
+ const tableName = sanitizeIdentifier(config.tableName);
+
+ // SELECT 절
+ let selectClause: string;
+ if (config.aggregation) {
+ const aggType = config.aggregation.type.toUpperCase();
+ const aggCol = config.aggregation.column?.trim()
+ ? sanitizeIdentifier(config.aggregation.column)
+ : "";
+
+ // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
+ if (!aggCol) {
+ selectClause = aggType === "COUNT"
+ ? "COUNT(*) as value"
+ : `${aggType}(${tableName}.*) as value`;
+ } else {
+ selectClause = `${aggType}(${aggCol}) as value`;
+ }
+
+ // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
+ if (config.aggregation.groupBy?.length) {
+ const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", ");
+ selectClause = `${groupCols}, ${selectClause}`;
+ }
+ } else {
+ selectClause = "*";
+ }
+
+ // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
+ let fromClause = tableName;
+ if (config.joins?.length) {
+ for (const join of config.joins) {
+ // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
+ if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
+ continue;
+ }
+ const joinTable = sanitizeIdentifier(join.targetTable);
+ const joinType = join.joinType.toUpperCase();
+ const srcCol = sanitizeIdentifier(join.on.sourceColumn);
+ const tgtCol = sanitizeIdentifier(join.on.targetColumn);
+ fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`;
+ }
+ }
+
+ // WHERE 절
+ const whereClause = config.filters?.length
+ ? buildWhereClause(config.filters)
+ : "";
+
+ // GROUP BY 절
+ let groupByClause = "";
+ if (config.aggregation?.groupBy?.length) {
+ groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`;
+ }
+
+ // ORDER BY 절
+ let orderByClause = "";
+ if (config.sort?.length) {
+ const sortCols = config.sort
+ .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`)
+ .join(", ");
+ orderByClause = `ORDER BY ${sortCols}`;
+ }
+
+ // LIMIT 절
+ const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : "";
+
+ return [
+ `SELECT ${selectClause}`,
+ `FROM ${fromClause}`,
+ whereClause,
+ groupByClause,
+ orderByClause,
+ limitClause,
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts
new file mode 100644
index 00000000..3c20acc2
--- /dev/null
+++ b/frontend/hooks/pop/useConnectionResolver.ts
@@ -0,0 +1,70 @@
+/**
+ * useConnectionResolver - 런타임 컴포넌트 연결 해석기
+ *
+ * PopViewerWithModals에서 사용.
+ * layout.dataFlow.connections를 읽고, 소스 컴포넌트의 __comp_output__ 이벤트를
+ * 타겟 컴포넌트의 __comp_input__ 이벤트로 자동 변환/중계한다.
+ *
+ * 이벤트 규칙:
+ * 소스: __comp_output__${sourceComponentId}__${outputKey}
+ * 타겟: __comp_input__${targetComponentId}__${inputKey}
+ */
+
+import { useEffect, useRef } from "react";
+import { usePopEvent } from "./usePopEvent";
+import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
+
+interface UseConnectionResolverOptions {
+ screenId: string;
+ connections: PopDataConnection[];
+}
+
+export function useConnectionResolver({
+ screenId,
+ connections,
+}: UseConnectionResolverOptions): void {
+ const { publish, subscribe } = usePopEvent(screenId);
+
+ // 연결 목록을 ref로 저장하여 콜백 안정성 확보
+ const connectionsRef = useRef(connections);
+ connectionsRef.current = connections;
+
+ useEffect(() => {
+ if (!connections || connections.length === 0) return;
+
+ const unsubscribers: (() => void)[] = [];
+
+ // 소스별로 그룹핑하여 구독 생성
+ const sourceGroups = new Map();
+ for (const conn of connections) {
+ const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
+ const existing = sourceGroups.get(sourceEvent) || [];
+ existing.push(conn);
+ sourceGroups.set(sourceEvent, existing);
+ }
+
+ for (const [sourceEvent, conns] of sourceGroups) {
+ const unsub = subscribe(sourceEvent, (payload: unknown) => {
+ for (const conn of conns) {
+ const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
+
+ // 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
+ const enrichedPayload = {
+ value: payload,
+ filterConfig: conn.filterConfig,
+ _connectionId: conn.id,
+ };
+
+ publish(targetEvent, enrichedPayload);
+ }
+ });
+ unsubscribers.push(unsub);
+ }
+
+ return () => {
+ for (const unsub of unsubscribers) {
+ unsub();
+ }
+ };
+ }, [screenId, connections, subscribe, publish]);
+}
diff --git a/frontend/hooks/pop/useDataSource.ts b/frontend/hooks/pop/useDataSource.ts
new file mode 100644
index 00000000..23bd9f93
--- /dev/null
+++ b/frontend/hooks/pop/useDataSource.ts
@@ -0,0 +1,383 @@
+/**
+ * useDataSource - POP 컴포넌트용 데이터 CRUD 통합 훅
+ *
+ * DataSourceConfig를 받아서 자동으로 적절한 API를 선택하여 데이터를 조회/생성/수정/삭제한다.
+ *
+ * 조회 분기:
+ * - aggregation 또는 joins가 있으면 → SQL 빌더 + executeQuery (대시보드와 동일)
+ * - 그 외 → dataApi.getTableData (단순 테이블 조회)
+ *
+ * CRUD:
+ * - save: dataApi.createRecord
+ * - update: dataApi.updateRecord
+ * - remove: dataApi.deleteRecord
+ *
+ * 사용 패턴:
+ * ```typescript
+ * // 집계 조회 (대시보드용)
+ * const { data, loading } = useDataSource({
+ * tableName: "sales_order",
+ * aggregation: { type: "sum", column: "amount", groupBy: ["category"] },
+ * refreshInterval: 30,
+ * });
+ *
+ * // 단순 목록 조회 (테이블용)
+ * const { data, refetch } = useDataSource({
+ * tableName: "purchase_order",
+ * sort: [{ column: "created_at", direction: "desc" }],
+ * limit: 20,
+ * });
+ *
+ * // 저장/삭제 (버튼용)
+ * const { save, remove } = useDataSource({ tableName: "inbound_record" });
+ * await save({ supplier_id: "SUP-001", quantity: 50 });
+ * ```
+ */
+
+import { useState, useCallback, useEffect, useRef } from "react";
+import { apiClient } from "@/lib/api/client";
+import { dashboardApi } from "@/lib/api/dashboard";
+import { dataApi } from "@/lib/api/data";
+import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types";
+import { validateDataSourceConfig, buildAggregationSQL } from "./popSqlBuilder";
+
+// ===== 타입 정의 =====
+
+/** 조회 결과 */
+export interface DataSourceResult {
+ /** 데이터 행 배열 */
+ rows: Record[];
+ /** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */
+ value: number;
+ /** 전체 행 수 (페이징용) */
+ total: number;
+}
+
+/** CRUD 작업 결과 */
+export interface MutationResult {
+ success: boolean;
+ data?: unknown;
+ error?: string;
+}
+
+/** refetch 시 전달할 오버라이드 필터 */
+interface OverrideOptions {
+ filters?: Record;
+}
+
+// ===== 내부: 집계/조인 조회 =====
+
+/**
+ * 집계 또는 조인이 포함된 DataSourceConfig를 SQL로 변환하여 실행
+ * dataFetcher.ts의 fetchAggregatedData와 동일한 로직
+ */
+async function fetchWithSqlBuilder(
+ config: DataSourceConfig
+): Promise {
+ const sql = buildAggregationSQL(config);
+
+ // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
+ let queryResult: { columns: string[]; rows: Record[] };
+ try {
+ // 1차: apiClient (axios 기반, 인증/세션 안정적)
+ const response = await apiClient.post("/dashboards/execute-query", { query: sql });
+ if (response.data?.success && response.data?.data) {
+ queryResult = response.data.data;
+ } else {
+ throw new Error(response.data?.message || "쿼리 실행 실패");
+ }
+ } catch {
+ // 2차: dashboardApi (fetch 기반, 폴백)
+ queryResult = await dashboardApi.executeQuery(sql);
+ }
+
+ if (queryResult.rows.length === 0) {
+ return { rows: [], value: 0, total: 0 };
+ }
+
+ // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 → 숫자 변환
+ const processedRows = queryResult.rows.map((row) => {
+ const converted: Record = { ...row };
+ for (const key of Object.keys(converted)) {
+ const val = converted[key];
+ if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
+ converted[key] = Number(val);
+ }
+ }
+ return converted;
+ });
+
+ // 첫 번째 행의 value 컬럼 추출
+ const firstRow = processedRows[0];
+ const numericValue = parseFloat(
+ String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0)
+ );
+
+ return {
+ rows: processedRows,
+ value: Number.isFinite(numericValue) ? numericValue : 0,
+ total: processedRows.length,
+ };
+}
+
+// ===== 내부: 단순 테이블 조회 =====
+
+/**
+ * aggregation/joins 없는 단순 테이블 조회
+ * dataApi.getTableData 래핑
+ */
+async function fetchSimpleTable(
+ config: DataSourceConfig,
+ overrideFilters?: Record
+): Promise {
+ // config.filters를 Record 형태로 변환
+ const baseFilters: Record = {};
+ if (config.filters?.length) {
+ for (const f of config.filters) {
+ if (f.column?.trim()) {
+ baseFilters[f.column] = f.value;
+ }
+ }
+ }
+
+ // overrideFilters가 있으면 병합 (같은 키는 override가 덮어씀)
+ const mergedFilters = overrideFilters
+ ? { ...baseFilters, ...overrideFilters }
+ : baseFilters;
+
+ const tableResult = await dataApi.getTableData(config.tableName, {
+ page: 1,
+ size: config.limit ?? 100,
+ sortBy: config.sort?.[0]?.column,
+ sortOrder: config.sort?.[0]?.direction,
+ filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined,
+ });
+
+ return {
+ rows: tableResult.data,
+ value: tableResult.total ?? tableResult.data.length,
+ total: tableResult.total ?? tableResult.data.length,
+ };
+}
+
+// ===== 내부: overrideFilters를 DataSourceFilter 배열에 병합 =====
+
+/**
+ * 기존 config에 overrideFilters를 병합한 새 config 생성
+ * 같은 column이 있으면 override 값으로 대체
+ */
+function mergeFilters(
+ config: DataSourceConfig,
+ overrideFilters?: Record
+): DataSourceConfig {
+ if (!overrideFilters || Object.keys(overrideFilters).length === 0) {
+ return config;
+ }
+
+ // 기존 filters에서 override 대상이 아닌 것만 유지
+ const overrideColumns = new Set(Object.keys(overrideFilters));
+ const existingFilters: DataSourceFilter[] = (config.filters ?? []).filter(
+ (f) => !overrideColumns.has(f.column)
+ );
+
+ // override를 DataSourceFilter로 변환하여 추가
+ const newFilters: DataSourceFilter[] = Object.entries(overrideFilters).map(
+ ([column, value]) => ({
+ column,
+ operator: "=" as const,
+ value,
+ })
+ );
+
+ return {
+ ...config,
+ filters: [...existingFilters, ...newFilters],
+ };
+}
+
+// ===== 메인 훅 =====
+
+/**
+ * POP 컴포넌트용 데이터 CRUD 통합 훅
+ *
+ * @param config - DataSourceConfig (tableName 필수)
+ * @returns data, loading, error, refetch, save, update, remove
+ */
+export function useDataSource(config: DataSourceConfig) {
+ const [data, setData] = useState({
+ rows: [],
+ value: 0,
+ total: 0,
+ });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // config를 ref로 저장 (콜백 안정성)
+ const configRef = useRef(config);
+ configRef.current = config;
+
+ // 자동 새로고침 타이머
+ const refreshTimerRef = useRef | null>(null);
+
+ // ===== 조회 (READ) =====
+
+ const refetch = useCallback(
+ async (options?: OverrideOptions): Promise => {
+ const currentConfig = configRef.current;
+
+ // 테이블명 없으면 조회하지 않음
+ if (!currentConfig.tableName?.trim()) {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const hasAggregation = !!currentConfig.aggregation;
+ const hasJoins = !!(currentConfig.joins && currentConfig.joins.length > 0);
+
+ let result: DataSourceResult;
+
+ if (hasAggregation || hasJoins) {
+ // 집계/조인 → SQL 빌더 경로
+ // 설정 완료 여부 검증
+ const merged = mergeFilters(currentConfig, options?.filters);
+ const validationError = validateDataSourceConfig(merged);
+ if (validationError) {
+ setError(validationError);
+ setLoading(false);
+ return;
+ }
+ result = await fetchWithSqlBuilder(merged);
+ } else {
+ // 단순 조회 → dataApi 경로
+ result = await fetchSimpleTable(currentConfig, options?.filters);
+ }
+
+ setData(result);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "데이터 조회 실패";
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [] // configRef 사용으로 의존성 불필요
+ );
+
+ // ===== 생성 (CREATE) =====
+
+ const save = useCallback(
+ async (record: Record): Promise => {
+ const tableName = configRef.current.tableName;
+ if (!tableName?.trim()) {
+ return { success: false, error: "테이블이 설정되지 않았습니다" };
+ }
+
+ try {
+ const result = await dataApi.createRecord(tableName, record);
+ return {
+ success: result.success ?? true,
+ data: result.data,
+ error: result.message,
+ };
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "레코드 생성 실패";
+ return { success: false, error: message };
+ }
+ },
+ []
+ );
+
+ // ===== 수정 (UPDATE) =====
+
+ const update = useCallback(
+ async (
+ id: string | number,
+ record: Record
+ ): Promise => {
+ const tableName = configRef.current.tableName;
+ if (!tableName?.trim()) {
+ return { success: false, error: "테이블이 설정되지 않았습니다" };
+ }
+
+ try {
+ const result = await dataApi.updateRecord(tableName, id, record);
+ return {
+ success: result.success ?? true,
+ data: result.data,
+ error: result.message,
+ };
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "레코드 수정 실패";
+ return { success: false, error: message };
+ }
+ },
+ []
+ );
+
+ // ===== 삭제 (DELETE) =====
+
+ const remove = useCallback(
+ async (
+ id: string | number | Record
+ ): Promise => {
+ const tableName = configRef.current.tableName;
+ if (!tableName?.trim()) {
+ return { success: false, error: "테이블이 설정되지 않았습니다" };
+ }
+
+ try {
+ const result = await dataApi.deleteRecord(tableName, id);
+ return {
+ success: result.success ?? true,
+ data: result.data,
+ error: result.message,
+ };
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "레코드 삭제 실패";
+ return { success: false, error: message };
+ }
+ },
+ []
+ );
+
+ // ===== 자동 조회 + 새로고침 =====
+
+ // config.tableName 또는 refreshInterval이 변경되면 재조회
+ const tableName = config.tableName;
+ const refreshInterval = config.refreshInterval;
+
+ useEffect(() => {
+ // 테이블명 있으면 초기 조회
+ if (tableName?.trim()) {
+ refetch();
+ }
+
+ // refreshInterval 설정 시 자동 새로고침
+ if (refreshInterval && refreshInterval > 0) {
+ const sec = Math.max(5, refreshInterval); // 최소 5초
+ refreshTimerRef.current = setInterval(() => {
+ refetch();
+ }, sec * 1000);
+ }
+
+ return () => {
+ if (refreshTimerRef.current) {
+ clearInterval(refreshTimerRef.current);
+ refreshTimerRef.current = null;
+ }
+ };
+ }, [tableName, refreshInterval, refetch]);
+
+ return {
+ data,
+ loading,
+ error,
+ refetch,
+ save,
+ update,
+ remove,
+ } as const;
+}
diff --git a/frontend/hooks/pop/usePopAction.ts b/frontend/hooks/pop/usePopAction.ts
new file mode 100644
index 00000000..267beb4e
--- /dev/null
+++ b/frontend/hooks/pop/usePopAction.ts
@@ -0,0 +1,218 @@
+/**
+ * usePopAction - POP 액션 실행 React 훅
+ *
+ * executePopAction (순수 함수)를 래핑하여 React UI 관심사를 처리:
+ * - 로딩 상태 (isLoading)
+ * - 확인 다이얼로그 (pendingConfirm)
+ * - 토스트 알림
+ * - 후속 액션 체이닝 (followUpActions)
+ *
+ * 사용처:
+ * - PopButtonComponent (메인 버튼)
+ *
+ * pop-string-list 등 리스트 컴포넌트는 executePopAction을 직접 호출하여
+ * 훅 인스턴스 폭발 문제를 회피함.
+ */
+
+import { useState, useCallback, useRef } from "react";
+import type {
+ ButtonMainAction,
+ FollowUpAction,
+ ConfirmConfig,
+} from "@/lib/registry/pop-components/pop-button";
+import { usePopEvent } from "./usePopEvent";
+import { executePopAction } from "./executePopAction";
+import type { ActionResult } from "./executePopAction";
+import { toast } from "sonner";
+
+// ========================================
+// 타입 정의
+// ========================================
+
+/** 확인 대기 중인 액션 상태 */
+export interface PendingConfirmState {
+ action: ButtonMainAction;
+ rowData?: Record;
+ fieldMapping?: Record;
+ confirm: ConfirmConfig;
+ followUpActions?: FollowUpAction[];
+}
+
+/** execute 호출 시 옵션 */
+interface ExecuteActionOptions {
+ /** 대상 행 데이터 */
+ rowData?: Record;
+ /** 필드 매핑 */
+ fieldMapping?: Record;
+ /** 확인 다이얼로그 설정 */
+ confirm?: ConfirmConfig;
+ /** 후속 액션 */
+ followUpActions?: FollowUpAction[];
+}
+
+// ========================================
+// 상수
+// ========================================
+
+/** 액션 성공 시 토스트 메시지 */
+const ACTION_SUCCESS_MESSAGES: Record = {
+ save: "저장되었습니다.",
+ delete: "삭제되었습니다.",
+ api: "요청이 완료되었습니다.",
+ modal: "",
+ event: "",
+};
+
+// ========================================
+// 메인 훅
+// ========================================
+
+/**
+ * POP 액션 실행 훅
+ *
+ * @param screenId - 화면 ID (이벤트 버스 연결용)
+ * @returns execute, isLoading, pendingConfirm, confirmExecute, cancelConfirm
+ */
+export function usePopAction(screenId: string) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [pendingConfirm, setPendingConfirm] = useState(null);
+
+ const { publish } = usePopEvent(screenId);
+
+ // publish 안정성 보장 (콜백 내에서 최신 참조 사용)
+ const publishRef = useRef(publish);
+ publishRef.current = publish;
+
+ /**
+ * 실제 실행 (확인 다이얼로그 이후 or 확인 불필요 시)
+ */
+ const runAction = useCallback(
+ async (
+ action: ButtonMainAction,
+ rowData?: Record,
+ fieldMapping?: Record,
+ followUpActions?: FollowUpAction[]
+ ): Promise => {
+ setIsLoading(true);
+
+ try {
+ const result = await executePopAction(action, rowData, {
+ fieldMapping,
+ screenId,
+ publish: publishRef.current,
+ });
+
+ // 결과에 따른 토스트
+ if (result.success) {
+ const msg = ACTION_SUCCESS_MESSAGES[action.type];
+ if (msg) toast.success(msg);
+ } else {
+ toast.error(result.error || "작업에 실패했습니다.");
+ }
+
+ // 성공 시 후속 액션 실행
+ if (result.success && followUpActions?.length) {
+ await executeFollowUpActions(followUpActions);
+ }
+
+ return result;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [screenId]
+ );
+
+ /**
+ * 후속 액션 실행
+ */
+ const executeFollowUpActions = useCallback(
+ async (actions: FollowUpAction[]) => {
+ for (const followUp of actions) {
+ switch (followUp.type) {
+ case "event":
+ if (followUp.eventName) {
+ publishRef.current(followUp.eventName, followUp.eventPayload);
+ }
+ break;
+
+ case "refresh":
+ // 새로고침 이벤트 발행 (구독하는 컴포넌트가 refetch)
+ publishRef.current("__pop_refresh__");
+ break;
+
+ case "navigate":
+ if (followUp.targetScreenId) {
+ publishRef.current("__pop_navigate__", {
+ screenId: followUp.targetScreenId,
+ params: followUp.params,
+ });
+ }
+ break;
+
+ case "close-modal":
+ publishRef.current("__pop_modal_close__");
+ break;
+ }
+ }
+ },
+ []
+ );
+
+ /**
+ * 외부에서 호출하는 실행 함수
+ * confirm이 활성화되어 있으면 pendingConfirm에 저장하고 대기.
+ * 비활성화이면 즉시 실행.
+ */
+ const execute = useCallback(
+ async (
+ action: ButtonMainAction,
+ options?: ExecuteActionOptions
+ ): Promise => {
+ const { rowData, fieldMapping, confirm, followUpActions } = options || {};
+
+ // 확인 다이얼로그 필요 시 대기
+ if (confirm?.enabled) {
+ setPendingConfirm({
+ action,
+ rowData,
+ fieldMapping,
+ confirm,
+ followUpActions,
+ });
+ return { success: true }; // 대기 상태이므로 일단 success
+ }
+
+ // 즉시 실행
+ return runAction(action, rowData, fieldMapping, followUpActions);
+ },
+ [runAction]
+ );
+
+ /**
+ * 확인 다이얼로그에서 "확인" 클릭 시
+ */
+ const confirmExecute = useCallback(async () => {
+ if (!pendingConfirm) return;
+
+ const { action, rowData, fieldMapping, followUpActions } = pendingConfirm;
+ setPendingConfirm(null);
+
+ await runAction(action, rowData, fieldMapping, followUpActions);
+ }, [pendingConfirm, runAction]);
+
+ /**
+ * 확인 다이얼로그에서 "취소" 클릭 시
+ */
+ const cancelConfirm = useCallback(() => {
+ setPendingConfirm(null);
+ }, []);
+
+ return {
+ execute,
+ isLoading,
+ pendingConfirm,
+ confirmExecute,
+ cancelConfirm,
+ } as const;
+}
diff --git a/frontend/hooks/pop/usePopEvent.ts b/frontend/hooks/pop/usePopEvent.ts
new file mode 100644
index 00000000..c600d838
--- /dev/null
+++ b/frontend/hooks/pop/usePopEvent.ts
@@ -0,0 +1,190 @@
+/**
+ * usePopEvent - POP 컴포넌트 간 이벤트 통신 훅
+ *
+ * 같은 화면(screenId) 안에서만 동작하는 이벤트 버스.
+ * 다른 screenId 간에는 완전히 격리됨.
+ *
+ * 주요 기능:
+ * - publish/subscribe: 일회성 이벤트 (거래처 선택됨, 저장 완료 등)
+ * - getSharedData/setSharedData: 지속성 상태 (버튼 클릭 시 다른 컴포넌트 값 수집용)
+ *
+ * 사용 패턴:
+ * ```typescript
+ * const { publish, subscribe, getSharedData, setSharedData } = usePopEvent("S001");
+ *
+ * // 이벤트 구독 (반드시 useEffect 안에서, cleanup 필수)
+ * useEffect(() => {
+ * const unsub = subscribe("supplier-selected", (payload) => {
+ * console.log(payload.supplierId);
+ * });
+ * return unsub;
+ * }, []);
+ *
+ * // 이벤트 발행
+ * publish("supplier-selected", { supplierId: "SUP-001" });
+ *
+ * // 공유 데이터 저장/조회
+ * setSharedData("selectedSupplier", { id: "SUP-001" });
+ * const supplier = getSharedData("selectedSupplier");
+ * ```
+ */
+
+import { useCallback, useRef } from "react";
+
+// ===== 타입 정의 =====
+
+/** 이벤트 콜백 함수 타입 */
+type EventCallback = (payload: unknown) => void;
+
+/** 화면별 이벤트 리스너 맵: eventName -> Set */
+type ListenerMap = Map>;
+
+/** 화면별 공유 데이터 맵: key -> value */
+type SharedDataMap = Map;
+
+// ===== 전역 저장소 (React 외부, 모듈 스코프) =====
+// SSR 환경에서 서버/클라이언트 간 공유 방지
+
+/** screenId별 이벤트 리스너 저장소 */
+const screenBuses: Map =
+ typeof window !== "undefined" ? new Map() : new Map();
+
+/** screenId별 공유 데이터 저장소 */
+const sharedDataStore: Map =
+ typeof window !== "undefined" ? new Map() : new Map();
+
+// ===== 내부 헬퍼 =====
+
+/** 해당 screenId의 리스너 맵 가져오기 (없으면 생성) */
+function getListenerMap(screenId: string): ListenerMap {
+ let map = screenBuses.get(screenId);
+ if (!map) {
+ map = new Map();
+ screenBuses.set(screenId, map);
+ }
+ return map;
+}
+
+/** 해당 screenId의 공유 데이터 맵 가져오기 (없으면 생성) */
+function getSharedMap(screenId: string): SharedDataMap {
+ let map = sharedDataStore.get(screenId);
+ if (!map) {
+ map = new Map();
+ sharedDataStore.set(screenId, map);
+ }
+ return map;
+}
+
+// ===== 외부 API: 화면 정리 =====
+
+/**
+ * 화면 언마운트 시 해당 screenId의 모든 리스너 + 공유 데이터 정리
+ * 메모리 누수 방지용. 뷰어 또는 PopRenderer에서 화면 전환 시 호출.
+ */
+export function cleanupScreen(screenId: string): void {
+ screenBuses.delete(screenId);
+ sharedDataStore.delete(screenId);
+}
+
+// ===== 메인 훅 =====
+
+/**
+ * POP 컴포넌트 간 이벤트 통신 훅
+ *
+ * @param screenId - 화면 ID (같은 screenId 안에서만 통신)
+ * @returns publish, subscribe, getSharedData, setSharedData
+ */
+export function usePopEvent(screenId: string) {
+ // screenId를 ref로 저장 (콜백 안정성)
+ const screenIdRef = useRef(screenId);
+ screenIdRef.current = screenId;
+
+ /**
+ * 이벤트 발행
+ * 해당 screenId + eventName에 등록된 모든 콜백에 payload 전달
+ */
+ const publish = useCallback(
+ (eventName: string, payload?: unknown): void => {
+ const listeners = getListenerMap(screenIdRef.current);
+ const callbacks = listeners.get(eventName);
+ if (!callbacks || callbacks.size === 0) return;
+
+ // Set을 배열로 복사 후 순회 (순회 중 unsubscribe 안전)
+ const callbackArray = Array.from(callbacks);
+ for (const cb of callbackArray) {
+ try {
+ cb(payload);
+ } catch (err) {
+ // 개별 콜백 에러가 다른 콜백 실행을 막지 않음
+ console.error(
+ `[usePopEvent] 콜백 에러 (screen: ${screenIdRef.current}, event: ${eventName}):`,
+ err
+ );
+ }
+ }
+ },
+ []
+ );
+
+ /**
+ * 이벤트 구독
+ *
+ * 주의: 반드시 useEffect 안에서 호출하고, 반환값(unsubscribe)을 cleanup에서 호출할 것.
+ *
+ * @returns unsubscribe 함수
+ */
+ const subscribe = useCallback(
+ (eventName: string, callback: EventCallback): (() => void) => {
+ const listeners = getListenerMap(screenIdRef.current);
+
+ let callbacks = listeners.get(eventName);
+ if (!callbacks) {
+ callbacks = new Set();
+ listeners.set(eventName, callbacks);
+ }
+ callbacks.add(callback);
+
+ // unsubscribe 함수 반환
+ const capturedScreenId = screenIdRef.current;
+ return () => {
+ const map = screenBuses.get(capturedScreenId);
+ if (!map) return;
+ const cbs = map.get(eventName);
+ if (!cbs) return;
+ cbs.delete(callback);
+ // 빈 Set 정리
+ if (cbs.size === 0) {
+ map.delete(eventName);
+ }
+ };
+ },
+ []
+ );
+
+ /**
+ * 공유 데이터 조회
+ * 다른 컴포넌트가 setSharedData로 저장한 값을 가져옴
+ */
+ const getSharedData = useCallback(
+ (key: string): T | undefined => {
+ const shared = sharedDataStore.get(screenIdRef.current);
+ if (!shared) return undefined;
+ return shared.get(key) as T | undefined;
+ },
+ []
+ );
+
+ /**
+ * 공유 데이터 저장
+ * 같은 screenId의 다른 컴포넌트가 getSharedData로 읽을 수 있음
+ */
+ const setSharedData = useCallback(
+ (key: string, value: unknown): void => {
+ const shared = getSharedMap(screenIdRef.current);
+ shared.set(key, value);
+ },
+ []
+ );
+
+ return { publish, subscribe, getSharedData, setSharedData } as const;
+}
diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts
index 31f9a4e1..0d7df5ec 100644
--- a/frontend/lib/registry/PopComponentRegistry.ts
+++ b/frontend/lib/registry/PopComponentRegistry.ts
@@ -2,6 +2,24 @@
import React from "react";
+/**
+ * 연결 메타 항목: 컴포넌트가 보내거나 받을 수 있는 데이터 슬롯
+ */
+export interface ConnectionMetaItem {
+ key: string;
+ label: string;
+ type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
+ description?: string;
+}
+
+/**
+ * 컴포넌트 연결 메타데이터: 디자이너가 연결 가능한 입출력 정의
+ */
+export interface ComponentConnectionMeta {
+ sendable: ConnectionMetaItem[];
+ receivable: ConnectionMetaItem[];
+}
+
/**
* POP 컴포넌트 정의 인터페이스
*/
@@ -15,6 +33,7 @@ export interface PopComponentDefinition {
configPanel?: React.ComponentType;
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
defaultProps?: Record;
+ connectionMeta?: ComponentConnectionMeta;
// POP 전용 속성
touchOptimized?: boolean;
minTouchArea?: number;
diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
index c2bb436d..949cd74b 100644
--- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
+++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
@@ -13,8 +13,34 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { X } from "lucide-react";
-import * as LucideIcons from "lucide-react";
+import {
+ X,
+ Check,
+ Plus,
+ Minus,
+ Edit,
+ Trash2,
+ Search,
+ Save,
+ RefreshCw,
+ AlertCircle,
+ Info,
+ Settings,
+ ChevronDown,
+ ChevronUp,
+ ChevronRight,
+ Copy,
+ Download,
+ Upload,
+ ExternalLink,
+ type LucideIcon,
+} from "lucide-react";
+
+const LUCIDE_ICON_MAP: Record = {
+ X, Check, Plus, Minus, Edit, Trash2, Search, Save, RefreshCw,
+ AlertCircle, Info, Settings, ChevronDown, ChevronUp, ChevronRight,
+ Copy, Download, Upload, ExternalLink,
+};
import { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils";
@@ -1559,7 +1585,7 @@ export const SelectedItemsDetailInputComponent: React.FC ;
}
diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts
index b604f9e8..36d1109c 100644
--- a/frontend/lib/registry/pop-components/index.ts
+++ b/frontend/lib/registry/pop-components/index.ts
@@ -13,8 +13,14 @@ export * from "./types";
// POP 컴포넌트 등록
import "./pop-text";
+import "./pop-icon";
+import "./pop-dashboard";
+import "./pop-card-list";
+
+import "./pop-button";
+import "./pop-string-list";
+import "./pop-search";
// 향후 추가될 컴포넌트들:
// import "./pop-field";
-// import "./pop-button";
// import "./pop-list";
diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx
new file mode 100644
index 00000000..b5532c30
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-button.tsx
@@ -0,0 +1,998 @@
+"use client";
+
+import { useCallback } from "react";
+import { cn } from "@/lib/utils";
+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 {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
+import { usePopAction } from "@/hooks/pop/usePopAction";
+import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
+import {
+ Save,
+ Trash2,
+ LogOut,
+ Menu,
+ ExternalLink,
+ Plus,
+ Check,
+ X,
+ Edit,
+ Search,
+ RefreshCw,
+ Download,
+ Upload,
+ Send,
+ Copy,
+ Settings,
+ ChevronDown,
+ type LucideIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+// ========================================
+// STEP 1: 타입 정의
+// ========================================
+
+/** 메인 액션 타입 (5종) */
+export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event";
+
+/** 후속 액션 타입 (4종) */
+export type FollowUpActionType = "event" | "refresh" | "navigate" | "close-modal";
+
+/** 버튼 variant (shadcn 기반 4종) */
+export type ButtonVariant = "default" | "secondary" | "outline" | "destructive";
+
+/** 모달 열기 방식 */
+export type ModalMode = "dropdown" | "fullscreen" | "screen-ref";
+
+/** 확인 다이얼로그 설정 */
+export interface ConfirmConfig {
+ enabled: boolean;
+ message?: string; // 빈값이면 기본 메시지
+}
+
+/** 후속 액션 1건 */
+export interface FollowUpAction {
+ type: FollowUpActionType;
+ // event
+ eventName?: string;
+ eventPayload?: Record;
+ // navigate
+ targetScreenId?: string;
+ params?: Record;
+}
+
+/** 드롭다운 모달 메뉴 항목 */
+export interface ModalMenuItem {
+ label: string;
+ screenId?: string;
+ action?: string; // 커스텀 이벤트명
+}
+
+/** 메인 액션 설정 */
+export interface ButtonMainAction {
+ type: ButtonActionType;
+ // save/delete 공통
+ targetTable?: string;
+ // api
+ apiEndpoint?: string;
+ apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
+ // modal
+ modalMode?: ModalMode;
+ modalScreenId?: string;
+ modalTitle?: string;
+ modalItems?: ModalMenuItem[];
+ // event
+ eventName?: string;
+ eventPayload?: Record;
+}
+
+/** 프리셋 이름 */
+export type ButtonPreset =
+ | "save"
+ | "delete"
+ | "logout"
+ | "menu"
+ | "modal-open"
+ | "custom";
+
+/** pop-button 전체 설정 */
+export interface PopButtonConfig {
+ label: string;
+ variant: ButtonVariant;
+ icon?: string; // Lucide 아이콘 이름
+ iconOnly?: boolean;
+ preset: ButtonPreset;
+ confirm?: ConfirmConfig;
+ action: ButtonMainAction;
+ followUpActions?: FollowUpAction[];
+}
+
+// ========================================
+// 상수
+// ========================================
+
+/** 메인 액션 타입 라벨 */
+const ACTION_TYPE_LABELS: Record = {
+ save: "저장",
+ delete: "삭제",
+ api: "API 호출",
+ modal: "모달 열기",
+ event: "이벤트 발행",
+};
+
+/** 후속 액션 타입 라벨 */
+const FOLLOWUP_TYPE_LABELS: Record = {
+ event: "이벤트 발행",
+ refresh: "새로고침",
+ navigate: "화면 이동",
+ "close-modal": "모달 닫기",
+};
+
+/** variant 라벨 */
+const VARIANT_LABELS: Record = {
+ default: "기본 (Primary)",
+ secondary: "보조 (Secondary)",
+ outline: "외곽선 (Outline)",
+ destructive: "위험 (Destructive)",
+};
+
+/** 프리셋 라벨 */
+const PRESET_LABELS: Record = {
+ save: "저장",
+ delete: "삭제",
+ logout: "로그아웃",
+ menu: "메뉴 (드롭다운)",
+ "modal-open": "모달 열기",
+ custom: "직접 설정",
+};
+
+/** 모달 모드 라벨 */
+const MODAL_MODE_LABELS: Record = {
+ dropdown: "드롭다운",
+ fullscreen: "전체 모달",
+ "screen-ref": "화면 선택",
+};
+
+/** API 메서드 라벨 */
+const API_METHOD_LABELS: Record = {
+ GET: "GET",
+ POST: "POST",
+ PUT: "PUT",
+ DELETE: "DELETE",
+};
+
+/** 주요 Lucide 아이콘 목록 (설정 패널용) */
+const ICON_OPTIONS: { value: string; label: string }[] = [
+ { value: "none", label: "없음" },
+ { value: "Save", label: "저장 (Save)" },
+ { value: "Trash2", label: "삭제 (Trash)" },
+ { value: "LogOut", label: "로그아웃 (LogOut)" },
+ { value: "Menu", label: "메뉴 (Menu)" },
+ { value: "ExternalLink", label: "외부링크 (ExternalLink)" },
+ { value: "Plus", label: "추가 (Plus)" },
+ { value: "Check", label: "확인 (Check)" },
+ { value: "X", label: "취소 (X)" },
+ { value: "Edit", label: "수정 (Edit)" },
+ { value: "Search", label: "검색 (Search)" },
+ { value: "RefreshCw", label: "새로고침 (RefreshCw)" },
+ { value: "Download", label: "다운로드 (Download)" },
+ { value: "Upload", label: "업로드 (Upload)" },
+ { value: "Send", label: "전송 (Send)" },
+ { value: "Copy", label: "복사 (Copy)" },
+ { value: "Settings", label: "설정 (Settings)" },
+ { value: "ChevronDown", label: "아래 화살표 (ChevronDown)" },
+];
+
+/** 프리셋별 기본 설정 */
+const PRESET_DEFAULTS: Record> = {
+ save: {
+ label: "저장",
+ variant: "default",
+ icon: "Save",
+ confirm: { enabled: false },
+ action: { type: "save" },
+ },
+ delete: {
+ label: "삭제",
+ variant: "destructive",
+ icon: "Trash2",
+ confirm: { enabled: true, message: "" },
+ action: { type: "delete" },
+ },
+ logout: {
+ label: "로그아웃",
+ variant: "outline",
+ icon: "LogOut",
+ confirm: { enabled: true, message: "로그아웃 하시겠습니까?" },
+ action: {
+ type: "api",
+ apiEndpoint: "/api/auth/logout",
+ apiMethod: "POST",
+ },
+ },
+ menu: {
+ label: "메뉴",
+ variant: "secondary",
+ icon: "Menu",
+ confirm: { enabled: false },
+ action: { type: "modal", modalMode: "dropdown" },
+ },
+ "modal-open": {
+ label: "열기",
+ variant: "outline",
+ icon: "ExternalLink",
+ confirm: { enabled: false },
+ action: { type: "modal", modalMode: "fullscreen" },
+ },
+ custom: {
+ label: "버튼",
+ variant: "default",
+ icon: "none",
+ confirm: { enabled: false },
+ action: { type: "save" },
+ },
+};
+
+/** 확인 다이얼로그 기본 메시지 (액션별) */
+const DEFAULT_CONFIRM_MESSAGES: Record = {
+ save: "저장하시겠습니까?",
+ delete: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
+ api: "실행하시겠습니까?",
+ modal: "열기하시겠습니까?",
+ event: "실행하시겠습니까?",
+};
+
+// ========================================
+// 헬퍼 함수
+// ========================================
+
+/** 섹션 구분선 */
+function SectionDivider({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
+const LUCIDE_ICON_MAP: Record = {
+ Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
+ Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
+};
+
+/** Lucide 아이콘 동적 렌더링 */
+function DynamicLucideIcon({
+ name,
+ size = 16,
+ className,
+}: {
+ name: string;
+ size?: number;
+ className?: string;
+}) {
+ const IconComponent = LUCIDE_ICON_MAP[name];
+ if (!IconComponent) return null;
+ return ;
+}
+
+// ========================================
+// STEP 2: 메인 컴포넌트
+// ========================================
+
+interface PopButtonComponentProps {
+ config?: PopButtonConfig;
+ label?: string;
+ isDesignMode?: boolean;
+ screenId?: string;
+}
+
+export function PopButtonComponent({
+ config,
+ label,
+ isDesignMode,
+ screenId,
+}: PopButtonComponentProps) {
+ // usePopAction 훅으로 액션 실행 통합
+ const {
+ execute,
+ isLoading,
+ pendingConfirm,
+ confirmExecute,
+ cancelConfirm,
+ } = usePopAction(screenId || "");
+
+ // 확인 메시지 결정
+ const getConfirmMessage = useCallback((): string => {
+ if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
+ if (config?.confirm?.message) return config.confirm.message;
+ return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
+ }, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
+
+ // 클릭 핸들러
+ const handleClick = useCallback(async () => {
+ // 디자인 모드: 실제 실행 안 함
+ if (isDesignMode) {
+ toast.info(
+ `[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
+ );
+ return;
+ }
+
+ const action = config?.action;
+ if (!action) return;
+
+ await execute(action, {
+ confirm: config?.confirm,
+ followUpActions: config?.followUpActions,
+ });
+ }, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]);
+
+ // 외형
+ const buttonLabel = config?.label || label || "버튼";
+ const variant = config?.variant || "default";
+ const iconName = config?.icon || "";
+ const isIconOnly = config?.iconOnly || false;
+
+ return (
+ <>
+
+
+ {iconName && (
+
+ )}
+ {!isIconOnly && {buttonLabel} }
+
+
+
+ {/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
+ { if (!open) cancelConfirm(); }}>
+
+
+
+ 실행 확인
+
+
+ {getConfirmMessage()}
+
+
+
+
+ 취소
+
+
+ 확인
+
+
+
+
+ >
+ );
+}
+
+// ========================================
+// STEP 3: 설정 패널
+// ========================================
+
+interface PopButtonConfigPanelProps {
+ config: PopButtonConfig;
+ onUpdate: (config: PopButtonConfig) => void;
+}
+
+export function PopButtonConfigPanel({
+ config,
+ onUpdate,
+}: PopButtonConfigPanelProps) {
+ const isCustom = config?.preset === "custom";
+
+ // 프리셋 변경 핸들러
+ const handlePresetChange = (preset: ButtonPreset) => {
+ const defaults = PRESET_DEFAULTS[preset];
+ onUpdate({
+ ...config,
+ preset,
+ label: defaults.label || config.label,
+ variant: defaults.variant || config.variant,
+ icon: defaults.icon ?? config.icon,
+ confirm: defaults.confirm || config.confirm,
+ action: (defaults.action as ButtonMainAction) || config.action,
+ // 후속 액션은 프리셋 변경 시 유지
+ });
+ };
+
+ // 메인 액션 업데이트 헬퍼
+ const updateAction = (updates: Partial) => {
+ onUpdate({
+ ...config,
+ action: { ...config.action, ...updates },
+ });
+ };
+
+ return (
+
+ {/* 프리셋 선택 */}
+
+
handlePresetChange(v as ButtonPreset)}
+ >
+
+
+
+
+ {Object.entries(PRESET_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+ {!isCustom && (
+
+ 프리셋 변경 시 외형과 액션이 자동 설정됩니다
+
+ )}
+
+ {/* 외형 설정 */}
+
+
+ {/* 라벨 */}
+
+ 라벨
+ onUpdate({ ...config, label: e.target.value })}
+ placeholder="버튼 텍스트"
+ className="h-8 text-xs"
+ />
+
+
+ {/* variant */}
+
+ 스타일
+
+ onUpdate({ ...config, variant: v as ButtonVariant })
+ }
+ >
+
+
+
+
+ {Object.entries(VARIANT_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ {/* 아이콘 */}
+
+
아이콘
+
+ onUpdate({ ...config, icon: v === "none" ? "" : v })
+ }
+ >
+
+
+
+
+ {ICON_OPTIONS.map((opt) => (
+
+
+ {opt.value && (
+
+ )}
+ {opt.label}
+
+
+ ))}
+
+
+
+
+ {/* 아이콘 전용 모드 */}
+
+
+ onUpdate({ ...config, iconOnly: checked === true })
+ }
+ />
+
+ 아이콘만 표시 (라벨 숨김)
+
+
+
+
+ {/* 메인 액션 */}
+
+
+ {/* 액션 타입 */}
+
+
액션 유형
+
+ updateAction({ type: v as ButtonActionType })
+ }
+ disabled={!isCustom}
+ >
+
+
+
+
+ {Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+ {!isCustom && (
+
+ 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
+
+ )}
+
+
+ {/* 액션별 추가 설정 */}
+
+
+
+ {/* 확인 다이얼로그 */}
+
+
+
+
+ onUpdate({
+ ...config,
+ confirm: {
+ ...config?.confirm,
+ enabled: checked === true,
+ },
+ })
+ }
+ />
+
+ 실행 전 확인 메시지 표시
+
+
+ {config?.confirm?.enabled && (
+
+
+ onUpdate({
+ ...config,
+ confirm: {
+ ...config?.confirm,
+ enabled: true,
+ message: e.target.value,
+ },
+ })
+ }
+ placeholder="비워두면 기본 메시지 사용"
+ className="h-8 text-xs"
+ />
+
+ 기본:{" "}
+ {DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]}
+
+
+ )}
+
+
+ {/* 후속 액션 */}
+
+
+ onUpdate({ ...config, followUpActions: actions })
+ }
+ />
+
+ );
+}
+
+// ========================================
+// 액션 세부 필드 (타입별)
+// ========================================
+
+function ActionDetailFields({
+ action,
+ onUpdate,
+ disabled,
+}: {
+ action?: ButtonMainAction;
+ onUpdate: (updates: Partial) => void;
+ disabled?: boolean;
+}) {
+ // 디자이너 컨텍스트 (뷰어에서는 null)
+ const designerCtx = usePopDesignerContext();
+ const actionType = action?.type || "save";
+
+ switch (actionType) {
+ case "save":
+ case "delete":
+ return (
+
+ 대상 테이블
+ onUpdate({ targetTable: e.target.value })}
+ placeholder="테이블명 입력"
+ className="h-8 text-xs"
+ disabled={disabled}
+ />
+
+ );
+
+ case "api":
+ return (
+
+
+ 엔드포인트
+ onUpdate({ apiEndpoint: e.target.value })}
+ placeholder="/api/..."
+ className="h-8 text-xs"
+ disabled={disabled}
+ />
+
+
+ HTTP 메서드
+
+ onUpdate({
+ apiMethod: v as "GET" | "POST" | "PUT" | "DELETE",
+ })
+ }
+ disabled={disabled}
+ >
+
+
+
+
+ {Object.entries(API_METHOD_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+
+ case "modal":
+ return (
+
+
+ 모달 방식
+
+ onUpdate({ modalMode: v as ModalMode })
+ }
+ disabled={disabled}
+ >
+
+
+
+
+ {Object.entries(MODAL_MODE_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {action?.modalMode === "screen-ref" && (
+
+ 화면 ID
+
+ onUpdate({ modalScreenId: e.target.value })
+ }
+ placeholder="화면 ID"
+ className="h-8 text-xs"
+ disabled={disabled}
+ />
+
+ )}
+
+ 모달 제목
+ onUpdate({ modalTitle: e.target.value })}
+ placeholder="모달 제목 (선택)"
+ className="h-8 text-xs"
+ disabled={disabled}
+ />
+
+ {/* 모달 캔버스 생성/열기 (fullscreen 모드 + 디자이너 내부) */}
+ {action?.modalMode === "fullscreen" && designerCtx && (
+
+ {action?.modalScreenId ? (
+ designerCtx.navigateToCanvas(action.modalScreenId!)}
+ >
+ 모달 캔버스 열기
+
+ ) : (
+ {
+ const selectedId = designerCtx.selectedComponentId;
+ if (!selectedId) return;
+ const modalId = designerCtx.createModalCanvas(
+ selectedId,
+ action?.modalTitle || "새 모달"
+ );
+ onUpdate({ modalScreenId: modalId });
+ }}
+ >
+ 모달 캔버스 생성
+
+ )}
+
+ )}
+
+ );
+
+ case "event":
+ return (
+
+
+ 이벤트명
+ onUpdate({ eventName: e.target.value })}
+ placeholder="예: data-saved, item-selected"
+ className="h-8 text-xs"
+ disabled={disabled}
+ />
+
+
+ );
+
+ default:
+ return null;
+ }
+}
+
+// ========================================
+// 후속 액션 편집기
+// ========================================
+
+function FollowUpActionsEditor({
+ actions,
+ onUpdate,
+}: {
+ actions: FollowUpAction[];
+ onUpdate: (actions: FollowUpAction[]) => void;
+}) {
+ // 추가
+ const handleAdd = () => {
+ onUpdate([...actions, { type: "event" }]);
+ };
+
+ // 삭제
+ const handleRemove = (index: number) => {
+ onUpdate(actions.filter((_, i) => i !== index));
+ };
+
+ // 수정
+ const handleUpdate = (index: number, updates: Partial) => {
+ const newActions = [...actions];
+ newActions[index] = { ...newActions[index], ...updates };
+ onUpdate(newActions);
+ };
+
+ return (
+
+ {actions.length === 0 && (
+
+ 메인 액션 성공 후 순차 실행할 후속 동작
+
+ )}
+
+ {actions.map((fa, idx) => (
+
+
+
+ 후속 {idx + 1}
+
+ handleRemove(idx)}
+ className="h-5 px-1 text-[10px] text-destructive"
+ >
+ 삭제
+
+
+
+ {/* 타입 선택 */}
+
+ handleUpdate(idx, { type: v as FollowUpActionType })
+ }
+ >
+
+
+
+
+ {Object.entries(FOLLOWUP_TYPE_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* 타입별 추가 입력 */}
+ {fa.type === "event" && (
+
+ handleUpdate(idx, { eventName: e.target.value })
+ }
+ placeholder="이벤트명"
+ className="h-7 text-xs"
+ />
+ )}
+ {fa.type === "navigate" && (
+
+ handleUpdate(idx, { targetScreenId: e.target.value })
+ }
+ placeholder="화면 ID"
+ className="h-7 text-xs"
+ />
+ )}
+
+ ))}
+
+
+ 후속 액션 추가
+
+
+ );
+}
+
+// ========================================
+// STEP 4: 미리보기 + 레지스트리 등록
+// ========================================
+
+function PopButtonPreviewComponent({
+ config,
+}: {
+ config?: PopButtonConfig;
+}) {
+ const buttonLabel = config?.label || "버튼";
+ const variant = config?.variant || "default";
+ const iconName = config?.icon || "";
+ const isIconOnly = config?.iconOnly || false;
+
+ return (
+
+
+ {iconName && (
+
+ )}
+ {!isIconOnly && {buttonLabel} }
+
+
+ );
+}
+
+// 레지스트리 등록
+PopComponentRegistry.registerComponent({
+ id: "pop-button",
+ name: "버튼",
+ description: "액션 버튼 (저장/삭제/API/모달/이벤트)",
+ category: "action",
+ icon: "MousePointerClick",
+ component: PopButtonComponent,
+ configPanel: PopButtonConfigPanel,
+ preview: PopButtonPreviewComponent,
+ defaultProps: {
+ label: "버튼",
+ variant: "default",
+ preset: "custom",
+ confirm: { enabled: false },
+ action: { type: "save" },
+ } as PopButtonConfig,
+ touchOptimized: true,
+ supportedDevices: ["mobile", "tablet"],
+});
diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx
new file mode 100644
index 00000000..806dedb1
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx
@@ -0,0 +1,209 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Delete } from "lucide-react";
+import {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+} from "@/components/ui/dialog";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import {
+ PackageUnitModal,
+ PACKAGE_UNITS,
+ type PackageUnit,
+} from "./PackageUnitModal";
+
+interface NumberInputModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ unit?: string;
+ initialValue?: number;
+ initialPackageUnit?: string;
+ min?: number;
+ maxValue?: number;
+ onConfirm: (value: number, packageUnit?: string) => void;
+}
+
+export function NumberInputModal({
+ open,
+ onOpenChange,
+ unit = "EA",
+ initialValue = 0,
+ initialPackageUnit,
+ min = 0,
+ maxValue = 999999,
+ onConfirm,
+}: NumberInputModalProps) {
+ const [displayValue, setDisplayValue] = useState("");
+ const [packageUnit, setPackageUnit] = useState(undefined);
+ const [isPackageModalOpen, setIsPackageModalOpen] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ setDisplayValue(initialValue > 0 ? String(initialValue) : "");
+ setPackageUnit(initialPackageUnit);
+ }
+ }, [open, initialValue, initialPackageUnit]);
+
+ const handleNumberClick = (num: string) => {
+ const newStr = displayValue + num;
+ const numericValue = parseInt(newStr, 10);
+ setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr);
+ };
+
+ const handleBackspace = () =>
+ setDisplayValue((prev) => prev.slice(0, -1));
+ const handleClear = () => setDisplayValue("");
+ const handleMax = () => setDisplayValue(String(maxValue));
+
+ const handleConfirm = () => {
+ const numericValue = parseInt(displayValue, 10) || 0;
+ const finalValue = Math.max(min, Math.min(maxValue, numericValue));
+ onConfirm(finalValue, packageUnit);
+ onOpenChange(false);
+ };
+
+ const handlePackageUnitSelect = (selected: PackageUnit) => {
+ setPackageUnit(selected);
+ };
+
+ const matchedUnit = packageUnit
+ ? PACKAGE_UNITS.find((u) => u.value === packageUnit)
+ : null;
+ const packageUnitLabel = matchedUnit?.label ?? null;
+ const packageUnitEmoji = matchedUnit?.emoji ?? "📦";
+
+ const displayText = displayValue
+ ? parseInt(displayValue, 10).toLocaleString()
+ : "";
+
+ return (
+ <>
+
+
+
+
+ {/* 파란 헤더 */}
+
+
+ 최대 {maxValue.toLocaleString()} {unit}
+
+ setIsPackageModalOpen(true)}
+ className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
+ >
+ {packageUnitEmoji} {packageUnitLabel ? `${packageUnitLabel} ✓` : "포장등록"}
+
+
+
+
+ {/* 숫자 표시 영역 */}
+
+ {displayText ? (
+
+ {displayText}
+
+ ) : (
+ 0
+ )}
+
+
+ {/* 안내 텍스트 */}
+
+ 수량을 입력하세요
+
+
+ {/* 키패드 4x4 */}
+
+ {/* 1행: 7 8 9 ← (주황) */}
+ {["7", "8", "9"].map((n) => (
+ handleNumberClick(n)}
+ >
+ {n}
+
+ ))}
+
+
+
+
+ {/* 2행: 4 5 6 C (주황) */}
+ {["4", "5", "6"].map((n) => (
+ handleNumberClick(n)}
+ >
+ {n}
+
+ ))}
+
+ C
+
+
+ {/* 3행: 1 2 3 MAX (파란) */}
+ {["1", "2", "3"].map((n) => (
+ handleNumberClick(n)}
+ >
+ {n}
+
+ ))}
+
+ MAX
+
+
+ {/* 4행: 0 / 확인 (초록, 3칸) */}
+ handleNumberClick("0")}
+ >
+ 0
+
+
+ 확인
+
+
+
+
+
+
+
+
+ {/* 포장 단위 선택 모달 */}
+
+ >
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx
new file mode 100644
index 00000000..0911bc47
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+} from "@/components/ui/dialog";
+
+export const PACKAGE_UNITS = [
+ { value: "box", label: "박스", emoji: "📦" },
+ { value: "bag", label: "포대", emoji: "🛍️" },
+ { value: "pack", label: "팩", emoji: "📋" },
+ { value: "bundle", label: "묶음", emoji: "🔗" },
+ { value: "roll", label: "롤", emoji: "🧻" },
+ { value: "barrel", label: "통", emoji: "🪣" },
+] as const;
+
+export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"];
+
+interface PackageUnitModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSelect: (unit: PackageUnit) => void;
+}
+
+export function PackageUnitModal({
+ open,
+ onOpenChange,
+ onSelect,
+}: PackageUnitModalProps) {
+ const handleSelect = (unit: PackageUnit) => {
+ onSelect(unit);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+ {/* 헤더 */}
+
+
📦 포장 단위 선택
+
+
+ {/* 3x2 그리드 */}
+
+ {PACKAGE_UNITS.map((unit) => (
+ handleSelect(unit.value as PackageUnit)}
+ className="hover:bg-muted active:bg-muted/70 flex flex-col items-center justify-center gap-2 rounded-xl border bg-background px-3 py-5 text-sm font-medium transition-colors"
+ >
+ {unit.emoji}
+ {unit.label}
+
+ ))}
+
+
+ {/* X 닫기 버튼 */}
+
+
+ Close
+
+
+
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx
new file mode 100644
index 00000000..bf7d71ed
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx
@@ -0,0 +1,1001 @@
+"use client";
+
+/**
+ * pop-card-list 런타임 컴포넌트
+ *
+ * 테이블의 각 행이 하나의 카드로 표시됩니다.
+ * 카드 구조:
+ * - 헤더: 코드 + 제목
+ * - 본문: 이미지(왼쪽) + 라벨-값 목록(오른쪽)
+ */
+
+import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react";
+import * as LucideIcons from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import type {
+ PopCardListConfig,
+ CardTemplateConfig,
+ CardFieldBinding,
+ CardInputFieldConfig,
+ CardCalculatedFieldConfig,
+ CardCartActionConfig,
+ CardPresetSpec,
+ CartItem,
+} from "../types";
+import {
+ DEFAULT_CARD_IMAGE,
+ CARD_PRESET_SPECS,
+} from "../types";
+import { dataApi } from "@/lib/api/data";
+import { usePopEvent } from "@/hooks/pop/usePopEvent";
+import { NumberInputModal } from "./NumberInputModal";
+
+// Lucide 아이콘 동적 렌더링
+function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
+ if (!name) return ;
+ const icons = LucideIcons as unknown as Record>;
+ const IconComp = icons[name];
+ if (!IconComp) return ;
+ return ;
+}
+
+// 마퀴 애니메이션 keyframes (한 번만 삽입)
+const MARQUEE_STYLE_ID = "pop-card-marquee-style";
+if (typeof document !== "undefined" && !document.getElementById(MARQUEE_STYLE_ID)) {
+ const style = document.createElement("style");
+ style.id = MARQUEE_STYLE_ID;
+ style.textContent = `
+ @keyframes pop-marquee {
+ 0%, 15% { transform: translateX(0); }
+ 85%, 100% { transform: translateX(var(--marquee-offset)); }
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+// 텍스트가 컨테이너보다 넓을 때 자동 슬라이딩하는 컴포넌트
+function MarqueeText({
+ children,
+ className,
+ style,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ const containerRef = useRef(null);
+ const textRef = useRef(null);
+ const [overflowPx, setOverflowPx] = useState(0);
+
+ const measure = useCallback(() => {
+ const container = containerRef.current;
+ const text = textRef.current;
+ if (!container || !text) return;
+ const diff = text.scrollWidth - container.clientWidth;
+ setOverflowPx(diff > 1 ? diff : 0);
+ }, []);
+
+ useEffect(() => {
+ measure();
+ }, [children, measure]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const ro = new ResizeObserver(() => measure());
+ ro.observe(container);
+ return () => ro.disconnect();
+ }, [measure]);
+
+ return (
+
+ 0
+ ? {
+ ["--marquee-offset" as string]: `-${overflowPx}px`,
+ animation: "pop-marquee 5s ease-in-out infinite alternate",
+ }
+ : undefined
+ }
+ >
+ {children}
+
+
+ );
+}
+
+interface PopCardListComponentProps {
+ config?: PopCardListConfig;
+ className?: string;
+ screenId?: string;
+ // 동적 크기 변경을 위한 props (PopRenderer에서 전달)
+ componentId?: string;
+ currentRowSpan?: number;
+ currentColSpan?: number;
+ onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
+}
+
+// 테이블 행 데이터 타입
+type RowData = Record;
+
+// 카드 내부 스타일 규격 (프리셋에서 매핑)
+interface ScaledConfig {
+ cardHeight: number;
+ cardWidth: number;
+ imageSize: number;
+ padding: number;
+ gap: number;
+ headerPaddingX: number;
+ headerPaddingY: number;
+ codeTextSize: number;
+ titleTextSize: number;
+ bodyTextSize: number;
+}
+
+export function PopCardListComponent({
+ config,
+ className,
+ screenId,
+ componentId,
+ currentRowSpan,
+ currentColSpan,
+ onRequestResize,
+}: PopCardListComponentProps) {
+ const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal";
+ const maxGridColumns = config?.gridColumns || 2;
+ const configGridRows = config?.gridRows || 3;
+ const dataSource = config?.dataSource;
+ const template = config?.cardTemplate;
+
+ // 이벤트 기반 company_code 필터링
+ const [eventCompanyCode, setEventCompanyCode] = useState();
+ const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default");
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!screenId) return;
+ const unsub = subscribe("company_selected", (payload: unknown) => {
+ const p = payload as { companyCode?: string } | undefined;
+ setEventCompanyCode(p?.companyCode);
+ });
+ return unsub;
+ }, [screenId, subscribe]);
+
+ // 데이터 상태
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 확장/페이지네이션 상태
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [originalRowSpan, setOriginalRowSpan] = useState(null);
+
+ // 컨테이너 ref + 크기 측정
+ const containerRef = useRef(null);
+ const [containerWidth, setContainerWidth] = useState(0);
+ const [containerHeight, setContainerHeight] = useState(0);
+ const baseContainerHeight = useRef(0);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const observer = new ResizeObserver((entries) => {
+ const { width, height } = entries[0].contentRect;
+ if (width > 0) setContainerWidth(width);
+ if (height > 0) setContainerHeight(height);
+ });
+ observer.observe(containerRef.current);
+ return () => observer.disconnect();
+ }, []);
+
+ // 이미지 URL 없는 항목 카운트 (toast 중복 방지용)
+ const missingImageCountRef = useRef(0);
+ const toastShownRef = useRef(false);
+
+ const spec: CardPresetSpec = CARD_PRESET_SPECS.large;
+
+ // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
+ const maxAllowedColumns = useMemo(() => {
+ if (!currentColSpan) return maxGridColumns;
+ if (currentColSpan >= 8) return maxGridColumns;
+ return 1;
+ }, [currentColSpan, maxGridColumns]);
+
+ // 카드 최소 너비 기준으로 컨테이너에 들어갈 수 있는 열 개수 자동 계산
+ const minCardWidth = Math.round(spec.height * 1.6);
+ const autoColumns = containerWidth > 0
+ ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
+ : maxGridColumns;
+ const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns);
+
+ // 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지)
+ const effectiveGridRows = useMemo(() => {
+ if (containerHeight <= 0) return configGridRows;
+
+ const controlBarHeight = 44;
+ const effectiveHeight = baseContainerHeight.current > 0
+ ? baseContainerHeight.current
+ : containerHeight;
+ const availableHeight = effectiveHeight - controlBarHeight;
+
+ const cardHeightWithGap = spec.height + spec.gap;
+ const fittableRows = Math.max(1, Math.floor(
+ (availableHeight + spec.gap) / cardHeightWithGap
+ ));
+
+ return Math.min(configGridRows, fittableRows);
+ }, [containerHeight, configGridRows, spec]);
+
+ const gridRows = effectiveGridRows;
+
+ // 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산
+ const scaled = useMemo((): ScaledConfig => {
+ const gap = spec.gap;
+ const controlBarHeight = 44;
+
+ const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => {
+ const scale = cardHeight / spec.height;
+ return {
+ cardHeight,
+ cardWidth,
+ imageSize: Math.round(spec.imageSize * scale),
+ padding: Math.round(spec.padding * scale),
+ gap,
+ headerPaddingX: Math.round(spec.headerPadX * scale),
+ headerPaddingY: Math.round(spec.headerPadY * scale),
+ codeTextSize: Math.round(spec.codeText * scale),
+ titleTextSize: Math.round(spec.titleText * scale),
+ bodyTextSize: Math.round(spec.bodyText * scale),
+ };
+ };
+
+ if (containerWidth <= 0 || containerHeight <= 0) {
+ return buildScaledConfig(Math.round(spec.height * 1.6), spec.height);
+ }
+
+ const effectiveHeight = baseContainerHeight.current > 0
+ ? baseContainerHeight.current
+ : containerHeight;
+
+ const availableHeight = effectiveHeight - controlBarHeight;
+ const availableWidth = containerWidth;
+
+ const cardHeight = Math.max(spec.height,
+ Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows));
+ const cardWidth = Math.max(Math.round(spec.height * 1.6),
+ Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns));
+
+ return buildScaledConfig(cardWidth, cardHeight);
+ }, [spec, containerWidth, containerHeight, gridColumns, gridRows]);
+
+ // 기본 상태에서 표시할 카드 수
+ const visibleCardCount = useMemo(() => {
+ return gridColumns * gridRows;
+ }, [gridColumns, gridRows]);
+
+ // 더보기 버튼 표시 여부
+ const hasMoreCards = rows.length > visibleCardCount;
+
+ // 확장 상태에서 표시할 카드 수 계산
+ const expandedCardsPerPage = useMemo(() => {
+ // 가로/세로 모두: 기본 표시 수의 2배 + 스크롤 유도를 위해 1줄 추가
+ // 가로: 컴포넌트 크기 변경 없이 카드 2배 → 가로 스크롤로 탐색
+ // 세로: rowSpan 2배 → 2배 영역에 카드 채움 + 세로 스크롤
+ return Math.max(1, visibleCardCount * 2 + gridColumns);
+ }, [visibleCardCount, gridColumns]);
+
+ // 스크롤 영역 ref
+ const scrollAreaRef = useRef(null);
+
+ // 현재 표시할 카드 결정
+ const displayCards = useMemo(() => {
+ if (!isExpanded) {
+ // 기본 상태: visibleCardCount만큼만 표시
+ return rows.slice(0, visibleCardCount);
+ } else {
+ // 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이)
+ const start = (currentPage - 1) * expandedCardsPerPage;
+ const end = start + expandedCardsPerPage;
+ return rows.slice(start, end);
+ }
+ }, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
+
+ // 총 페이지 수
+ const totalPages = isExpanded
+ ? Math.ceil(rows.length / expandedCardsPerPage)
+ : 1;
+ // 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때
+ const needsPagination = isExpanded && totalPages > 1;
+
+ // 페이지 변경 핸들러
+ const handlePrevPage = () => {
+ if (currentPage > 1) {
+ setCurrentPage(currentPage - 1);
+ }
+ };
+
+ const handleNextPage = () => {
+ if (currentPage < totalPages) {
+ setCurrentPage(currentPage + 1);
+ }
+ };
+
+ // 확장/접기 토글: 세로 모드에서만 rowSpan 2배 확장, 가로 모드에서는 크기 변경 없이 카드만 추가 표시
+ const toggleExpand = () => {
+ if (isExpanded) {
+ if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) {
+ onRequestResize(componentId, originalRowSpan);
+ }
+ setCurrentPage(1);
+ setOriginalRowSpan(null);
+ baseContainerHeight.current = 0;
+ setIsExpanded(false);
+ } else {
+ baseContainerHeight.current = containerHeight;
+ if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) {
+ setOriginalRowSpan(currentRowSpan);
+ onRequestResize(componentId, currentRowSpan * 2);
+ }
+ setIsExpanded(true);
+ }
+ };
+
+ // 페이지 변경 시 스크롤 위치 초기화 (가로/세로 모두)
+ useEffect(() => {
+ if (scrollAreaRef.current && isExpanded) {
+ scrollAreaRef.current.scrollTop = 0;
+ scrollAreaRef.current.scrollLeft = 0;
+ }
+ }, [currentPage, isExpanded]);
+
+ // 데이터 조회
+ useEffect(() => {
+ if (!dataSource?.tableName) {
+ setLoading(false);
+ setRows([]);
+ return;
+ }
+
+ const fetchData = async () => {
+ setLoading(true);
+ setError(null);
+ missingImageCountRef.current = 0;
+ toastShownRef.current = false;
+
+ try {
+ // 필터 조건 구성
+ const filters: Record = {};
+ if (dataSource.filters && dataSource.filters.length > 0) {
+ dataSource.filters.forEach((f) => {
+ if (f.column && f.value) {
+ filters[f.column] = f.value;
+ }
+ });
+ }
+
+ // 이벤트로 수신한 company_code 필터 병합
+ if (eventCompanyCode) {
+ filters["company_code"] = eventCompanyCode;
+ }
+
+ // 정렬 조건
+ const sortBy = dataSource.sort?.column;
+ const sortOrder = dataSource.sort?.direction;
+
+ // 개수 제한
+ const size =
+ dataSource.limit?.mode === "limited" && dataSource.limit?.count
+ ? dataSource.limit.count
+ : 100;
+
+ // TODO: 조인 지원은 추후 구현
+ // 현재는 단일 테이블 조회만 지원
+
+ const result = await dataApi.getTableData(dataSource.tableName, {
+ page: 1,
+ size,
+ sortBy: sortOrder ? sortBy : undefined,
+ sortOrder,
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
+ });
+
+ setRows(result.data || []);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "데이터 조회 실패";
+ setError(message);
+ setRows([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [dataSource, eventCompanyCode]);
+
+ // 이미지 URL 없는 항목 체크 및 toast 표시
+ useEffect(() => {
+ if (
+ !loading &&
+ rows.length > 0 &&
+ template?.image?.enabled &&
+ template?.image?.imageColumn &&
+ !toastShownRef.current
+ ) {
+ const imageColumn = template.image.imageColumn;
+ const missingCount = rows.filter((row) => !row[imageColumn]).length;
+
+ if (missingCount > 0) {
+ missingImageCountRef.current = missingCount;
+ toastShownRef.current = true;
+ toast.warning(
+ `${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다`
+ );
+ }
+ }
+ }, [loading, rows, template?.image]);
+
+
+ // 카드 영역 스타일
+ const cardAreaStyle: React.CSSProperties = {
+ gap: `${scaled.gap}px`,
+ ...(isHorizontalMode
+ ? {
+ gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`,
+ gridAutoFlow: "column",
+ gridAutoColumns: `${scaled.cardWidth}px`,
+ }
+ : {
+ // 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지
+ gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
+ gridAutoRows: `${scaled.cardHeight}px`,
+ }),
+ };
+
+ // 세로 모드 스크롤 클래스: 비확장 시 overflow hidden, 확장 시에만 세로 스크롤 허용
+ const scrollClassName = isHorizontalMode
+ ? "overflow-x-auto overflow-y-hidden"
+ : isExpanded
+ ? "overflow-y-auto overflow-x-hidden"
+ : "overflow-hidden";
+
+ return (
+
+ {!dataSource?.tableName ? (
+
+ ) : loading ? (
+
+
+
+ ) : error ? (
+
+ ) : rows.length === 0 ? (
+
+ ) : (
+ <>
+ {/* 카드 영역 (스크롤 가능) */}
+
+ {displayCards.map((row, index) => (
+
+ ))}
+
+
+ {/* 하단 컨트롤 영역 */}
+ {hasMoreCards && (
+
+
+
+
+ {isExpanded ? (
+ <>
+ 접기
+
+ >
+ ) : (
+ <>
+ 더보기
+
+ >
+ )}
+
+
+ {rows.length}건
+
+
+
+ {isExpanded && needsPagination && (
+
+
+
+
+
+ {currentPage} / {totalPages}
+
+ = totalPages}
+ className="h-9 w-9 p-0"
+ >
+
+
+
+ )}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// ===== 카드 컴포넌트 =====
+
+function Card({
+ row,
+ template,
+ scaled,
+ inputField,
+ calculatedField,
+ cartAction,
+ publish,
+ getSharedData,
+ setSharedData,
+ router,
+}: {
+ row: RowData;
+ template?: CardTemplateConfig;
+ scaled: ScaledConfig;
+ inputField?: CardInputFieldConfig;
+ calculatedField?: CardCalculatedFieldConfig;
+ cartAction?: CardCartActionConfig;
+ publish: (eventName: string, payload?: unknown) => void;
+ getSharedData: (key: string) => T | undefined;
+ setSharedData: (key: string, value: unknown) => void;
+ router: ReturnType;
+}) {
+ const header = template?.header;
+ const image = template?.image;
+ const body = template?.body;
+
+ // 입력 필드 상태
+ const [inputValue, setInputValue] = useState(
+ inputField?.defaultValue || 0
+ );
+ const [packageUnit, setPackageUnit] = useState(undefined);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ // 담기/취소 토글 상태
+ const [isCarted, setIsCarted] = useState(false);
+
+ // 헤더 값 추출
+ const codeValue = header?.codeField ? row[header.codeField] : null;
+ const titleValue = header?.titleField ? row[header.titleField] : null;
+
+ // 이미지 URL 결정
+ const imageUrl =
+ image?.enabled && image?.imageColumn && row[image.imageColumn]
+ ? String(row[image.imageColumn])
+ : image?.defaultImage || DEFAULT_CARD_IMAGE;
+
+ // 계산 필드 값 계산
+ const calculatedValue = useMemo(() => {
+ if (!calculatedField?.enabled || !calculatedField?.formula) return null;
+ return evaluateFormula(calculatedField.formula, row, inputValue);
+ }, [calculatedField, row, inputValue]);
+
+ // effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백
+ const effectiveMax = useMemo(() => {
+ if (inputField?.maxColumn) {
+ const colVal = Number(row[inputField.maxColumn]);
+ if (!isNaN(colVal) && colVal > 0) return colVal;
+ }
+ return inputField?.max ?? 999999;
+ }, [inputField, row]);
+
+ // 기본값이 설정되지 않은 경우 최대값으로 자동 초기화
+ useEffect(() => {
+ if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) {
+ setInputValue(effectiveMax);
+ }
+ }, [effectiveMax, inputField?.enabled, inputField?.defaultValue]);
+
+ const cardStyle: React.CSSProperties = {
+ height: `${scaled.cardHeight}px`,
+ overflow: "hidden",
+ };
+
+ const headerStyle: React.CSSProperties = {
+ padding: `${scaled.headerPaddingY}px ${scaled.headerPaddingX}px`,
+ };
+
+ const bodyStyle: React.CSSProperties = {
+ padding: `${scaled.padding}px`,
+ gap: `${scaled.gap}px`,
+ };
+
+ const imageContainerStyle: React.CSSProperties = {
+ width: `${scaled.imageSize}px`,
+ height: `${scaled.imageSize}px`,
+ };
+
+ const handleInputClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsModalOpen(true);
+ };
+
+ const handleInputConfirm = (value: number, unit?: string) => {
+ setInputValue(value);
+ setPackageUnit(unit);
+ };
+
+ // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글
+ const handleCartAdd = () => {
+ const cartItem: CartItem = {
+ row,
+ quantity: inputValue,
+ packageUnit: packageUnit || undefined,
+ };
+
+ const existing = getSharedData("cart_items") || [];
+ setSharedData("cart_items", [...existing, cartItem]);
+ publish("cart_item_added", cartItem);
+
+ setIsCarted(true);
+ toast.success("장바구니에 담겼습니다.");
+
+ if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) {
+ router.push(`/pop/screens/${cartAction.targetScreenId}`);
+ }
+ };
+
+ // 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원
+ const handleCartCancel = () => {
+ const existing = getSharedData("cart_items") || [];
+ const rowKey = JSON.stringify(row);
+ const filtered = existing.filter(
+ (item) => JSON.stringify(item.row) !== rowKey
+ );
+ setSharedData("cart_items", filtered);
+ publish("cart_item_removed", { row });
+
+ setIsCarted(false);
+ toast.info("장바구니에서 제거되었습니다.");
+ };
+
+ // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
+ const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11)));
+ const cartLabel = cartAction?.label || "담기";
+ const cancelLabel = cartAction?.cancelLabel || "취소";
+
+ return (
+
+ {/* 헤더 영역 */}
+ {(codeValue !== null || titleValue !== null) && (
+
+
+ {codeValue !== null && (
+
+ {formatValue(codeValue)}
+
+ )}
+ {titleValue !== null && (
+
+ {formatValue(titleValue)}
+
+ )}
+
+
+ )}
+
+ {/* 본문 영역 */}
+
+ {/* 이미지 (왼쪽) */}
+ {image?.enabled && (
+
+
+
{
+ const target = e.target as HTMLImageElement;
+ if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE;
+ }}
+ />
+
+
+ )}
+
+ {/* 필드 목록 (중간, flex-1) */}
+
+
+ {body?.fields && body.fields.length > 0 ? (
+ body.fields.map((field) => (
+
+ ))
+ ) : (
+
+ 본문 필드를 추가하세요
+
+ )}
+
+ {/* 계산 필드 */}
+ {calculatedField?.enabled && calculatedValue !== null && (
+
+
+ {calculatedField.label || "계산값"}
+
+
+ {calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""}
+
+
+ )}
+
+
+
+ {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */}
+ {inputField?.enabled && (
+
+ {/* 수량 버튼 */}
+
+
+ {inputValue.toLocaleString()}
+
+
+ {inputField.unit || "EA"}
+
+
+
+ {/* pop-icon 스타일 담기/취소 토글 버튼 */}
+ {isCarted ? (
+
+
+
+ {cancelLabel}
+
+
+ ) : (
+
+ {cartAction?.iconType === "emoji" && cartAction?.iconValue ? (
+ {cartAction.iconValue}
+ ) : (
+
+ )}
+
+ {cartLabel}
+
+
+ )}
+
+ )}
+
+
+ {/* 숫자 입력 모달 */}
+ {inputField?.enabled && (
+
+ )}
+
+ );
+}
+
+// ===== 필드 행 컴포넌트 =====
+
+function FieldRow({
+ field,
+ row,
+ scaled,
+}: {
+ field: CardFieldBinding;
+ row: RowData;
+ scaled: ScaledConfig;
+}) {
+ const value = row[field.columnName];
+
+ // 비율 기반 라벨 최소 너비
+ const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12));
+
+ return (
+
+ {/* 라벨 */}
+
+ {field.label}
+
+ {/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */}
+
+ {formatValue(value)}
+
+
+ );
+}
+
+// ===== 값 포맷팅 =====
+
+function formatValue(value: unknown): string {
+ if (value === null || value === undefined) {
+ return "-";
+ }
+ if (typeof value === "number") {
+ return value.toLocaleString();
+ }
+ if (typeof value === "boolean") {
+ return value ? "예" : "아니오";
+ }
+ if (value instanceof Date) {
+ return value.toLocaleDateString();
+ }
+ // ISO 날짜 문자열 감지 및 포맷
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
+ const date = new Date(value);
+ if (!isNaN(date.getTime())) {
+ // MM-DD 형식으로 표시
+ return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
+ }
+ }
+ return String(value);
+}
+
+// ===== 계산식 평가 =====
+
+/**
+ * 간단한 계산식을 평가합니다.
+ * 지원 연산: +, -, *, /
+ * 특수 변수: $input (입력 필드 값)
+ *
+ * @param formula 계산식 (예: "order_qty - inbound_qty", "$input - received_qty")
+ * @param row 데이터 행
+ * @param inputValue 입력 필드 값
+ * @returns 계산 결과 또는 null (계산 실패 시)
+ */
+function evaluateFormula(
+ formula: string,
+ row: RowData,
+ inputValue: number
+): number | null {
+ try {
+ // 수식에서 컬럼명과 $input을 실제 값으로 치환
+ let expression = formula;
+
+ // $input을 입력값으로 치환
+ expression = expression.replace(/\$input/g, String(inputValue));
+
+ // 컬럼명을 값으로 치환 (알파벳, 숫자, 언더스코어로 구성된 식별자)
+ const columnPattern = /[a-zA-Z_][a-zA-Z0-9_]*/g;
+ expression = expression.replace(columnPattern, (match) => {
+ // 이미 숫자로 치환된 경우 스킵
+ if (/^\d+$/.test(match)) return match;
+
+ const value = row[match];
+ if (value === null || value === undefined) return "0";
+ if (typeof value === "number") return String(value);
+ const parsed = parseFloat(String(value));
+ return isNaN(parsed) ? "0" : String(parsed);
+ });
+
+ // 안전한 계산 (기본 산술 연산만 허용)
+ // 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
+ if (!/^[\d\s+\-*/().]+$/.test(expression)) {
+ console.warn("Invalid formula expression:", expression);
+ return null;
+ }
+
+ // eval 대신 Function 사용 (더 안전)
+ const result = new Function(`return (${expression})`)();
+
+ if (typeof result !== "number" || isNaN(result) || !isFinite(result)) {
+ return null;
+ }
+
+ return Math.round(result * 100) / 100; // 소수점 2자리까지
+ } catch (error) {
+ console.warn("Formula evaluation error:", error);
+ return null;
+ }
+}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx
new file mode 100644
index 00000000..d8b31c34
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx
@@ -0,0 +1,1874 @@
+"use client";
+
+/**
+ * pop-card-list 설정 패널
+ *
+ * 3개 탭:
+ * [테이블] - 데이터 테이블 선택
+ * [카드 템플릿] - 헤더/이미지/본문 필드 + 레이아웃 설정
+ * [데이터 소스] - 조인/필터/정렬/개수 설정
+ */
+
+import React, { useState, useEffect, useMemo } from "react";
+import { ChevronDown, ChevronRight, Plus, Trash2, Database } from "lucide-react";
+import type { GridMode } from "@/components/pop/designer/types/pop-layout";
+import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type {
+ PopCardListConfig,
+ CardListDataSource,
+ CardTemplateConfig,
+ CardHeaderConfig,
+ CardImageConfig,
+ CardBodyConfig,
+ CardFieldBinding,
+ CardColumnJoin,
+ CardColumnFilter,
+ CardScrollDirection,
+ FilterOperator,
+ CardInputFieldConfig,
+ CardCalculatedFieldConfig,
+ CardCartActionConfig,
+} from "../types";
+import {
+ CARD_SCROLL_DIRECTION_LABELS,
+ DEFAULT_CARD_IMAGE,
+} from "../types";
+import {
+ fetchTableList,
+ fetchTableColumns,
+ type TableInfo,
+ type ColumnInfo,
+} from "../pop-dashboard/utils/dataFetcher";
+
+// ===== Props =====
+
+interface ConfigPanelProps {
+ config: PopCardListConfig | undefined;
+ onUpdate: (config: PopCardListConfig) => void;
+ currentMode?: GridMode;
+ currentColSpan?: number;
+}
+
+// ===== 기본값 =====
+
+const DEFAULT_DATA_SOURCE: CardListDataSource = {
+ tableName: "",
+};
+
+const DEFAULT_HEADER: CardHeaderConfig = {
+ codeField: undefined,
+ titleField: undefined,
+};
+
+const DEFAULT_IMAGE: CardImageConfig = {
+ enabled: true,
+ imageColumn: undefined,
+ defaultImage: DEFAULT_CARD_IMAGE,
+};
+
+const DEFAULT_BODY: CardBodyConfig = {
+ fields: [],
+};
+
+const DEFAULT_TEMPLATE: CardTemplateConfig = {
+ header: DEFAULT_HEADER,
+ image: DEFAULT_IMAGE,
+ body: DEFAULT_BODY,
+};
+
+const DEFAULT_CONFIG: PopCardListConfig = {
+ dataSource: DEFAULT_DATA_SOURCE,
+ cardTemplate: DEFAULT_TEMPLATE,
+ scrollDirection: "vertical",
+ gridColumns: 2,
+ gridRows: 3,
+ cardSize: "large",
+};
+
+// ===== 색상 옵션 =====
+
+const COLOR_OPTIONS = [
+ { value: "__default__", label: "기본" },
+ { value: "#ef4444", label: "빨간색" },
+ { value: "#f97316", label: "주황색" },
+ { value: "#eab308", label: "노란색" },
+ { value: "#22c55e", label: "초록색" },
+ { value: "#3b82f6", label: "파란색" },
+ { value: "#8b5cf6", label: "보라색" },
+ { value: "#6b7280", label: "회색" },
+];
+
+// ===== 메인 컴포넌트 =====
+
+export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
+ // 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿
+ const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">(
+ "basic"
+ );
+
+ // config가 없으면 기본값 사용
+ const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
+
+ // config 업데이트 헬퍼
+ const updateConfig = (partial: Partial) => {
+ onUpdate({ ...cfg, ...partial });
+ };
+
+ // 테이블이 선택되었는지 확인
+ const hasTable = !!cfg.dataSource?.tableName;
+
+ return (
+
+ {/* 탭 헤더 - 3탭 구조 */}
+
+ setActiveTab("basic")}
+ >
+ 기본 설정
+
+ hasTable && setActiveTab("dataSource")}
+ disabled={!hasTable}
+ >
+ 데이터 소스
+
+ hasTable && setActiveTab("template")}
+ disabled={!hasTable}
+ >
+ 카드 템플릿
+
+
+
+ {/* 탭 내용 */}
+
+ {activeTab === "basic" && (
+
+ )}
+ {activeTab === "dataSource" && (
+
+ )}
+ {activeTab === "template" && (
+
+ )}
+
+
+ );
+}
+
+// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
+
+function BasicSettingsTab({
+ config,
+ onUpdate,
+ currentMode,
+ currentColSpan,
+}: {
+ config: PopCardListConfig;
+ onUpdate: (partial: Partial) => void;
+ currentMode?: GridMode;
+ currentColSpan?: number;
+}) {
+ const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
+ const [tables, setTables] = useState([]);
+
+ // 테이블 목록 로드
+ useEffect(() => {
+ fetchTableList().then(setTables);
+ }, []);
+
+ // 모드별 추천값 계산
+ const recommendation = useMemo(() => {
+ if (!currentMode) return null;
+ const columns = GRID_BREAKPOINTS[currentMode].columns;
+ if (columns >= 8) return { rows: 3, cols: 2 };
+ if (columns >= 6) return { rows: 3, cols: 1 };
+ return { rows: 2, cols: 1 };
+ }, [currentMode]);
+
+ // 열 최대값: colSpan 기반 제한
+ const maxColumns = useMemo(() => {
+ if (!currentColSpan) return 2;
+ return currentColSpan >= 8 ? 2 : 1;
+ }, [currentColSpan]);
+
+ // 현재 모드 라벨
+ const modeLabel = currentMode ? GRID_BREAKPOINTS[currentMode].label : null;
+
+ // 모드 변경 시 열/행 자동 적용
+ useEffect(() => {
+ if (!recommendation) return;
+ const currentRows = config.gridRows || 3;
+ const currentCols = config.gridColumns || 2;
+ if (currentRows !== recommendation.rows || currentCols !== recommendation.cols) {
+ onUpdate({
+ gridRows: recommendation.rows,
+ gridColumns: recommendation.cols,
+ });
+ }
+ }, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+ {/* 테이블 선택 섹션 */}
+
+
+
+ 데이터 테이블
+ {
+ onUpdate({
+ dataSource: {
+ tableName: val,
+ joins: undefined,
+ filters: undefined,
+ sort: undefined,
+ limit: undefined,
+ },
+ cardTemplate: DEFAULT_TEMPLATE,
+ });
+ }}
+ >
+
+
+
+
+ {tables.map((table) => (
+
+ {table.displayName || table.tableName}
+
+ ))}
+
+
+
+
+ {dataSource.tableName && (
+
+
+ {dataSource.tableName}
+
+ )}
+
+
+
+ {/* 레이아웃 설정 섹션 */}
+
+
+ {/* 현재 모드 뱃지 */}
+ {modeLabel && (
+
+ 현재:
+
+ {modeLabel}
+
+
+ )}
+
+ {/* 스크롤 방향 */}
+
+
스크롤 방향
+
+ {(["horizontal", "vertical"] as CardScrollDirection[]).map((dir) => (
+
onUpdate({ scrollDirection: dir })}
+ >
+
+ {CARD_SCROLL_DIRECTION_LABELS[dir]}
+
+ ))}
+
+
+
+ {/* 그리드 배치 설정 (행 x 열) */}
+
+
카드 배치 (행 x 열)
+
+
+ onUpdate({ gridRows: parseInt(e.target.value, 10) || 3 })
+ }
+ className="h-7 w-16 text-center text-xs"
+ />
+ x
+
+ onUpdate({ gridColumns: Math.min(parseInt(e.target.value, 10) || 1, maxColumns) })
+ }
+ className="h-7 w-16 text-center text-xs"
+ disabled={maxColumns === 1}
+ />
+
+
+
+ {config.scrollDirection === "horizontal"
+ ? "격자로 배치, 가로 스크롤"
+ : "격자로 배치, 세로 스크롤"}
+
+
+ {maxColumns === 1
+ ? "현재 모드에서 열 최대 1 (모드 변경 시 자동 적용)"
+ : "모드 변경 시 열/행 자동 적용 / 열 최대 2"}
+
+
+
+
+
+ );
+}
+
+// ===== 데이터 소스 탭 =====
+
+function DataSourceTab({
+ config,
+ onUpdate,
+}: {
+ config: PopCardListConfig;
+ onUpdate: (partial: Partial) => void;
+}) {
+ const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
+ const [tables, setTables] = useState([]);
+ const [columns, setColumns] = useState([]);
+
+ // 테이블 목록 로드
+ useEffect(() => {
+ fetchTableList().then(setTables);
+ }, []);
+
+ // 테이블 선택 시 컬럼 목록 로드
+ useEffect(() => {
+ if (dataSource.tableName) {
+ fetchTableColumns(dataSource.tableName).then(setColumns);
+ } else {
+ setColumns([]);
+ }
+ }, [dataSource.tableName]);
+
+ const updateDataSource = (partial: Partial) => {
+ onUpdate({
+ dataSource: { ...dataSource, ...partial },
+ });
+ };
+
+ // 테이블이 선택되지 않은 경우
+ if (!dataSource.tableName) {
+ return (
+
+
+
+ 먼저 테이블 탭에서 테이블을 선택하세요
+
+
+ );
+ }
+
+ return (
+
+ {/* 현재 선택된 테이블 표시 */}
+
+
+ {dataSource.tableName}
+
+
+ {/* 조인 설정 */}
+
0
+ ? `${dataSource.joins.length}개`
+ : "없음"
+ }
+ >
+
+
+
+ {/* 필터 설정 */}
+
0
+ ? `${dataSource.filters.length}개`
+ : "없음"
+ }
+ >
+
+
+
+ {/* 정렬 설정 */}
+
+
+
+
+ {/* 표시 개수 */}
+
+
+
+
+ );
+}
+
+// ===== 카드 템플릿 탭 =====
+
+function CardTemplateTab({
+ config,
+ onUpdate,
+}: {
+ config: PopCardListConfig;
+ onUpdate: (partial: Partial) => void;
+}) {
+ const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
+ const template = config.cardTemplate || DEFAULT_TEMPLATE;
+ const [columns, setColumns] = useState([]);
+
+ // 테이블 컬럼 로드
+ useEffect(() => {
+ if (dataSource.tableName) {
+ fetchTableColumns(dataSource.tableName).then(setColumns);
+ } else {
+ setColumns([]);
+ }
+ }, [dataSource.tableName]);
+
+ const updateTemplate = (partial: Partial) => {
+ onUpdate({
+ cardTemplate: { ...template, ...partial },
+ });
+ };
+
+ // 테이블 미선택 시 안내
+ if (!dataSource.tableName) {
+ return (
+
+
+
테이블을 먼저 선택해주세요
+
데이터 소스 탭에서 테이블을 선택하세요
+
+
+ );
+ }
+
+ return (
+
+ {/* 헤더 설정 */}
+
+ updateTemplate({ header })}
+ />
+
+
+ {/* 이미지 설정 */}
+
+ updateTemplate({ image })}
+ />
+
+
+ {/* 본문 필드 */}
+
+ updateTemplate({ body })}
+ />
+
+
+ {/* 입력 필드 설정 */}
+
+ onUpdate({ inputField })}
+ />
+
+
+ {/* 계산 필드 설정 */}
+
+ onUpdate({ calculatedField })}
+ />
+
+
+ {/* 담기 버튼 설정 */}
+
+ onUpdate({ cartAction })}
+ />
+
+
+ );
+}
+
+// ===== 접기/펴기 섹션 컴포넌트 =====
+
+function CollapsibleSection({
+ title,
+ badge,
+ defaultOpen = false,
+ children,
+}: {
+ title: string;
+ badge?: string;
+ defaultOpen?: boolean;
+ children: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+
+ return (
+
+
setOpen(!open)}
+ >
+
+ {open ? (
+
+ ) : (
+
+ )}
+ {title}
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+ {open &&
{children}
}
+
+ );
+}
+
+// ===== 헤더 설정 섹션 =====
+
+function HeaderSettingsSection({
+ header,
+ columns,
+ onUpdate,
+}: {
+ header: CardHeaderConfig;
+ columns: ColumnInfo[];
+ onUpdate: (header: CardHeaderConfig) => void;
+}) {
+ return (
+
+ {/* 코드 필드 */}
+
+
코드 필드
+
+ onUpdate({ ...header, codeField: val === "__none__" ? undefined : val })
+ }
+ >
+
+
+
+
+ 선택 안함
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+ 카드 헤더 왼쪽에 표시될 코드 (예: ITEM032)
+
+
+
+ {/* 제목 필드 */}
+
+
제목 필드
+
+ onUpdate({ ...header, titleField: val === "__none__" ? undefined : val })
+ }
+ >
+
+
+
+
+ 선택 안함
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+ 카드 헤더 오른쪽에 표시될 제목 (예: 너트 M10)
+
+
+
+ );
+}
+
+// ===== 이미지 설정 섹션 =====
+
+function ImageSettingsSection({
+ image,
+ columns,
+ onUpdate,
+}: {
+ image: CardImageConfig;
+ columns: ColumnInfo[];
+ onUpdate: (image: CardImageConfig) => void;
+}) {
+ return (
+
+ {/* 이미지 사용 여부 */}
+
+ 이미지 사용
+ onUpdate({ ...image, enabled: checked })}
+ />
+
+
+ {image.enabled && (
+ <>
+ {/* 기본 이미지 미리보기 */}
+
+
+ 기본 이미지 미리보기
+
+
+
+
+
+
+
+
+ {/* 기본 이미지 URL */}
+
+
기본 이미지 URL
+
+ onUpdate({
+ ...image,
+ defaultImage: e.target.value || DEFAULT_CARD_IMAGE,
+ })
+ }
+ placeholder="이미지 URL 입력"
+ className="mt-1 h-7 text-xs"
+ />
+
+ 이미지가 없는 항목에 표시될 기본 이미지
+
+
+
+ {/* 이미지 컬럼 선택 */}
+
+
이미지 URL 컬럼 (선택)
+
+ onUpdate({ ...image, imageColumn: val === "__none__" ? undefined : val })
+ }
+ >
+
+
+
+
+ 선택 안함 (기본 이미지 사용)
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+ DB에서 이미지 URL을 가져올 컬럼. URL이 없으면 기본 이미지 사용
+
+
+ >
+ )}
+
+ );
+}
+
+// ===== 본문 필드 섹션 =====
+
+function BodyFieldsSection({
+ body,
+ columns,
+ onUpdate,
+}: {
+ body: CardBodyConfig;
+ columns: ColumnInfo[];
+ onUpdate: (body: CardBodyConfig) => void;
+}) {
+ const fields = body.fields || [];
+
+ // 필드 추가
+ const addField = () => {
+ const newField: CardFieldBinding = {
+ id: `field-${Date.now()}`,
+ columnName: "",
+ label: "",
+ textColor: undefined,
+ };
+ onUpdate({ fields: [...fields, newField] });
+ };
+
+ // 필드 업데이트
+ const updateField = (index: number, updated: CardFieldBinding) => {
+ const newFields = [...fields];
+ newFields[index] = updated;
+ onUpdate({ fields: newFields });
+ };
+
+ // 필드 삭제
+ const deleteField = (index: number) => {
+ const newFields = fields.filter((_, i) => i !== index);
+ onUpdate({ fields: newFields });
+ };
+
+ // 필드 순서 이동
+ const moveField = (index: number, direction: "up" | "down") => {
+ const newIndex = direction === "up" ? index - 1 : index + 1;
+ if (newIndex < 0 || newIndex >= fields.length) return;
+
+ const newFields = [...fields];
+ [newFields[index], newFields[newIndex]] = [
+ newFields[newIndex],
+ newFields[index],
+ ];
+ onUpdate({ fields: newFields });
+ };
+
+ return (
+
+ {/* 필드 목록 */}
+ {fields.length === 0 ? (
+
+
+ 본문에 표시할 필드를 추가하세요
+
+
+ ) : (
+
+ {fields.map((field, index) => (
+ updateField(index, updated)}
+ onDelete={() => deleteField(index)}
+ onMove={(dir) => moveField(index, dir)}
+ />
+ ))}
+
+ )}
+
+ {/* 필드 추가 버튼 */}
+
+
+ 필드 추가
+
+
+ );
+}
+
+// ===== 필드 편집기 =====
+
+function FieldEditor({
+ field,
+ index,
+ columns,
+ totalCount,
+ onUpdate,
+ onDelete,
+ onMove,
+}: {
+ field: CardFieldBinding;
+ index: number;
+ columns: ColumnInfo[];
+ totalCount: number;
+ onUpdate: (field: CardFieldBinding) => void;
+ onDelete: () => void;
+ onMove: (direction: "up" | "down") => void;
+}) {
+ return (
+
+
+ {/* 순서 이동 버튼 */}
+
+ onMove("up")}
+ disabled={index === 0}
+ >
+
+
+ onMove("down")}
+ disabled={index === totalCount - 1}
+ >
+
+
+
+
+ {/* 필드 설정 */}
+
+
+ {/* 라벨 */}
+
+ 라벨
+ onUpdate({ ...field, label: e.target.value })}
+ placeholder="예: 발주일"
+ className="mt-1 h-7 text-xs"
+ />
+
+
+ {/* 컬럼 */}
+
+ 컬럼
+
+ onUpdate({ ...field, columnName: val === "__placeholder__" ? "" : val })
+ }
+ >
+
+
+
+
+
+ 컬럼 선택
+
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+
+
+ {/* 텍스트 색상 */}
+
+
텍스트 색상
+
+ onUpdate({ ...field, textColor: val === "__default__" ? undefined : val })
+ }
+ >
+
+
+
+
+ {COLOR_OPTIONS.map((opt) => (
+
+
+ {opt.value !== "__default__" && (
+
+ )}
+ {opt.label}
+
+
+ ))}
+
+
+
+
+
+ {/* 삭제 버튼 */}
+
+
+
+
+
+ );
+}
+
+
+// ===== 입력 필드 설정 섹션 =====
+
+function InputFieldSettingsSection({
+ inputField,
+ columns,
+ onUpdate,
+}: {
+ inputField?: CardInputFieldConfig;
+ columns: ColumnInfo[];
+ onUpdate: (inputField: CardInputFieldConfig) => void;
+}) {
+ const field = inputField || {
+ enabled: false,
+ label: "발주 수량",
+ unit: "EA",
+ defaultValue: 0,
+ min: 0,
+ max: 999999,
+ step: 1,
+ };
+
+ const updateField = (partial: Partial) => {
+ onUpdate({ ...field, ...partial });
+ };
+
+ return (
+
+ {/* 활성화 스위치 */}
+
+ 입력 필드 사용
+ updateField({ enabled })}
+ />
+
+
+ {field.enabled && (
+ <>
+ {/* 라벨 */}
+
+ 라벨
+ updateField({ label: e.target.value })}
+ className="mt-1 h-7 text-xs"
+ placeholder="발주 수량"
+ />
+
+
+ {/* 단위 */}
+
+ 단위
+ updateField({ unit: e.target.value })}
+ className="mt-1 h-7 text-xs"
+ placeholder="EA"
+ />
+
+
+ {/* 기본값 */}
+
+ 기본값
+ updateField({ defaultValue: parseInt(e.target.value, 10) || 0 })}
+ className="mt-1 h-7 text-xs"
+ placeholder="0"
+ />
+
+
+ {/* 최소/최대값 */}
+
+
+ {/* 최대값 컬럼 */}
+
+
+ 최대값 컬럼 (선택)
+
+
+ updateField({ maxColumn: val === "__none__" ? undefined : val })
+ }
+ >
+
+
+
+
+ 선택 안함 (위 고정 최대값 사용)
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+ 설정 시 각 카드 행의 해당 컬럼 값이 숫자패드 최대값으로 사용됨 (예: unreceived_qty)
+
+
+
+ {/* 저장 컬럼 (선택사항) */}
+
+
+ 저장 컬럼 (선택사항)
+
+
+ updateField({ columnName: val === "__none__" ? undefined : val })
+ }
+ >
+
+
+
+
+ 선택 안함
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+ 입력값을 저장할 DB 컬럼 (현재는 로컬 상태만 유지)
+
+
+ >
+ )}
+
+ );
+}
+
+// ===== 계산 필드 설정 섹션 =====
+
+function CalculatedFieldSettingsSection({
+ calculatedField,
+ columns,
+ onUpdate,
+}: {
+ calculatedField?: CardCalculatedFieldConfig;
+ columns: ColumnInfo[];
+ onUpdate: (calculatedField: CardCalculatedFieldConfig) => void;
+}) {
+ const field = calculatedField || {
+ enabled: false,
+ label: "미입고",
+ formula: "",
+ sourceColumns: [],
+ unit: "EA",
+ };
+
+ const updateField = (partial: Partial) => {
+ onUpdate({ ...field, ...partial });
+ };
+
+ return (
+
+ {/* 활성화 스위치 */}
+
+ 계산 필드 사용
+ updateField({ enabled })}
+ />
+
+
+ {field.enabled && (
+ <>
+ {/* 라벨 */}
+
+ 라벨
+ updateField({ label: e.target.value })}
+ className="mt-1 h-7 text-xs"
+ placeholder="미입고"
+ />
+
+
+ {/* 계산식 */}
+
+
계산식
+
updateField({ formula: e.target.value })}
+ className="mt-1 h-7 text-xs font-mono"
+ placeholder="$input - received_qty"
+ />
+
+ 사용 가능: 컬럼명, $input (입력값), +, -, *, /
+
+
+
+ {/* 단위 */}
+
+ 단위
+ updateField({ unit: e.target.value })}
+ className="mt-1 h-7 text-xs"
+ placeholder="EA"
+ />
+
+
+ {/* 사용 가능한 컬럼 목록 */}
+
+
+ 사용 가능한 컬럼
+
+
+
+ {columns.map((col) => (
+ {
+ // 클릭 시 계산식에 컬럼명 추가
+ const currentFormula = field.formula || "";
+ updateField({ formula: currentFormula + col.name });
+ }}
+ >
+ {col.name}
+
+ ))}
+
+
+
+ 클릭하면 계산식에 추가됩니다
+
+
+ >
+ )}
+
+ );
+}
+
+// ===== 조인 설정 섹션 =====
+
+function JoinSettingsSection({
+ dataSource,
+ tables,
+ onUpdate,
+}: {
+ dataSource: CardListDataSource;
+ tables: TableInfo[];
+ onUpdate: (partial: Partial) => void;
+}) {
+ const joins = dataSource.joins || [];
+ const [sourceColumns, setSourceColumns] = useState([]);
+ const [targetColumnsMap, setTargetColumnsMap] = useState<
+ Record
+ >({});
+
+ // 소스 테이블 컬럼 로드
+ useEffect(() => {
+ if (dataSource.tableName) {
+ fetchTableColumns(dataSource.tableName).then(setSourceColumns);
+ }
+ }, [dataSource.tableName]);
+
+ // 조인 추가
+ const addJoin = () => {
+ const newJoin: CardColumnJoin = {
+ targetTable: "",
+ joinType: "LEFT",
+ sourceColumn: "",
+ targetColumn: "",
+ };
+ onUpdate({ joins: [...joins, newJoin] });
+ };
+
+ // 조인 업데이트
+ const updateJoin = (index: number, updated: CardColumnJoin) => {
+ const newJoins = [...joins];
+ newJoins[index] = updated;
+ onUpdate({ joins: newJoins });
+
+ // 대상 테이블 컬럼 로드
+ if (updated.targetTable && !targetColumnsMap[updated.targetTable]) {
+ fetchTableColumns(updated.targetTable).then((cols) => {
+ setTargetColumnsMap((prev) => ({
+ ...prev,
+ [updated.targetTable]: cols,
+ }));
+ });
+ }
+ };
+
+ // 조인 삭제
+ const deleteJoin = (index: number) => {
+ const newJoins = joins.filter((_, i) => i !== index);
+ onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined });
+ };
+
+ return (
+
+ {joins.length === 0 ? (
+
+
+ 다른 테이블과 조인하여 추가 컬럼을 사용할 수 있습니다
+
+
+ ) : (
+
+ {joins.map((join, index) => (
+
+
+
+ 조인 {index + 1}
+
+ deleteJoin(index)}
+ >
+
+
+
+
+ {/* 조인 타입 */}
+
+ updateJoin(index, {
+ ...join,
+ joinType: val as "LEFT" | "INNER" | "RIGHT",
+ })
+ }
+ >
+
+
+
+
+ LEFT JOIN
+ INNER JOIN
+ RIGHT JOIN
+
+
+
+ {/* 대상 테이블 */}
+
+ updateJoin(index, {
+ ...join,
+ targetTable: val,
+ targetColumn: "",
+ })
+ }
+ >
+
+
+
+
+ {tables
+ .filter((t) => t.tableName !== dataSource.tableName)
+ .map((table) => (
+
+ {table.displayName || table.tableName}
+
+ ))}
+
+
+
+ {/* ON 조건 */}
+ {join.targetTable && (
+
+
+ updateJoin(index, { ...join, sourceColumn: val })
+ }
+ >
+
+
+
+
+ {sourceColumns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+ =
+
+ updateJoin(index, { ...join, targetColumn: val })
+ }
+ >
+
+
+
+
+ {(targetColumnsMap[join.targetTable] || []).map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ 조인 추가
+
+
+ );
+}
+
+// ===== 필터 설정 섹션 =====
+
+function FilterSettingsSection({
+ dataSource,
+ columns,
+ onUpdate,
+}: {
+ dataSource: CardListDataSource;
+ columns: ColumnInfo[];
+ onUpdate: (partial: Partial) => void;
+}) {
+ const filters = dataSource.filters || [];
+
+ const operators: { value: FilterOperator; label: string }[] = [
+ { value: "=", label: "=" },
+ { value: "!=", label: "!=" },
+ { value: ">", label: ">" },
+ { value: ">=", label: ">=" },
+ { value: "<", label: "<" },
+ { value: "<=", label: "<=" },
+ { value: "like", label: "LIKE" },
+ ];
+
+ // 필터 추가
+ const addFilter = () => {
+ const newFilter: CardColumnFilter = {
+ column: "",
+ operator: "=",
+ value: "",
+ };
+ onUpdate({ filters: [...filters, newFilter] });
+ };
+
+ // 필터 업데이트
+ const updateFilter = (index: number, updated: CardColumnFilter) => {
+ const newFilters = [...filters];
+ newFilters[index] = updated;
+ onUpdate({ filters: newFilters });
+ };
+
+ // 필터 삭제
+ const deleteFilter = (index: number) => {
+ const newFilters = filters.filter((_, i) => i !== index);
+ onUpdate({ filters: newFilters.length > 0 ? newFilters : undefined });
+ };
+
+ return (
+
+ {filters.length === 0 ? (
+
+
+ 필터 조건을 추가하여 데이터를 필터링할 수 있습니다
+
+
+ ) : (
+
+ {filters.map((filter, index) => (
+
+
+ updateFilter(index, { ...filter, column: val })
+ }
+ >
+
+
+
+
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+
+ updateFilter(index, {
+ ...filter,
+ operator: val as FilterOperator,
+ })
+ }
+ >
+
+
+
+
+ {operators.map((op) => (
+
+ {op.label}
+
+ ))}
+
+
+
+
+ updateFilter(index, { ...filter, value: e.target.value })
+ }
+ placeholder="값"
+ className="h-7 flex-1 text-xs"
+ />
+
+ deleteFilter(index)}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+ 필터 추가
+
+
+ );
+}
+
+// ===== 정렬 설정 섹션 =====
+
+function SortSettingsSection({
+ dataSource,
+ columns,
+ onUpdate,
+}: {
+ dataSource: CardListDataSource;
+ columns: ColumnInfo[];
+ onUpdate: (partial: Partial) => void;
+}) {
+ const sort = dataSource.sort;
+
+ return (
+
+ {/* 정렬 사용 여부 */}
+
+ onUpdate({ sort: undefined })}
+ >
+ 정렬 없음
+
+
+ onUpdate({ sort: { column: "", direction: "asc" } })
+ }
+ >
+ 정렬 사용
+
+
+
+ {sort && (
+
+ {/* 정렬 컬럼 */}
+
+ 정렬 컬럼
+
+ onUpdate({ sort: { ...sort, column: val } })
+ }
+ >
+
+
+
+
+ {columns.map((col) => (
+
+ {col.name}
+
+ ))}
+
+
+
+
+ {/* 정렬 방향 */}
+
+
정렬 방향
+
+ onUpdate({ sort: { ...sort, direction: "asc" } })}
+ >
+ 오름차순
+
+
+ onUpdate({ sort: { ...sort, direction: "desc" } })
+ }
+ >
+ 내림차순
+
+
+
+
+ )}
+
+ );
+}
+
+// ===== 표시 개수 설정 섹션 =====
+
+function LimitSettingsSection({
+ dataSource,
+ onUpdate,
+}: {
+ dataSource: CardListDataSource;
+ onUpdate: (partial: Partial) => void;
+}) {
+ const limit = dataSource.limit || { mode: "all" as const };
+ const isLimited = limit.mode === "limited";
+
+ return (
+
+ {/* 모드 선택 */}
+
+ onUpdate({ limit: { mode: "all" } })}
+ >
+ 전체 보기
+
+ onUpdate({ limit: { mode: "limited", count: 10 } })}
+ >
+ 개수 제한
+
+
+
+ {isLimited && (
+
+ 표시 개수
+
+ onUpdate({
+ limit: {
+ mode: "limited",
+ count: parseInt(e.target.value, 10) || 10,
+ },
+ })
+ }
+ className="mt-1 h-7 text-xs"
+ />
+
+ )}
+
+ );
+}
+
+// ===== 담기 버튼 설정 섹션 =====
+
+function CartActionSettingsSection({
+ cartAction,
+ onUpdate,
+}: {
+ cartAction?: CardCartActionConfig;
+ onUpdate: (cartAction: CardCartActionConfig) => void;
+}) {
+ const action: CardCartActionConfig = cartAction || {
+ navigateMode: "none",
+ iconType: "lucide",
+ iconValue: "ShoppingCart",
+ label: "담기",
+ cancelLabel: "취소",
+ };
+
+ const update = (partial: Partial) => {
+ onUpdate({ ...action, ...partial });
+ };
+
+ return (
+
+ {/* 네비게이션 모드 */}
+
+ 담기 후 이동
+
+ update({ navigateMode: v as "none" | "screen" })
+ }
+ >
+
+
+
+
+ 없음 (토스트만)
+ POP 화면 이동
+
+
+
+
+ {/* 대상 화면 ID (screen 모드일 때만) */}
+ {action.navigateMode === "screen" && (
+
+
장바구니 화면 ID
+
update({ targetScreenId: e.target.value })}
+ placeholder="예: 15"
+ className="mt-1 h-7 text-xs"
+ />
+
+ 담기 클릭 시 이동할 POP 화면의 screenId
+
+
+ )}
+
+ {/* 아이콘 타입 */}
+
+ 아이콘 타입
+
+ update({ iconType: v as "lucide" | "emoji" })
+ }
+ >
+
+
+
+
+ Lucide 아이콘
+ 이모지
+
+
+
+
+ {/* 아이콘 값 */}
+
+
+ {action.iconType === "emoji" ? "이모지" : "Lucide 아이콘명"}
+
+
update({ iconValue: e.target.value })}
+ placeholder={
+ action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart"
+ }
+ className="mt-1 h-7 text-xs"
+ />
+ {action.iconType === "lucide" && (
+
+ PascalCase로 입력 (ShoppingCart, Package, Truck 등)
+
+ )}
+
+
+ {/* 담기 라벨 */}
+
+ 담기 라벨
+ update({ label: e.target.value })}
+ placeholder="담기"
+ className="mt-1 h-7 text-xs"
+ />
+
+
+ {/* 취소 라벨 */}
+
+ 취소 라벨
+ update({ cancelLabel: e.target.value })}
+ placeholder="취소"
+ className="mt-1 h-7 text-xs"
+ />
+
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx
new file mode 100644
index 00000000..312567b9
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+/**
+ * pop-card-list 디자인 모드 미리보기 컴포넌트 (V2)
+ *
+ * 디자이너 캔버스에서 표시되는 미리보기
+ * 이미지 참조 기반 카드 구조 반영
+ */
+
+import React from "react";
+import { LayoutGrid, Package } from "lucide-react";
+import type { PopCardListConfig } from "../types";
+import {
+ CARD_SCROLL_DIRECTION_LABELS,
+ CARD_SIZE_LABELS,
+ DEFAULT_CARD_IMAGE,
+} from "../types";
+
+interface PopCardListPreviewProps {
+ config?: PopCardListConfig;
+}
+
+export function PopCardListPreviewComponent({
+ config,
+}: PopCardListPreviewProps) {
+ const scrollDirection = config?.scrollDirection || "vertical";
+ const cardSize = config?.cardSize || "medium";
+ const dataSource = config?.dataSource;
+ const template = config?.cardTemplate;
+
+ const hasTable = !!dataSource?.tableName;
+ const hasHeader =
+ !!template?.header?.codeField || !!template?.header?.titleField;
+ const hasImage = template?.image?.enabled ?? true;
+ const fieldCount = template?.body?.fields?.length || 0;
+
+ const sampleCardCount = 2;
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ 카드 목록
+
+
+ {/* 설정 배지 */}
+
+
+ {CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
+
+
+ {CARD_SIZE_LABELS[cardSize]}
+
+
+
+
+ {/* 테이블 미선택 시 안내 */}
+ {!hasTable ? (
+
+ ) : (
+ <>
+ {/* 테이블 정보 */}
+
+
+ {dataSource.tableName}
+
+
+
+ {/* 카드 미리보기 */}
+
+ {Array.from({ length: sampleCardCount }).map((_, idx) => (
+
+ ))}
+
+
+ {/* 필드 정보 */}
+ {fieldCount > 0 && (
+
+
+ {fieldCount}개 필드 설정됨
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// ===== 미리보기 카드 컴포넌트 =====
+
+function PreviewCard({
+ index,
+ hasHeader,
+ hasImage,
+ fieldCount,
+ cardSize,
+ scrollDirection,
+}: {
+ index: number;
+ hasHeader: boolean;
+ hasImage: boolean;
+ fieldCount: number;
+ cardSize: string;
+ scrollDirection: string;
+}) {
+ const sizeClass =
+ cardSize === "small"
+ ? "min-h-[60px]"
+ : cardSize === "large"
+ ? "min-h-[100px]"
+ : "min-h-[80px]";
+
+ const widthClass =
+ scrollDirection === "vertical"
+ ? "w-full"
+ : "min-w-[140px] flex-shrink-0";
+
+ return (
+
+ {/* 헤더 */}
+ {hasHeader && (
+
+ )}
+
+ {/* 본문 */}
+
+ {/* 이미지 */}
+ {hasImage && (
+
+
+
+
+
+ )}
+
+ {/* 필드 목록 */}
+
+ {fieldCount > 0 ? (
+ Array.from({ length: Math.min(fieldCount, 3) }).map((_, i) => (
+
+
+
+
+ ))
+ ) : (
+
+
+ 필드 추가
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx
new file mode 100644
index 00000000..6e417c1e
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+/**
+ * pop-card-list 컴포넌트 레지스트리 등록 진입점 (V2)
+ *
+ * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨
+ */
+
+import { PopComponentRegistry } from "../../PopComponentRegistry";
+import { PopCardListComponent } from "./PopCardListComponent";
+import { PopCardListConfigPanel } from "./PopCardListConfig";
+import { PopCardListPreviewComponent } from "./PopCardListPreview";
+import type { PopCardListConfig } from "../types";
+import { DEFAULT_CARD_IMAGE } from "../types";
+
+const defaultConfig: PopCardListConfig = {
+ // 데이터 소스 (테이블 단위)
+ dataSource: {
+ tableName: "",
+ },
+ // 카드 템플릿
+ cardTemplate: {
+ header: {
+ codeField: undefined,
+ titleField: undefined,
+ },
+ image: {
+ enabled: true,
+ imageColumn: undefined,
+ defaultImage: DEFAULT_CARD_IMAGE,
+ },
+ body: {
+ fields: [],
+ },
+ },
+ // 스크롤 방향
+ scrollDirection: "vertical",
+ cardSize: "medium",
+ // 그리드 배치 (가로 x 세로)
+ gridColumns: 3,
+ gridRows: 2,
+ // 담기 버튼 기본 설정
+ cartAction: {
+ navigateMode: "none",
+ iconType: "lucide",
+ iconValue: "ShoppingCart",
+ label: "담기",
+ cancelLabel: "취소",
+ },
+};
+
+// 레지스트리 등록
+PopComponentRegistry.registerComponent({
+ id: "pop-card-list",
+ name: "카드 목록",
+ description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
+ category: "display",
+ icon: "LayoutGrid",
+ component: PopCardListComponent,
+ configPanel: PopCardListConfigPanel,
+ preview: PopCardListPreviewComponent,
+ defaultProps: defaultConfig,
+ touchOptimized: true,
+ supportedDevices: ["mobile", "tablet"],
+});
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx
new file mode 100644
index 00000000..8c9ce861
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx
@@ -0,0 +1,459 @@
+"use client";
+
+/**
+ * pop-dashboard 메인 컴포넌트 (뷰어용)
+ *
+ * 멀티 아이템 컨테이너: 여러 집계 아이템을 묶어서 다양한 표시 모드로 렌더링
+ *
+ * @INFRA-EXTRACT 대상:
+ * - fetchAggregatedData 호출부 -> useDataSource로 교체 예정
+ * - filter_changed 이벤트 수신 -> usePopEvent로 교체 예정
+ */
+
+import React, { useState, useEffect, useCallback, useRef } from "react";
+import type {
+ PopDashboardConfig,
+ DashboardItem,
+ DashboardPage,
+} from "../types";
+import { fetchAggregatedData } from "./utils/dataFetcher";
+import {
+ evaluateFormula,
+ formatFormulaResult,
+} from "./utils/formula";
+
+// 서브타입 아이템 컴포넌트
+import { KpiCardComponent } from "./items/KpiCard";
+import { ChartItemComponent } from "./items/ChartItem";
+import { GaugeItemComponent } from "./items/GaugeItem";
+import { StatCardComponent } from "./items/StatCard";
+
+// 표시 모드 컴포넌트
+import { ArrowsModeComponent } from "./modes/ArrowsMode";
+import { AutoSlideModeComponent } from "./modes/AutoSlideMode";
+import { GridModeComponent } from "./modes/GridMode";
+import { ScrollModeComponent } from "./modes/ScrollMode";
+
+// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 =====
+
+/**
+ * 기존 config를 페이지 기반 구조로 마이그레이션.
+ * 런타임에서만 사용 (저장된 config 원본은 변경하지 않음).
+ *
+ * 시나리오1: displayMode="grid" (가장 오래된 형태)
+ * 시나리오2: useGridLayout=true (직전 마이그레이션 결과)
+ * 시나리오3: pages 이미 있음 (새 형태) -> 변환 불필요
+ * 시나리오4: pages 없음 + grid 아님 -> 빈 pages (아이템 하나씩 슬라이드)
+ */
+export function migrateConfig(
+ raw: Record
+): PopDashboardConfig {
+ const config = { ...raw } as PopDashboardConfig & Record;
+
+ // pages가 이미 있으면 마이그레이션 불필요
+ if (
+ Array.isArray(config.pages) &&
+ config.pages.length > 0
+ ) {
+ return config;
+ }
+
+ // 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true
+ const wasGrid =
+ config.displayMode === ("grid" as string) ||
+ (config as Record).useGridLayout === true;
+
+ if (wasGrid) {
+ const cols =
+ ((config as Record).gridColumns as number) ?? 2;
+ const rows =
+ ((config as Record).gridRows as number) ?? 2;
+ const cells =
+ ((config as Record).gridCells as DashboardPage["gridCells"]) ?? [];
+
+ const page: DashboardPage = {
+ id: "migrated-page-1",
+ label: "페이지 1",
+ gridColumns: cols,
+ gridRows: rows,
+ gridCells: cells,
+ };
+
+ config.pages = [page];
+
+ // displayMode="grid" 보정
+ if (config.displayMode === ("grid" as string)) {
+ (config as Record).displayMode = "arrows";
+ }
+ }
+
+ return config as PopDashboardConfig;
+}
+
+// ===== 내부 타입 =====
+
+interface ItemData {
+ /** 단일 집계 값 */
+ value: number;
+ /** 데이터 행 (차트용) */
+ rows: Record[];
+ /** 수식 결과 표시 문자열 */
+ formulaDisplay: string | null;
+ /** 에러 메시지 */
+ error: string | null;
+}
+
+// ===== 데이터 로딩 함수 =====
+
+/** 단일 아이템의 데이터를 조회 */
+async function loadItemData(item: DashboardItem): Promise {
+ try {
+ // 수식 모드
+ if (item.formula?.enabled && item.formula.values.length > 0) {
+ // 각 값(A, B, ...)을 병렬 조회
+ const results = await Promise.allSettled(
+ item.formula.values.map((fv) => fetchAggregatedData(fv.dataSource))
+ );
+
+ const valueMap: Record = {};
+ for (let i = 0; i < item.formula.values.length; i++) {
+ const result = results[i];
+ const fv = item.formula.values[i];
+ valueMap[fv.id] =
+ result.status === "fulfilled" ? result.value.value : 0;
+ }
+
+ const calculatedValue = evaluateFormula(
+ item.formula.expression,
+ valueMap
+ );
+ const formulaDisplay = formatFormulaResult(item.formula, valueMap);
+
+ return {
+ value: calculatedValue,
+ rows: [],
+ formulaDisplay,
+ error: null,
+ };
+ }
+
+ // 단일 집계 모드
+ const result = await fetchAggregatedData(item.dataSource);
+ if (result.error) {
+ return { value: 0, rows: [], formulaDisplay: null, error: result.error };
+ }
+
+ return {
+ value: result.value,
+ rows: result.rows ?? [],
+ formulaDisplay: null,
+ error: null,
+ };
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "데이터 로딩 실패";
+ return { value: 0, rows: [], formulaDisplay: null, error: message };
+ }
+}
+
+// ===== 메인 컴포넌트 =====
+
+export function PopDashboardComponent({
+ config,
+ previewPageIndex,
+}: {
+ config?: PopDashboardConfig;
+ /** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */
+ previewPageIndex?: number;
+}) {
+ const [dataMap, setDataMap] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const refreshTimerRef = useRef | null>(null);
+ const containerRef = useRef(null);
+ const [containerWidth, setContainerWidth] = useState(300);
+
+ // 보이는 아이템만 필터링 (hooks 이전에 early return 불가하므로 빈 배열 허용)
+ const visibleItems = Array.isArray(config?.items)
+ ? config.items.filter((item) => item.visible)
+ : [];
+
+ // 컨테이너 크기 감지
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ setContainerWidth(entry.contentRect.width);
+ }
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ // 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지)
+ const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id));
+
+ // 데이터 로딩 함수
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const fetchAllData = useCallback(async () => {
+ if (!visibleItems.length) {
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+
+ // 모든 아이템 병렬 로딩 (하나 실패해도 나머지 표시)
+ // @INFRA-EXTRACT: useDataSource로 교체 예정
+ const results = await Promise.allSettled(
+ visibleItems.map((item) => loadItemData(item))
+ );
+
+ const newDataMap: Record = {};
+ for (let i = 0; i < visibleItems.length; i++) {
+ const result = results[i];
+ newDataMap[visibleItems[i].id] =
+ result.status === "fulfilled"
+ ? result.value
+ : { value: 0, rows: [], formulaDisplay: null, error: "로딩 실패" };
+ }
+
+ setDataMap(newDataMap);
+ setLoading(false);
+ }, [visibleItemIds]);
+
+ // 초기 로딩 + 주기적 새로고침
+ useEffect(() => {
+ fetchAllData();
+
+ // refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제)
+ const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval;
+ const refreshSec = rawRefreshSec && rawRefreshSec > 0
+ ? Math.max(5, rawRefreshSec)
+ : 0;
+ if (refreshSec > 0) {
+ refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000);
+ }
+
+ return () => {
+ if (refreshTimerRef.current) {
+ clearInterval(refreshTimerRef.current);
+ refreshTimerRef.current = null;
+ }
+ };
+ // visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetchAllData, visibleItemIds]);
+
+ // 빈 설정 (모든 hooks 이후에 early return)
+ if (!config || !config.items?.length) {
+ return (
+
+
+ 대시보드 아이템을 추가하세요
+
+
+ );
+ }
+
+ // 단일 아이템 렌더링
+ const renderSingleItem = (item: DashboardItem) => {
+ const itemData = dataMap[item.id];
+ if (!itemData) {
+ return (
+
+ 로딩 중...
+
+ );
+ }
+
+ if (itemData.error) {
+ return (
+
+ {itemData.error}
+
+ );
+ }
+
+ switch (item.subType) {
+ case "kpi-card":
+ return (
+
+ );
+ case "chart": {
+ // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
+ const chartItem = { ...item };
+ if (
+ item.dataSource.aggregation?.groupBy?.length &&
+ !item.chartConfig?.xAxisColumn
+ ) {
+ chartItem.chartConfig = {
+ ...chartItem.chartConfig,
+ chartType: chartItem.chartConfig?.chartType ?? "bar",
+ xAxisColumn: item.dataSource.aggregation.groupBy[0],
+ };
+ }
+ return (
+
+ );
+ }
+ case "gauge":
+ return ;
+ case "stat-card": {
+ // StatCard: 카테고리별 건수 맵 구성 (필터 적용)
+ const categoryData: Record = {};
+ if (item.statConfig?.categories) {
+ for (const cat of item.statConfig.categories) {
+ if (cat.filter.column && cat.filter.value !== undefined && cat.filter.value !== "") {
+ // 카테고리 필터로 rows 필터링
+ const filtered = itemData.rows.filter((row) => {
+ const cellValue = String(row[cat.filter.column] ?? "");
+ const filterValue = String(cat.filter.value ?? "");
+ switch (cat.filter.operator) {
+ case "=":
+ return cellValue === filterValue;
+ case "!=":
+ return cellValue !== filterValue;
+ case "like":
+ return cellValue.toLowerCase().includes(filterValue.toLowerCase());
+ default:
+ return cellValue === filterValue;
+ }
+ });
+ categoryData[cat.label] = filtered.length;
+ } else {
+ // 필터 미설정 시 전체 건수
+ categoryData[cat.label] = itemData.rows.length;
+ }
+ }
+ }
+ return (
+
+ );
+ }
+ default:
+ return (
+
+
+ 미지원 타입: {item.subType}
+
+
+ );
+ }
+ };
+
+ // 로딩 상태
+ if (loading && !Object.keys(dataMap).length) {
+ return (
+
+ );
+ }
+
+ // 마이그레이션: 기존 config를 페이지 기반으로 변환
+ const migrated = migrateConfig(config as unknown as Record);
+ const pages = migrated.pages ?? [];
+ const displayMode = migrated.displayMode;
+
+ // 페이지 하나를 GridModeComponent로 렌더링
+ const renderPageContent = (page: DashboardPage) => {
+ return (
+ {
+ const item = visibleItems.find((i) => i.id === itemId);
+ if (!item) return null;
+ return renderSingleItem(item);
+ }}
+ />
+ );
+ };
+
+ // 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
+ const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
+
+ // 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템
+ const renderSlide = (index: number) => {
+ if (pages.length > 0 && pages[index]) {
+ return renderPageContent(pages[index]);
+ }
+ // fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시)
+ if (visibleItems[index]) {
+ return renderSingleItem(visibleItems[index]);
+ }
+ return null;
+ };
+
+ // 페이지 미리보기 모드: 특정 페이지만 단독 렌더링 (디자이너에서 사용)
+ if (
+ typeof previewPageIndex === "number" &&
+ previewPageIndex >= 0 &&
+ pages[previewPageIndex]
+ ) {
+ return (
+
+ {renderPageContent(pages[previewPageIndex])}
+
+ );
+ }
+
+ // 표시 모드별 렌더링
+ return (
+
+ {displayMode === "arrows" && (
+
+ )}
+
+ {displayMode === "auto-slide" && (
+
+ )}
+
+ {displayMode === "scroll" && (
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx
new file mode 100644
index 00000000..f64e09ae
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx
@@ -0,0 +1,2338 @@
+"use client";
+
+/**
+ * pop-dashboard 설정 패널 (디자이너용)
+ *
+ * 3개 탭:
+ * [기본 설정] - 표시 모드, 간격, 인디케이터
+ * [아이템 관리] - 아이템 추가/삭제/순서변경, 데이터 소스 설정
+ * [페이지] - 페이지(슬라이드) 추가/삭제, 각 페이지 독립 그리드 레이아웃
+ */
+
+import React, { useState, useEffect, useCallback } from "react";
+import {
+ Plus,
+ Trash2,
+ ChevronDown,
+ ChevronUp,
+ GripVertical,
+ Check,
+ ChevronsUpDown,
+ Eye,
+} from "lucide-react";
+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 { Switch } from "@/components/ui/switch";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import type {
+ PopDashboardConfig,
+ DashboardItem,
+ DashboardSubType,
+ DashboardDisplayMode,
+ DataSourceConfig,
+ DataSourceFilter,
+ FilterOperator,
+ FormulaConfig,
+ ItemVisibility,
+ DashboardCell,
+ DashboardPage,
+ JoinConfig,
+ JoinType,
+ ItemStyleConfig,
+ AggregationType,
+} from "../types";
+import {
+ TEXT_ALIGN_LABELS,
+} from "../types";
+import { migrateConfig } from "./PopDashboardComponent";
+import {
+ fetchTableColumns,
+ fetchTableList,
+ type ColumnInfo,
+ type TableInfo,
+} from "./utils/dataFetcher";
+import { validateExpression } from "./utils/formula";
+
+// ===== Props =====
+
+interface ConfigPanelProps {
+ config: PopDashboardConfig | undefined;
+ onUpdate: (config: PopDashboardConfig) => void;
+ /** 페이지 미리보기 요청 (-1이면 해제) */
+ onPreviewPage?: (pageIndex: number) => void;
+ /** 현재 미리보기 중인 페이지 인덱스 */
+ previewPageIndex?: number;
+}
+
+// ===== 기본값 =====
+
+const DEFAULT_CONFIG: PopDashboardConfig = {
+ items: [],
+ pages: [],
+ displayMode: "arrows",
+ autoSlideInterval: 5,
+ autoSlideResumeDelay: 3,
+ showIndicator: true,
+ gap: 8,
+};
+
+const DEFAULT_VISIBILITY: ItemVisibility = {
+ showLabel: true,
+ showValue: true,
+ showUnit: true,
+ showTrend: true,
+ showSubLabel: false,
+ showTarget: true,
+};
+
+const DEFAULT_DATASOURCE: DataSourceConfig = {
+ tableName: "",
+ filters: [],
+ sort: [],
+};
+
+// ===== 라벨 상수 =====
+
+const DISPLAY_MODE_LABELS: Record = {
+ arrows: "좌우 버튼",
+ "auto-slide": "자동 슬라이드",
+ scroll: "스크롤",
+};
+
+const SUBTYPE_LABELS: Record = {
+ "kpi-card": "KPI 카드",
+ chart: "차트",
+ gauge: "게이지",
+ "stat-card": "통계 카드",
+};
+
+const JOIN_TYPE_LABELS: Record = {
+ inner: "INNER JOIN",
+ left: "LEFT JOIN",
+ right: "RIGHT JOIN",
+};
+
+// ===== 집계 함수 유효성 검증 유틸 =====
+
+// 아이템 타입별 사용 가능한 집계 함수
+const SUBTYPE_AGGREGATION_MAP: Record = {
+ "kpi-card": ["count", "sum", "avg", "min", "max"],
+ chart: ["count", "sum", "avg", "min", "max"],
+ gauge: ["count", "sum", "avg", "min", "max"],
+ "stat-card": ["count"],
+};
+
+// 집계 함수 라벨
+const AGGREGATION_LABELS: Record = {
+ count: "건수 (COUNT)",
+ sum: "합계 (SUM)",
+ avg: "평균 (AVG)",
+ min: "최소 (MIN)",
+ max: "최대 (MAX)",
+};
+
+// 숫자 전용 집계 함수 (숫자 컬럼에만 사용 가능)
+const NUMERIC_ONLY_AGGREGATIONS: AggregationType[] = ["sum", "avg"];
+
+// PostgreSQL 숫자 타입 판별용 패턴
+const NUMERIC_TYPE_PATTERNS = [
+ "int", "integer", "bigint", "smallint",
+ "numeric", "decimal", "real", "double",
+ "float", "serial", "bigserial", "smallserial",
+ "money", "number",
+];
+
+/** 컬럼이 숫자 타입인지 판별 */
+function isNumericColumn(col: ColumnInfo): boolean {
+ const t = (col.type || "").toLowerCase();
+ const u = (col.udtName || "").toLowerCase();
+ return NUMERIC_TYPE_PATTERNS.some(
+ (pattern) => t.includes(pattern) || u.includes(pattern)
+ );
+}
+
+/** 현재 집계 함수가 숫자 전용(sum/avg)인지 판별 */
+function isNumericOnlyAggregation(aggType: string | undefined): boolean {
+ return !!aggType && NUMERIC_ONLY_AGGREGATIONS.includes(aggType as AggregationType);
+}
+
+const FILTER_OPERATOR_LABELS: Record = {
+ "=": "같음 (=)",
+ "!=": "다름 (!=)",
+ ">": "초과 (>)",
+ ">=": "이상 (>=)",
+ "<": "미만 (<)",
+ "<=": "이하 (<=)",
+ like: "포함 (LIKE)",
+ in: "목록 (IN)",
+ between: "범위 (BETWEEN)",
+};
+
+// ===== 데이터 소스 편집기 =====
+
+function DataSourceEditor({
+ dataSource,
+ onChange,
+ subType,
+}: {
+ dataSource: DataSourceConfig;
+ onChange: (ds: DataSourceConfig) => void;
+ subType?: DashboardSubType;
+}) {
+ // 테이블 목록 (Combobox용)
+ const [tables, setTables] = useState([]);
+ const [loadingTables, setLoadingTables] = useState(false);
+ const [tableOpen, setTableOpen] = useState(false);
+
+ // 컬럼 목록 (집계 대상 컬럼용)
+ const [columns, setColumns] = useState([]);
+ const [loadingCols, setLoadingCols] = useState(false);
+ const [columnOpen, setColumnOpen] = useState(false);
+
+ // 그룹핑 컬럼 (차트 X축용)
+ const [groupByOpen, setGroupByOpen] = useState(false);
+
+ // 마운트 시 테이블 목록 로드
+ useEffect(() => {
+ setLoadingTables(true);
+ fetchTableList()
+ .then(setTables)
+ .finally(() => setLoadingTables(false));
+ }, []);
+
+ // 테이블 변경 시 컬럼 목록 조회
+ useEffect(() => {
+ if (!dataSource.tableName) {
+ setColumns([]);
+ return;
+ }
+ setLoadingCols(true);
+ fetchTableColumns(dataSource.tableName)
+ .then(setColumns)
+ .finally(() => setLoadingCols(false));
+ }, [dataSource.tableName]);
+
+ return (
+
+ {/* 테이블 선택 (검색 가능한 Combobox) */}
+
+
테이블
+
+
+
+ {dataSource.tableName
+ ? (tables.find((t) => t.tableName === dataSource.tableName)
+ ?.displayName ?? dataSource.tableName)
+ : loadingTables
+ ? "로딩..."
+ : "테이블 선택"}
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다
+
+
+ {tables.map((table) => (
+ {
+ const newVal =
+ table.tableName === dataSource.tableName
+ ? ""
+ : table.tableName;
+ onChange({ ...dataSource, tableName: newVal });
+ setTableOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+
+ {table.displayName || table.tableName}
+
+ {table.displayName &&
+ table.displayName !== table.tableName && (
+
+ {table.tableName}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* 집계 함수 + 대상 컬럼 */}
+
+
+ 집계 함수
+ {
+ // STEP 4: 숫자 전용 집계로 변경 시, 현재 컬럼이 숫자가 아니면 초기화
+ let currentColumn = dataSource.aggregation?.column ?? "";
+ if (val && isNumericOnlyAggregation(val) && currentColumn) {
+ const selectedCol = columns.find((c) => c.name === currentColumn);
+ if (selectedCol && !isNumericColumn(selectedCol)) {
+ currentColumn = "";
+ }
+ }
+ onChange({
+ ...dataSource,
+ aggregation: val
+ ? {
+ type: val as NonNullable<
+ DataSourceConfig["aggregation"]
+ >["type"],
+ column: currentColumn,
+ groupBy: dataSource.aggregation?.groupBy,
+ }
+ : undefined,
+ });
+ }}
+ >
+
+
+
+
+ {(subType
+ ? SUBTYPE_AGGREGATION_MAP[subType]
+ : (Object.keys(AGGREGATION_LABELS) as AggregationType[])
+ ).map((aggType) => (
+
+ {AGGREGATION_LABELS[aggType]}
+
+ ))}
+
+
+
+
+ {dataSource.aggregation && (
+
+
대상 컬럼
+
+
+
+ {loadingCols
+ ? "로딩..."
+ : dataSource.aggregation.column
+ ? columns.find(
+ (c) => c.name === dataSource.aggregation!.column
+ )
+ ? `${dataSource.aggregation.column} (${columns.find((c) => c.name === dataSource.aggregation!.column)?.type})`
+ : dataSource.aggregation.column
+ : "선택"}
+
+
+
+
+
+
+
+
+ {isNumericOnlyAggregation(dataSource.aggregation?.type)
+ ? "숫자 타입 컬럼이 없습니다."
+ : "컬럼을 찾을 수 없습니다."}
+
+
+ {(isNumericOnlyAggregation(dataSource.aggregation?.type)
+ ? columns.filter(isNumericColumn)
+ : columns
+ ).map((col) => (
+ {
+ onChange({
+ ...dataSource,
+ aggregation: {
+ ...dataSource.aggregation!,
+ column: col.name,
+ },
+ });
+ setColumnOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ {col.name}
+
+ ({col.type})
+
+
+ ))}
+
+
+
+
+
+
+ )}
+
+
+ {/* 그룹핑 (차트 X축 분류) */}
+ {dataSource.aggregation && (
+
+
그룹핑 (X축)
+
+
+
+ {dataSource.aggregation.groupBy?.length
+ ? dataSource.aggregation.groupBy.join(", ")
+ : "없음 (단일 값)"}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {columns.map((col) => (
+ {
+ const current = dataSource.aggregation?.groupBy ?? [];
+ const isSelected = current.includes(col.name);
+ const newGroupBy = isSelected
+ ? current.filter((g) => g !== col.name)
+ : [...current, col.name];
+ onChange({
+ ...dataSource,
+ aggregation: {
+ ...dataSource.aggregation!,
+ groupBy: newGroupBy.length > 0 ? newGroupBy : undefined,
+ },
+ });
+ setGroupByOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ {col.name}
+ ({col.type})
+
+ ))}
+
+
+
+
+
+
+ 차트에서 X축 카테고리로 사용됩니다
+
+ {subType === "chart" && !dataSource.aggregation?.groupBy?.length && (
+
+ 차트 모드에서는 그룹핑(X축)을 설정해야 의미 있는 차트가 표시됩니다
+
+ )}
+
+ )}
+
+ {/* 자동 새로고침 (Switch + 주기 입력) */}
+
+
+ 자동 새로고침
+ 0}
+ onCheckedChange={(checked) =>
+ onChange({
+ ...dataSource,
+ refreshInterval: checked ? 30 : 0,
+ })
+ }
+ />
+
+ {(dataSource.refreshInterval ?? 0) > 0 && (
+
+
+ 주기 (초)
+
+
+ onChange({
+ ...dataSource,
+ refreshInterval: Math.max(
+ 5,
+ parseInt(e.target.value) || 30
+ ),
+ })
+ }
+ className="h-7 text-xs"
+ min={5}
+ />
+
+ )}
+
+
+ {/* 조인 설정 */}
+
onChange({ ...dataSource, joins })}
+ />
+
+ {/* 필터 조건 */}
+ onChange({ ...dataSource, filters })}
+ />
+
+ );
+}
+
+// ===== 조인 편집기 =====
+
+function JoinEditor({
+ joins,
+ mainTable,
+ onChange,
+}: {
+ joins: JoinConfig[];
+ mainTable: string;
+ onChange: (joins: JoinConfig[]) => void;
+}) {
+ const [tables, setTables] = useState([]);
+
+ // 테이블 목록 로드
+ useEffect(() => {
+ fetchTableList().then(setTables);
+ }, []);
+
+ const addJoin = () => {
+ onChange([
+ ...joins,
+ {
+ targetTable: "",
+ joinType: "left",
+ on: { sourceColumn: "", targetColumn: "" },
+ },
+ ]);
+ };
+
+ const updateJoin = (index: number, partial: Partial) => {
+ const newJoins = [...joins];
+ newJoins[index] = { ...newJoins[index], ...partial };
+ onChange(newJoins);
+ };
+
+ const removeJoin = (index: number) => {
+ onChange(joins.filter((_, i) => i !== index));
+ };
+
+ return (
+
+
+
+ {!mainTable && joins.length === 0 && (
+
+ 먼저 메인 테이블을 선택하세요
+
+ )}
+
+ {joins.map((join, index) => (
+
updateJoin(index, partial)}
+ onRemove={() => removeJoin(index)}
+ />
+ ))}
+
+ );
+}
+
+function JoinRow({
+ join,
+ mainTable,
+ tables,
+ onUpdate,
+ onRemove,
+}: {
+ join: JoinConfig;
+ mainTable: string;
+ tables: TableInfo[];
+ onUpdate: (partial: Partial) => void;
+ onRemove: () => void;
+}) {
+ const [targetColumns, setTargetColumns] = useState([]);
+ const [sourceColumns, setSourceColumns] = useState([]);
+ const [targetTableOpen, setTargetTableOpen] = useState(false);
+
+ // 메인 테이블 컬럼 로드
+ useEffect(() => {
+ if (!mainTable) return;
+ fetchTableColumns(mainTable).then(setSourceColumns);
+ }, [mainTable]);
+
+ // 조인 대상 테이블 컬럼 로드
+ useEffect(() => {
+ if (!join.targetTable) return;
+ fetchTableColumns(join.targetTable).then(setTargetColumns);
+ }, [join.targetTable]);
+
+ return (
+
+
+ {/* 조인 타입 */}
+
onUpdate({ joinType: val as JoinType })}
+ >
+
+
+
+
+ {Object.entries(JOIN_TYPE_LABELS).map(([val, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* 조인 대상 테이블 (Combobox) */}
+
+
+
+ {join.targetTable
+ ? (tables.find((t) => t.tableName === join.targetTable)
+ ?.displayName ?? join.targetTable)
+ : "테이블 선택"}
+
+
+
+
+
+
+
+
+ 없음
+
+
+ {tables
+ .filter((t) => t.tableName !== mainTable)
+ .map((t) => (
+ {
+ onUpdate({ targetTable: t.tableName });
+ setTargetTableOpen(false);
+ }}
+ className="text-xs"
+ >
+ {t.displayName || t.tableName}
+
+ ))}
+
+
+
+
+
+
+ {/* 삭제 */}
+
+
+
+
+
+ {/* 조인 조건 (ON 절) */}
+ {join.targetTable && (
+
+ ON
+
+ onUpdate({ on: { ...join.on, sourceColumn: val } })
+ }
+ >
+
+
+
+
+ {sourceColumns.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+ =
+
+ onUpdate({ on: { ...join.on, targetColumn: val } })
+ }
+ >
+
+
+
+
+ {targetColumns.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+// ===== 필터 편집기 =====
+
+function FilterEditor({
+ filters,
+ tableName,
+ onChange,
+}: {
+ filters: DataSourceFilter[];
+ tableName: string;
+ onChange: (filters: DataSourceFilter[]) => void;
+}) {
+ const [columns, setColumns] = useState([]);
+
+ useEffect(() => {
+ if (!tableName) return;
+ fetchTableColumns(tableName).then(setColumns);
+ }, [tableName]);
+
+ const addFilter = () => {
+ onChange([...filters, { column: "", operator: "=", value: "" }]);
+ };
+
+ const updateFilter = (
+ index: number,
+ partial: Partial
+ ) => {
+ const newFilters = [...filters];
+ newFilters[index] = { ...newFilters[index], ...partial };
+
+ // operator 변경 시 value 초기화
+ if (partial.operator) {
+ if (partial.operator === "between") {
+ newFilters[index].value = ["", ""];
+ } else if (partial.operator === "in") {
+ newFilters[index].value = [];
+ } else if (
+ typeof newFilters[index].value !== "string" &&
+ typeof newFilters[index].value !== "number"
+ ) {
+ newFilters[index].value = "";
+ }
+ }
+
+ onChange(newFilters);
+ };
+
+ const removeFilter = (index: number) => {
+ onChange(filters.filter((_, i) => i !== index));
+ };
+
+ return (
+
+
+
+ {filters.map((filter, index) => (
+
+ {/* 컬럼 선택 */}
+
updateFilter(index, { column: val })}
+ >
+
+
+
+
+ {columns.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ {/* 연산자 */}
+
+ updateFilter(index, { operator: val as FilterOperator })
+ }
+ >
+
+
+
+
+ {Object.entries(FILTER_OPERATOR_LABELS).map(([op, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* 값 입력 (연산자에 따라 다른 UI) */}
+
+
+ {/* 삭제 */}
+
removeFilter(index)}
+ >
+
+
+
+ ))}
+
+ );
+}
+
+// ===== 수식 편집기 =====
+
+function FormulaEditor({
+ formula,
+ onChange,
+}: {
+ formula: FormulaConfig;
+ onChange: (f: FormulaConfig) => void;
+}) {
+ const availableIds = formula.values.map((v) => v.id);
+ const isValid = formula.expression
+ ? validateExpression(formula.expression, availableIds)
+ : true;
+
+ return (
+
+
계산식 설정
+
+ {/* 값 목록 */}
+ {formula.values.map((fv, index) => (
+
+
+
+ {fv.id}
+
+ {
+ const newValues = [...formula.values];
+ newValues[index] = { ...fv, label: e.target.value };
+ onChange({ ...formula, values: newValues });
+ }}
+ placeholder="라벨 (예: 생산량)"
+ className="h-7 flex-1 text-xs"
+ />
+ {formula.values.length > 2 && (
+ {
+ const newValues = formula.values.filter(
+ (_, i) => i !== index
+ );
+ onChange({ ...formula, values: newValues });
+ }}
+ >
+
+
+ )}
+
+
{
+ const newValues = [...formula.values];
+ newValues[index] = { ...fv, dataSource: ds };
+ onChange({ ...formula, values: newValues });
+ }}
+ />
+
+ ))}
+
+ {/* 값 추가 */}
+
{
+ const nextId = String.fromCharCode(65 + formula.values.length);
+ onChange({
+ ...formula,
+ values: [
+ ...formula.values,
+ {
+ id: nextId,
+ label: "",
+ dataSource: { ...DEFAULT_DATASOURCE },
+ },
+ ],
+ });
+ }}
+ >
+
+ 값 추가
+
+
+ {/* 수식 입력 */}
+
+
수식
+
+ onChange({ ...formula, expression: e.target.value })
+ }
+ placeholder="예: A / B * 100"
+ className={`h-8 text-xs ${!isValid ? "border-destructive" : ""}`}
+ />
+ {!isValid && (
+
+ 수식에 정의되지 않은 변수가 있습니다
+
+ )}
+
+
+ {/* 표시 형태 */}
+
+ 표시 형태
+
+ onChange({
+ ...formula,
+ displayFormat: val as FormulaConfig["displayFormat"],
+ })
+ }
+ >
+
+
+
+
+ 계산 결과 숫자
+ 분수 (1,234 / 5,678)
+ 퍼센트 (21.7%)
+ 비율 (1,234 : 5,678)
+
+
+
+
+ );
+}
+
+// ===== 아이템 편집기 =====
+
+function ItemEditor({
+ item,
+ index,
+ onUpdate,
+ onDelete,
+ onMoveUp,
+ onMoveDown,
+ isFirst,
+ isLast,
+}: {
+ item: DashboardItem;
+ index: number;
+ onUpdate: (item: DashboardItem) => void;
+ onDelete: () => void;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ isFirst: boolean;
+ isLast: boolean;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const [dataMode, setDataMode] = useState<"single" | "formula">(
+ item.formula?.enabled ? "formula" : "single"
+ );
+
+ return (
+
+ {/* 헤더 */}
+
+
+ onUpdate({ ...item, label: e.target.value })}
+ placeholder={`아이템 ${index + 1}`}
+ className="h-6 min-w-0 flex-1 border-0 bg-transparent px-1 text-xs font-medium shadow-none focus-visible:ring-1"
+ />
+
+ {SUBTYPE_LABELS[item.subType]}
+
+
+
+
+
+
+
+
+
+
+ onUpdate({ ...item, visible: checked })
+ }
+ className="scale-75"
+ />
+
+ setExpanded(!expanded)}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {/* 상세 설정 */}
+ {expanded && (
+
+
+ 타입
+ {
+ const newSubType = val as DashboardSubType;
+ const allowedAggs = SUBTYPE_AGGREGATION_MAP[newSubType];
+ const currentAggType = item.dataSource.aggregation?.type;
+
+ // STEP 7.5: subType 변경 시, 현재 집계 함수가 새 타입에서 허용되지 않으면 자동 전환
+ let newDataSource = item.dataSource;
+ if (currentAggType && !allowedAggs.includes(currentAggType)) {
+ const fallbackAgg = allowedAggs[0]; // 허용되는 첫 번째 집계 함수로 전환
+ newDataSource = {
+ ...item.dataSource,
+ aggregation: item.dataSource.aggregation
+ ? {
+ ...item.dataSource.aggregation,
+ type: fallbackAgg,
+ // count로 전환되면 컬럼 의미 없으므로 초기화
+ column: fallbackAgg === "count" ? "" : item.dataSource.aggregation.column,
+ }
+ : undefined,
+ };
+ }
+
+ onUpdate({ ...item, subType: newSubType, dataSource: newDataSource });
+ }}
+ >
+
+
+
+
+ KPI 카드
+ 차트
+ 게이지
+ 통계 카드
+
+
+
+
+
+ 데이터 모드
+ {
+ const mode = val as "single" | "formula";
+ setDataMode(mode);
+ if (mode === "formula" && !item.formula) {
+ onUpdate({
+ ...item,
+ formula: {
+ enabled: true,
+ values: [
+ {
+ id: "A",
+ label: "",
+ dataSource: { ...DEFAULT_DATASOURCE },
+ },
+ {
+ id: "B",
+ label: "",
+ dataSource: { ...DEFAULT_DATASOURCE },
+ },
+ ],
+ expression: "A / B",
+ displayFormat: "value",
+ },
+ });
+ } else if (mode === "single") {
+ onUpdate({ ...item, formula: undefined });
+ }
+ }}
+ >
+
+
+
+
+ 단일 집계
+ 계산식
+
+
+
+
+ {dataMode === "formula" && item.formula ? (
+
onUpdate({ ...item, formula: f })}
+ />
+ ) : (
+ onUpdate({ ...item, dataSource: ds })}
+ subType={item.subType}
+ />
+ )}
+
+ {/* 요소별 보이기/숨기기 */}
+
+
표시 요소
+
+ {(
+ [
+ ["showLabel", "라벨"],
+ ["showValue", "값"],
+ ["showUnit", "단위"],
+ ["showTrend", "증감율"],
+ ["showSubLabel", "보조라벨"],
+ ["showTarget", "목표값"],
+ ] as const
+ ).map(([key, label]) => (
+
+
+ onUpdate({
+ ...item,
+ visibility: {
+ ...item.visibility,
+ [key]: e.target.checked,
+ },
+ })
+ }
+ className="h-3 w-3 rounded border-input"
+ />
+ {label}
+
+ ))}
+
+
+
+ {/* 서브타입별 추가 설정 */}
+ {item.subType === "kpi-card" && (
+
+ 단위
+
+ onUpdate({
+ ...item,
+ kpiConfig: { ...item.kpiConfig, unit: e.target.value },
+ })
+ }
+ placeholder="EA, 톤, 원"
+ className="h-8 text-xs"
+ />
+
+ )}
+
+ {item.subType === "chart" && (
+
+
+ 차트 유형
+
+ onUpdate({
+ ...item,
+ chartConfig: {
+ ...item.chartConfig,
+ chartType: val as "bar" | "pie" | "line",
+ },
+ })
+ }
+ >
+
+
+
+
+ 막대 차트
+ 원형 차트
+ 라인 차트
+
+
+
+
+ {/* X축/Y축 자동 안내 */}
+
+ X축: 그룹핑(X축)에서 선택한 컬럼 자동 적용 / Y축: 집계 결과(value) 자동 적용
+
+
+ )}
+
+ {item.subType === "gauge" && (
+
+
+ 최소
+
+ onUpdate({
+ ...item,
+ gaugeConfig: {
+ ...item.gaugeConfig,
+ min: parseInt(e.target.value) || 0,
+ max: item.gaugeConfig?.max ?? 100,
+ },
+ })
+ }
+ className="h-8 text-xs"
+ />
+
+
+ 최대
+
+ onUpdate({
+ ...item,
+ gaugeConfig: {
+ ...item.gaugeConfig,
+ min: item.gaugeConfig?.min ?? 0,
+ max: parseInt(e.target.value) || 100,
+ },
+ })
+ }
+ className="h-8 text-xs"
+ />
+
+
+ 목표
+
+ onUpdate({
+ ...item,
+ gaugeConfig: {
+ ...item.gaugeConfig,
+ min: item.gaugeConfig?.min ?? 0,
+ max: item.gaugeConfig?.max ?? 100,
+ target: parseInt(e.target.value) || undefined,
+ },
+ })
+ }
+ className="h-8 text-xs"
+ />
+
+
+ )}
+
+ {/* 통계 카드 카테고리 설정 */}
+ {item.subType === "stat-card" && (
+
+
+
카테고리 설정
+
{
+ const currentCats = item.statConfig?.categories ?? [];
+ onUpdate({
+ ...item,
+ statConfig: {
+ ...item.statConfig,
+ categories: [
+ ...currentCats,
+ {
+ label: `카테고리 ${currentCats.length + 1}`,
+ filter: { column: "", operator: "=", value: "" },
+ },
+ ],
+ },
+ });
+ }}
+ >
+
+ 카테고리 추가
+
+
+
+ {(item.statConfig?.categories ?? []).map((cat, catIdx) => (
+
+
+ {
+ const newCats = [...(item.statConfig?.categories ?? [])];
+ newCats[catIdx] = { ...cat, label: e.target.value };
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ placeholder="라벨 (예: 수주)"
+ className="h-6 flex-1 text-xs"
+ />
+ {
+ const newCats = [...(item.statConfig?.categories ?? [])];
+ newCats[catIdx] = { ...cat, color: e.target.value || undefined };
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ placeholder="#색상코드"
+ className="h-6 w-20 text-xs"
+ />
+ {
+ const newCats = (item.statConfig?.categories ?? []).filter(
+ (_, i) => i !== catIdx
+ );
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ >
+
+
+
+ {/* 필터 조건: 컬럼 / 연산자 / 값 */}
+
+ {
+ const newCats = [...(item.statConfig?.categories ?? [])];
+ newCats[catIdx] = {
+ ...cat,
+ filter: { ...cat.filter, column: e.target.value },
+ };
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ placeholder="컬럼"
+ className="h-6 w-20 text-[10px]"
+ />
+ {
+ const newCats = [...(item.statConfig?.categories ?? [])];
+ newCats[catIdx] = {
+ ...cat,
+ filter: { ...cat.filter, operator: val as FilterOperator },
+ };
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ >
+
+
+
+
+ = 같음
+ != 다름
+ LIKE
+
+
+ {
+ const newCats = [...(item.statConfig?.categories ?? [])];
+ newCats[catIdx] = {
+ ...cat,
+ filter: { ...cat.filter, value: e.target.value },
+ };
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ placeholder="값"
+ className="h-6 flex-1 text-[10px]"
+ />
+
+
+ ))}
+
+ {(item.statConfig?.categories ?? []).length === 0 && (
+
+ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
+
+// ===== 그리드 레이아웃 편집기 =====
+
+/** 기본 셀 그리드 생성 헬퍼 */
+function generateDefaultCells(
+ cols: number,
+ rows: number
+): DashboardCell[] {
+ const cells: DashboardCell[] = [];
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ cells.push({
+ id: `cell-${r}-${c}`,
+ gridColumn: `${c + 1} / ${c + 2}`,
+ gridRow: `${r + 1} / ${r + 2}`,
+ itemId: null,
+ });
+ }
+ }
+ return cells;
+}
+
+// =====================================================
+// 아이템 스타일 에디터 (접기/펼치기 지원)
+// =====================================================
+function ItemStyleEditor({
+ item,
+ onUpdate,
+}: {
+ item: DashboardItem;
+ onUpdate: (updatedItem: DashboardItem) => void;
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ const updateStyle = (partial: Partial) => {
+ const updatedItem = {
+ ...item,
+ itemStyle: { ...item.itemStyle, ...partial },
+ };
+ onUpdate(updatedItem);
+ };
+
+ return (
+
+ {/* 헤더 - 클릭으로 접기/펼치기 */}
+
setExpanded(!expanded)}
+ >
+
+ {item.label || item.id}
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 내용 - 접기/펼치기 */}
+ {expanded && (
+
+ {/* 라벨 정렬 */}
+
+
+ 라벨 정렬
+
+
+ {(["left", "center", "right"] as const).map((align) => (
+ updateStyle({ labelAlign: align })}
+ >
+ {TEXT_ALIGN_LABELS[align]}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+function GridLayoutEditor({
+ cells,
+ gridColumns,
+ gridRows,
+ items,
+ onChange,
+ onUpdateItem,
+}: {
+ cells: DashboardCell[];
+ gridColumns: number;
+ gridRows: number;
+ items: DashboardItem[];
+ onChange: (cells: DashboardCell[], cols: number, rows: number) => void;
+ /** 아이템 스타일 업데이트 콜백 */
+ onUpdateItem?: (updatedItem: DashboardItem) => void;
+}) {
+ const ensuredCells =
+ cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows);
+
+ return (
+
+ {/* 행/열 조절 버튼 */}
+
+
+ 열
+ {
+ if (gridColumns > 1) {
+ const newCells = ensuredCells.filter((c) => {
+ const col = parseInt(c.gridColumn.split(" / ")[0]);
+ return col <= gridColumns - 1;
+ });
+ onChange(newCells, gridColumns - 1, gridRows);
+ }
+ }}
+ disabled={gridColumns <= 1}
+ >
+ -
+
+
+ {gridColumns}
+
+ {
+ if (gridColumns < 6) {
+ const newCells = [...ensuredCells];
+ for (let r = 0; r < gridRows; r++) {
+ newCells.push({
+ id: `cell-${r}-${gridColumns}`,
+ gridColumn: `${gridColumns + 1} / ${gridColumns + 2}`,
+ gridRow: `${r + 1} / ${r + 2}`,
+ itemId: null,
+ });
+ }
+ onChange(newCells, gridColumns + 1, gridRows);
+ }
+ }}
+ disabled={gridColumns >= 6}
+ >
+ +
+
+
+
+
+ 행
+ {
+ if (gridRows > 1) {
+ const newCells = ensuredCells.filter((c) => {
+ const row = parseInt(c.gridRow.split(" / ")[0]);
+ return row <= gridRows - 1;
+ });
+ onChange(newCells, gridColumns, gridRows - 1);
+ }
+ }}
+ disabled={gridRows <= 1}
+ >
+ -
+
+
+ {gridRows}
+
+ {
+ if (gridRows < 6) {
+ const newCells = [...ensuredCells];
+ for (let c = 0; c < gridColumns; c++) {
+ newCells.push({
+ id: `cell-${gridRows}-${c}`,
+ gridColumn: `${c + 1} / ${c + 2}`,
+ gridRow: `${gridRows + 1} / ${gridRows + 2}`,
+ itemId: null,
+ });
+ }
+ onChange(newCells, gridColumns, gridRows + 1);
+ }
+ }}
+ disabled={gridRows >= 6}
+ >
+ +
+
+
+
+
+ onChange(
+ generateDefaultCells(gridColumns, gridRows),
+ gridColumns,
+ gridRows
+ )
+ }
+ >
+ 초기화
+
+
+
+ {/* 시각적 그리드 프리뷰 + 아이템 배정 */}
+
+ {ensuredCells.map((cell) => (
+
+ {
+ const newCells = ensuredCells.map((c) =>
+ c.id === cell.id
+ ? { ...c, itemId: val === "empty" ? null : val }
+ : c
+ );
+ onChange(newCells, gridColumns, gridRows);
+ }}
+ >
+
+
+
+
+ 빈 셀
+ {items.map((item) => (
+
+ {item.label || item.id}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ 각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을
+ 추가/삭제할 수 있습니다.
+
+
+ {/* 배정된 아이템별 스타일 설정 */}
+ {onUpdateItem && (() => {
+ const assignedItemIds = ensuredCells
+ .map((c) => c.itemId)
+ .filter((id): id is string => !!id);
+ const uniqueIds = [...new Set(assignedItemIds)];
+ const assignedItems = uniqueIds
+ .map((id) => items.find((i) => i.id === id))
+ .filter((i): i is DashboardItem => !!i);
+
+ if (assignedItems.length === 0) return null;
+
+ return (
+
+
+ 아이템 스타일
+
+ {assignedItems.map((item) => (
+
+ ))}
+
+ );
+ })()}
+
+ );
+}
+
+// ===== 페이지 편집기 =====
+
+function PageEditor({
+ page,
+ pageIndex,
+ items,
+ onChange,
+ onDelete,
+ onPreview,
+ isPreviewing,
+ onUpdateItem,
+}: {
+ page: DashboardPage;
+ pageIndex: number;
+ items: DashboardItem[];
+ onChange: (updatedPage: DashboardPage) => void;
+ onDelete: () => void;
+ onPreview?: () => void;
+ isPreviewing?: boolean;
+ onUpdateItem?: (updatedItem: DashboardItem) => void;
+}) {
+ const [expanded, setExpanded] = useState(true);
+
+ return (
+
+ {/* 헤더 */}
+
+
+ {page.label || `페이지 ${pageIndex + 1}`}
+
+
+ {page.gridColumns}x{page.gridRows}
+
+ onPreview?.()}
+ title="이 페이지 미리보기"
+ >
+
+
+ setExpanded(!expanded)}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* 상세 */}
+ {expanded && (
+
+ {/* 라벨 */}
+
+ 라벨
+
+ onChange({ ...page, label: e.target.value })
+ }
+ placeholder={`페이지 ${pageIndex + 1}`}
+ className="h-7 text-xs"
+ />
+
+
+ {/* GridLayoutEditor 재사용 */}
+
+ onChange({
+ ...page,
+ gridCells: cells,
+ gridColumns: cols,
+ gridRows: rows,
+ })
+ }
+ onUpdateItem={onUpdateItem}
+ />
+
+ )}
+
+ );
+}
+
+// ===== 메인 설정 패널 =====
+
+export function PopDashboardConfigPanel(props: ConfigPanelProps) {
+ const { config, onUpdate: onChange } = props;
+ // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장
+ const merged = { ...DEFAULT_CONFIG, ...(config || {}) };
+
+ // 마이그레이션: 기존 useGridLayout/grid* -> pages 기반으로 변환
+ const cfg = migrateConfig(
+ merged as unknown as Record
+ ) as PopDashboardConfig;
+
+ const [activeTab, setActiveTab] = useState<"basic" | "items" | "pages">(
+ "basic"
+ );
+
+ // 설정 변경 헬퍼
+ const updateConfig = useCallback(
+ (partial: Partial) => {
+ onChange({ ...cfg, ...partial });
+ },
+ [cfg, onChange]
+ );
+
+ // 아이템 추가
+ const addItem = useCallback(
+ (subType: DashboardSubType) => {
+ const newItem: DashboardItem = {
+ id: `item-${Date.now()}`,
+ label: `${SUBTYPE_LABELS[subType]} ${cfg.items.length + 1}`,
+ visible: true,
+ subType,
+ dataSource: { ...DEFAULT_DATASOURCE },
+ visibility: { ...DEFAULT_VISIBILITY },
+ };
+ updateConfig({ items: [...cfg.items, newItem] });
+ },
+ [cfg.items, updateConfig]
+ );
+
+ // 아이템 업데이트
+ const updateItem = useCallback(
+ (index: number, item: DashboardItem) => {
+ const newItems = [...cfg.items];
+ newItems[index] = item;
+ updateConfig({ items: newItems });
+ },
+ [cfg.items, updateConfig]
+ );
+
+ // 아이템 삭제 (모든 페이지의 셀 배정도 해제)
+ const deleteItem = useCallback(
+ (index: number) => {
+ const deletedId = cfg.items[index].id;
+ const newItems = cfg.items.filter((_, i) => i !== index);
+
+ const newPages = cfg.pages?.map((page) => ({
+ ...page,
+ gridCells: page.gridCells.map((cell) =>
+ cell.itemId === deletedId ? { ...cell, itemId: null } : cell
+ ),
+ }));
+
+ updateConfig({ items: newItems, pages: newPages });
+ },
+ [cfg.items, cfg.pages, updateConfig]
+ );
+
+ // 아이템 순서 변경
+ const moveItem = useCallback(
+ (from: number, to: number) => {
+ if (to < 0 || to >= cfg.items.length) return;
+ const newItems = [...cfg.items];
+ const [moved] = newItems.splice(from, 1);
+ newItems.splice(to, 0, moved);
+ updateConfig({ items: newItems });
+ },
+ [cfg.items, updateConfig]
+ );
+
+ return (
+
+ {/* 탭 헤더 */}
+
+ {(
+ [
+ ["basic", "기본 설정"],
+ ["items", "아이템"],
+ ["pages", "페이지"],
+ ] as const
+ ).map(([key, label]) => (
+ setActiveTab(key)}
+ className={`rounded-t px-2 py-1 text-xs font-medium transition-colors ${
+ activeTab === key
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-muted"
+ }`}
+ >
+ {label}
+
+ ))}
+
+
+ {/* ===== 기본 설정 탭 ===== */}
+ {activeTab === "basic" && (
+
+ {/* 표시 모드 */}
+
+ 표시 모드
+
+ updateConfig({
+ displayMode: val as DashboardDisplayMode,
+ })
+ }
+ >
+
+
+
+
+ {Object.entries(DISPLAY_MODE_LABELS).map(([val, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ {/* 자동 슬라이드 설정 */}
+ {cfg.displayMode === "auto-slide" && (
+
+ )}
+
+ {/* 인디케이터 */}
+
+ 페이지 인디케이터
+
+ updateConfig({ showIndicator: checked })
+ }
+ />
+
+
+ {/* 간격 */}
+
+ 아이템 간격 (px)
+
+ updateConfig({ gap: parseInt(e.target.value) || 8 })
+ }
+ className="h-8 text-xs"
+ min={0}
+ />
+
+
+ {/* 배경색 */}
+
+ 배경색
+
+ updateConfig({
+ backgroundColor: e.target.value || undefined,
+ })
+ }
+ placeholder="예: #f0f0f0"
+ className="h-8 text-xs"
+ />
+
+
+ )}
+
+ {/* ===== 아이템 관리 탭 ===== */}
+ {activeTab === "items" && (
+
+ {cfg.items.map((item, index) => (
+
updateItem(index, updated)}
+ onDelete={() => deleteItem(index)}
+ onMoveUp={() => moveItem(index, index - 1)}
+ onMoveDown={() => moveItem(index, index + 1)}
+ isFirst={index === 0}
+ isLast={index === cfg.items.length - 1}
+ />
+ ))}
+
+
+ {(Object.keys(SUBTYPE_LABELS) as DashboardSubType[]).map(
+ (subType) => (
+
addItem(subType)}
+ >
+
+ {SUBTYPE_LABELS[subType]}
+
+ )
+ )}
+
+
+ )}
+
+ {/* ===== 페이지 탭 ===== */}
+ {activeTab === "pages" && (
+
+ {/* 페이지 목록 */}
+ {(cfg.pages ?? []).map((page, pageIdx) => (
+
{
+ const newPages = [...(cfg.pages ?? [])];
+ newPages[pageIdx] = updatedPage;
+ updateConfig({ pages: newPages });
+ }}
+ onDelete={() => {
+ const newPages = (cfg.pages ?? []).filter(
+ (_, i) => i !== pageIdx
+ );
+ updateConfig({ pages: newPages });
+ }}
+ onPreview={() => {
+ if (props.onPreviewPage) {
+ // 같은 페이지를 다시 누르면 미리보기 해제
+ props.onPreviewPage(props.previewPageIndex === pageIdx ? -1 : pageIdx);
+ }
+ }}
+ isPreviewing={props.previewPageIndex === pageIdx}
+ onUpdateItem={(updatedItem) => {
+ const newItems = cfg.items.map((i) =>
+ i.id === updatedItem.id ? updatedItem : i
+ );
+ updateConfig({ items: newItems });
+ // 스타일 변경 시 자동으로 해당 페이지 미리보기 활성화
+ if (props.onPreviewPage && props.previewPageIndex !== pageIdx) {
+ props.onPreviewPage(pageIdx);
+ }
+ }}
+ />
+ ))}
+
+ {/* 페이지 추가 버튼 */}
+ {
+ const newPage: DashboardPage = {
+ id: `page-${Date.now()}`,
+ label: `페이지 ${(cfg.pages?.length ?? 0) + 1}`,
+ gridColumns: 2,
+ gridRows: 2,
+ gridCells: generateDefaultCells(2, 2),
+ };
+ updateConfig({ pages: [...(cfg.pages ?? []), newPage] });
+ }}
+ >
+
+ 페이지 추가
+
+
+ {(cfg.pages?.length ?? 0) === 0 && (
+
+ 페이지를 추가하면 각 페이지에 독립적인 그리드 레이아웃을
+ 설정할 수 있습니다.
+
+ 페이지가 없으면 아이템이 하나씩 슬라이드됩니다.
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx
new file mode 100644
index 00000000..2c8b7643
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+/**
+ * pop-dashboard 디자이너 미리보기 컴포넌트
+ *
+ * 실제 데이터 없이 더미 레이아웃으로 미리보기 표시
+ * 디자이너가 설정 변경 시 즉시 미리보기 확인 가능
+ */
+
+import React from "react";
+import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react";
+import type { PopDashboardConfig, DashboardSubType } from "../types";
+import { migrateConfig } from "./PopDashboardComponent";
+
+// ===== 서브타입별 아이콘 매핑 =====
+
+const SUBTYPE_ICONS: Record = {
+ "kpi-card": ,
+ chart: ,
+ gauge: ,
+ "stat-card": ,
+};
+
+const SUBTYPE_LABELS: Record = {
+ "kpi-card": "KPI",
+ chart: "차트",
+ gauge: "게이지",
+ "stat-card": "통계",
+};
+
+// ===== 모드 라벨 =====
+
+const MODE_LABELS: Record = {
+ arrows: "좌우 버튼",
+ "auto-slide": "자동 슬라이드",
+ scroll: "스크롤",
+};
+
+// ===== 더미 아이템 프리뷰 =====
+
+function DummyItemPreview({
+ subType,
+ label,
+}: {
+ subType: DashboardSubType;
+ label: string;
+}) {
+ return (
+
+
+ {SUBTYPE_ICONS[subType]}
+
+
+ {label || SUBTYPE_LABELS[subType]}
+
+
+ );
+}
+
+// ===== 메인 미리보기 =====
+
+export function PopDashboardPreviewComponent({
+ config,
+}: {
+ config?: PopDashboardConfig;
+}) {
+ // config가 빈 객체 {} 또는 items가 없는 경우 방어
+ if (!config || !Array.isArray(config.items) || !config.items.length) {
+ return (
+
+
+ 대시보드
+
+ );
+ }
+
+ const visibleItems = config.items.filter((i) => i.visible);
+
+ // 마이그레이션 적용
+ const migrated = migrateConfig(config as unknown as Record);
+ const pages = migrated.pages ?? [];
+ const hasPages = pages.length > 0;
+
+ return (
+
+ {/* 모드 + 페이지 뱃지 */}
+
+
+ {MODE_LABELS[migrated.displayMode] ?? migrated.displayMode}
+
+ {hasPages && (
+
+ {pages.length}페이지
+
+ )}
+
+ {visibleItems.length}개
+
+
+
+ {/* 미리보기 */}
+
+ {hasPages ? (
+ // 첫 번째 페이지 그리드 미리보기
+
+ {pages[0].gridCells.length > 0
+ ? pages[0].gridCells.map((cell) => {
+ const item = visibleItems.find(
+ (i) => i.id === cell.itemId
+ );
+ return (
+
+ {item ? (
+
+ ) : (
+
+ )}
+
+ );
+ })
+ : visibleItems.slice(0, 4).map((item) => (
+
+ ))}
+
+ ) : (
+ // 페이지 미설정: 첫 번째 아이템만 크게 표시
+
+ {visibleItems[0] && (
+
+ )}
+ {visibleItems.length > 1 && (
+
+ +{visibleItems.length - 1}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/index.tsx b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx
new file mode 100644
index 00000000..58cdf6e2
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+/**
+ * pop-dashboard 컴포넌트 레지스트리 등록 진입점
+ *
+ * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨
+ */
+
+import { PopComponentRegistry } from "../../PopComponentRegistry";
+import { PopDashboardComponent } from "./PopDashboardComponent";
+import { PopDashboardConfigPanel } from "./PopDashboardConfig";
+import { PopDashboardPreviewComponent } from "./PopDashboardPreview";
+
+// 레지스트리 등록
+PopComponentRegistry.registerComponent({
+ id: "pop-dashboard",
+ name: "대시보드",
+ description: "여러 집계 아이템을 묶어서 다양한 방식으로 보여줌",
+ category: "display",
+ icon: "BarChart3",
+ component: PopDashboardComponent,
+ configPanel: PopDashboardConfigPanel,
+ preview: PopDashboardPreviewComponent,
+ defaultProps: {
+ items: [],
+ pages: [],
+ displayMode: "arrows",
+ autoSlideInterval: 5,
+ autoSlideResumeDelay: 3,
+ showIndicator: true,
+ gap: 8,
+ },
+ touchOptimized: true,
+ supportedDevices: ["mobile", "tablet"],
+});
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx
new file mode 100644
index 00000000..fc828925
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+/**
+ * 차트 서브타입 컴포넌트
+ *
+ * Recharts 기반 막대/원형/라인 차트
+ * 컨테이너 크기가 너무 작으면 "차트 표시 불가" 메시지
+ */
+
+import React from "react";
+import {
+ BarChart,
+ Bar,
+ PieChart,
+ Pie,
+ Cell,
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+ CartesianGrid,
+} from "recharts";
+import type { DashboardItem } from "../../types";
+import { TEXT_ALIGN_CLASSES } from "../../types";
+import { abbreviateNumber } from "../utils/formula";
+
+// ===== Props =====
+
+export interface ChartItemProps {
+ item: DashboardItem;
+ /** 차트에 표시할 데이터 행 */
+ rows: Record[];
+ /** 컨테이너 너비 (px) - 최소 크기 판단용 */
+ containerWidth: number;
+}
+
+// ===== 기본 색상 팔레트 =====
+
+const DEFAULT_COLORS = [
+ "#6366f1", // indigo
+ "#8b5cf6", // violet
+ "#06b6d4", // cyan
+ "#10b981", // emerald
+ "#f59e0b", // amber
+ "#ef4444", // rose
+ "#ec4899", // pink
+ "#14b8a6", // teal
+];
+
+// ===== 최소 표시 크기 =====
+
+const MIN_CHART_WIDTH = 120;
+
+// ===== 메인 컴포넌트 =====
+
+export function ChartItemComponent({
+ item,
+ rows,
+ containerWidth,
+}: ChartItemProps) {
+ const { chartConfig, visibility, itemStyle } = item;
+ const chartType = chartConfig?.chartType ?? "bar";
+ const colors = chartConfig?.colors?.length
+ ? chartConfig.colors
+ : DEFAULT_COLORS;
+ const xKey = chartConfig?.xAxisColumn ?? "name";
+ const yKey = chartConfig?.yAxisColumn ?? "value";
+
+ // 라벨 정렬만 사용자 설정
+ const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
+
+ // 컨테이너가 너무 작으면 메시지 표시
+ if (containerWidth < MIN_CHART_WIDTH) {
+ return (
+
+ 차트
+
+ );
+ }
+
+ // 데이터 없음
+ if (!rows.length) {
+ return (
+
+ 데이터 없음
+
+ );
+ }
+
+ // X축 라벨이 긴지 판정 (7자 이상이면 대각선)
+ const hasLongLabels = rows.some(
+ (r) => String(r[xKey] ?? "").length > 7
+ );
+ const xAxisTickProps = hasLongLabels
+ ? { fontSize: 10, angle: -45, textAnchor: "end" as const }
+ : { fontSize: 10 };
+ // 긴 라벨이 있으면 하단 여백 확보
+ const chartMargin = hasLongLabels
+ ? { top: 5, right: 10, bottom: 40, left: 10 }
+ : { top: 5, right: 10, bottom: 5, left: 10 };
+
+ return (
+
+ {/* 라벨 - 사용자 정렬 적용 */}
+ {visibility.showLabel && (
+
+ {item.label}
+
+ )}
+
+ {/* 차트 영역 */}
+
+
+ {chartType === "bar" ? (
+ []} margin={chartMargin}>
+
+
+ abbreviateNumber(v)}
+ />
+
+
+
+ ) : chartType === "line" ? (
+ []} margin={chartMargin}>
+
+
+ abbreviateNumber(v)}
+ />
+
+ 250}
+ />
+
+ ) : (
+ /* pie - 카테고리명 + 값 라벨 표시 */
+
+ []}
+ dataKey={yKey}
+ nameKey={xKey}
+ cx="50%"
+ cy="50%"
+ outerRadius={containerWidth > 400 ? "70%" : "80%"}
+ label={
+ containerWidth > 250
+ ? ({ name, value, percent }: { name: string; value: number; percent: number }) =>
+ `${name} ${abbreviateNumber(value)} (${(percent * 100).toFixed(0)}%)`
+ : false
+ }
+ labelLine={containerWidth > 250}
+ >
+ {rows.map((_, index) => (
+ |
+ ))}
+
+ [abbreviateNumber(value), name]}
+ />
+ {containerWidth > 300 && (
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx
new file mode 100644
index 00000000..f76c4832
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+/**
+ * 게이지 서브타입 컴포넌트
+ *
+ * SVG 기반 반원형 게이지 (외부 라이브러리 불필요)
+ * min/max/target/current 표시, 달성률 구간별 색상
+ */
+
+import React from "react";
+import type { DashboardItem, FontSize } from "../../types";
+import { TEXT_ALIGN_CLASSES } from "../../types";
+import { abbreviateNumber } from "../utils/formula";
+
+/** FontSize -> SVG 직접 fontSize(px) 매핑 */
+const SVG_FONT_SIZE_MAP: Record = {
+ xs: 14,
+ sm: 18,
+ base: 24,
+ lg: 32,
+ xl: 48,
+};
+
+// ===== Props =====
+
+export interface GaugeItemProps {
+ item: DashboardItem;
+ data: number | null;
+ /** 동적 목표값 (targetDataSource로 조회된 값) */
+ targetValue?: number | null;
+}
+
+// ===== 게이지 색상 판정 =====
+
+function getGaugeColor(
+ percentage: number,
+ ranges?: { min: number; max: number; color: string }[]
+): string {
+ if (ranges?.length) {
+ const match = ranges.find((r) => percentage >= r.min && percentage <= r.max);
+ if (match) return match.color;
+ }
+ // 기본 색상 (달성률 기준)
+ if (percentage >= 80) return "#10b981"; // emerald
+ if (percentage >= 50) return "#f59e0b"; // amber
+ return "#ef4444"; // rose
+}
+
+// ===== 메인 컴포넌트 =====
+
+export function GaugeItemComponent({
+ item,
+ data,
+ targetValue,
+}: GaugeItemProps) {
+ const { visibility, gaugeConfig, itemStyle } = item;
+ const current = data ?? 0;
+ const min = gaugeConfig?.min ?? 0;
+ const max = gaugeConfig?.max ?? 100;
+ const target = targetValue ?? gaugeConfig?.target ?? max;
+
+ // 라벨 정렬만 사용자 설정
+ const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
+
+ // SVG 내부 텍스트는 기본값 고정 (사용자 설정 연동 제거)
+ const svgValueFontSize = SVG_FONT_SIZE_MAP["base"]; // 24
+ const svgSubFontSize = SVG_FONT_SIZE_MAP["xs"]; // 14
+
+ // 달성률 계산 (0~100)
+ const range = max - min;
+ const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0;
+ const gaugeColor = getGaugeColor(percentage, gaugeConfig?.colorRanges);
+
+ // SVG 반원 게이지 수치
+ const cx = 100;
+ const cy = 90;
+ const radius = 70;
+ // 반원: 180도 -> percentage에 비례한 각도
+ const startAngle = Math.PI; // 180도 (왼쪽)
+ const endAngle = Math.PI - (percentage / 100) * Math.PI; // 0도 (오른쪽) 방향
+
+ const startX = cx + radius * Math.cos(startAngle);
+ const startY = cy - radius * Math.sin(startAngle);
+ const endX = cx + radius * Math.cos(endAngle);
+ const endY = cy - radius * Math.sin(endAngle);
+ const largeArcFlag = percentage > 50 ? 1 : 0;
+
+ return (
+
+ {/* 라벨 - 사용자 정렬 적용 */}
+ {visibility.showLabel && (
+
+ {item.label}
+
+ )}
+
+ {/* 게이지 SVG - 높이/너비 모두 반응형 */}
+
+
+ {/* 배경 반원 (회색) */}
+
+
+ {/* 값 반원 (색상) */}
+ {percentage > 0 && (
+
+ )}
+
+ {/* 중앙 텍스트 */}
+ {visibility.showValue && (
+
+ {abbreviateNumber(current)}
+
+ )}
+
+ {/* 퍼센트 */}
+
+ {percentage.toFixed(1)}%
+
+
+
+
+ {/* 목표값 */}
+ {visibility.showTarget && (
+
+ 목표: {abbreviateNumber(target)}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx
new file mode 100644
index 00000000..9e309a7b
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+/**
+ * KPI 카드 서브타입 컴포넌트
+ *
+ * 큰 숫자 + 단위 + 증감 표시
+ * CSS Container Query로 반응형 내부 콘텐츠
+ */
+
+import React from "react";
+import type { DashboardItem } from "../../types";
+import { TEXT_ALIGN_CLASSES } from "../../types";
+import { abbreviateNumber } from "../utils/formula";
+
+// ===== Props =====
+
+export interface KpiCardProps {
+ item: DashboardItem;
+ data: number | null;
+ /** 이전 기간 대비 증감 퍼센트 (선택) */
+ trendValue?: number | null;
+ /** 수식 결과 표시 문자열 (formula가 있을 때) */
+ formulaDisplay?: string | null;
+}
+
+// ===== 증감 표시 =====
+
+function TrendIndicator({ value }: { value: number }) {
+ const isPositive = value > 0;
+ const isZero = value === 0;
+ const color = isPositive
+ ? "text-emerald-600"
+ : isZero
+ ? "text-muted-foreground"
+ : "text-rose-600";
+ const arrow = isPositive ? "↑" : isZero ? "→" : "↓";
+
+ return (
+
+ {arrow}
+ {Math.abs(value).toFixed(1)}%
+
+ );
+}
+
+// ===== 색상 구간 판정 =====
+
+function getColorForValue(
+ value: number,
+ ranges?: { min: number; max: number; color: string }[]
+): string | undefined {
+ if (!ranges?.length) return undefined;
+ const match = ranges.find((r) => value >= r.min && value <= r.max);
+ return match?.color;
+}
+
+// ===== 메인 컴포넌트 =====
+
+export function KpiCardComponent({
+ item,
+ data,
+ trendValue,
+ formulaDisplay,
+}: KpiCardProps) {
+ const { visibility, kpiConfig, itemStyle } = item;
+ const displayValue = data ?? 0;
+ const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
+
+ // 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동
+ const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
+
+ return (
+
+ {/* 라벨 - 사용자 정렬 적용 */}
+ {visibility.showLabel && (
+
+ {item.label}
+
+ )}
+
+ {/* 메인 값 - @container 반응형 */}
+ {visibility.showValue && (
+
+
+ {formulaDisplay ?? abbreviateNumber(displayValue)}
+
+
+ {/* 단위 */}
+ {visibility.showUnit && kpiConfig?.unit && (
+
+ {kpiConfig.unit}
+
+ )}
+
+ )}
+
+ {/* 증감율 */}
+ {visibility.showTrend && trendValue != null && (
+
+ )}
+
+ {/* 보조 라벨 (수식 표시 등) */}
+ {visibility.showSubLabel && formulaDisplay && (
+
+ {item.formula?.values.map((v) => v.label).join(" / ")}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx
new file mode 100644
index 00000000..eeae4dcb
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+/**
+ * 통계 카드 서브타입 컴포넌트
+ *
+ * 상태별 건수 표시 (대기/진행/완료 등)
+ * 각 카테고리별 색상 및 링크 지원
+ */
+
+import React from "react";
+import type { DashboardItem } from "../../types";
+import { TEXT_ALIGN_CLASSES } from "../../types";
+import { abbreviateNumber } from "../utils/formula";
+
+// ===== Props =====
+
+export interface StatCardProps {
+ item: DashboardItem;
+ /** 카테고리별 건수 맵 (카테고리 label -> 건수) */
+ categoryData: Record;
+}
+
+// ===== 기본 색상 팔레트 =====
+
+const DEFAULT_STAT_COLORS = [
+ "#6366f1", // indigo
+ "#f59e0b", // amber
+ "#10b981", // emerald
+ "#ef4444", // rose
+ "#8b5cf6", // violet
+];
+
+// ===== 메인 컴포넌트 =====
+
+export function StatCardComponent({ item, categoryData }: StatCardProps) {
+ const { visibility, statConfig, itemStyle } = item;
+ const categories = statConfig?.categories ?? [];
+ const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
+
+ // 라벨 정렬만 사용자 설정
+ const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
+
+ return (
+
+ {/* 라벨 - 사용자 정렬 적용 */}
+ {visibility.showLabel && (
+
+ {item.label}
+
+ )}
+
+ {/* 총합 - @container 반응형 */}
+ {visibility.showValue && (
+
+ {abbreviateNumber(total)}
+
+ )}
+
+ {/* 카테고리별 건수 */}
+
+ {categories.map((cat, index) => {
+ const count = categoryData[cat.label] ?? 0;
+ const color =
+ cat.color ?? DEFAULT_STAT_COLORS[index % DEFAULT_STAT_COLORS.length];
+
+ return (
+
+ {/* 색상 점 */}
+
+ {/* 라벨 + 건수 */}
+
+ {cat.label}
+
+
+ {abbreviateNumber(count)}
+
+
+ );
+ })}
+
+
+ {/* 보조 라벨 (단위 등) */}
+ {visibility.showSubLabel && (
+
+ {visibility.showUnit && item.kpiConfig?.unit
+ ? `단위: ${item.kpiConfig.unit}`
+ : ""}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx
new file mode 100644
index 00000000..d91e6ea2
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+/**
+ * 좌우 버튼 표시 모드
+ *
+ * 화살표 버튼으로 아이템을 한 장씩 넘기는 모드
+ * 터치 최적화: 최소 44x44px 터치 영역
+ */
+
+import React, { useState, useCallback } from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+// ===== Props =====
+
+export interface ArrowsModeProps {
+ /** 총 아이템 수 */
+ itemCount: number;
+ /** 페이지 인디케이터 표시 여부 */
+ showIndicator?: boolean;
+ /** 현재 인덱스에 해당하는 아이템 렌더링 */
+ renderItem: (index: number) => React.ReactNode;
+}
+
+// ===== 메인 컴포넌트 =====
+
+export function ArrowsModeComponent({
+ itemCount,
+ showIndicator = true,
+ renderItem,
+}: ArrowsModeProps) {
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const goToPrev = useCallback(() => {
+ setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
+ }, [itemCount]);
+
+ const goToNext = useCallback(() => {
+ setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
+ }, [itemCount]);
+
+ if (itemCount === 0) {
+ return (
+
+ 아이템 없음
+
+ );
+ }
+
+ return (
+
+ {/* 아이템 (전체 영역 사용) */}
+
+ {renderItem(currentIndex)}
+
+
+ {/* 좌우 화살표 (콘텐츠 위에 겹침) */}
+ {itemCount > 1 && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ {/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */}
+ {showIndicator && itemCount > 1 && (
+
+ {Array.from({ length: itemCount }).map((_, i) => (
+ setCurrentIndex(i)}
+ className={`h-1.5 rounded-full transition-all ${
+ i === currentIndex
+ ? "w-4 bg-primary"
+ : "w-1.5 bg-muted-foreground/30"
+ }`}
+ aria-label={`${i + 1}번째 아이템`}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx
new file mode 100644
index 00000000..cb67255b
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+/**
+ * 자동 슬라이드 표시 모드
+ *
+ * 전광판처럼 자동 전환, 터치 시 멈춤, 일정 시간 후 재개
+ * 컴포넌트 unmount 시 타이머 정리 필수
+ */
+
+import React, { useState, useEffect, useRef, useCallback } from "react";
+
+// ===== Props =====
+
+export interface AutoSlideModeProps {
+ /** 총 아이템 수 */
+ itemCount: number;
+ /** 자동 전환 간격 (초, 기본 5) */
+ interval?: number;
+ /** 터치 후 자동 재개까지 대기 시간 (초, 기본 3) */
+ resumeDelay?: number;
+ /** 페이지 인디케이터 표시 여부 */
+ showIndicator?: boolean;
+ /** 현재 인덱스에 해당하는 아이템 렌더링 */
+ renderItem: (index: number) => React.ReactNode;
+}
+
+// ===== 메인 컴포넌트 =====
+
+export function AutoSlideModeComponent({
+ itemCount,
+ interval = 5,
+ resumeDelay = 3,
+ showIndicator = true,
+ renderItem,
+}: AutoSlideModeProps) {
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [isPaused, setIsPaused] = useState(false);
+ const intervalRef = useRef | null>(null);
+ const resumeTimerRef = useRef | null>(null);
+
+ // 타이머 정리 함수
+ const clearTimers = useCallback(() => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ if (resumeTimerRef.current) {
+ clearTimeout(resumeTimerRef.current);
+ resumeTimerRef.current = null;
+ }
+ }, []);
+
+ // 자동 슬라이드 시작
+ const startAutoSlide = useCallback(() => {
+ clearTimers();
+ if (itemCount <= 1) return;
+
+ intervalRef.current = setInterval(() => {
+ setCurrentIndex((prev) => (prev + 1) % itemCount);
+ }, interval * 1000);
+ }, [itemCount, interval, clearTimers]);
+
+ // 터치/클릭으로 일시 정지
+ const handlePause = useCallback(() => {
+ setIsPaused(true);
+ clearTimers();
+
+ // resumeDelay 후 자동 재개
+ resumeTimerRef.current = setTimeout(() => {
+ setIsPaused(false);
+ startAutoSlide();
+ }, resumeDelay * 1000);
+ }, [resumeDelay, clearTimers, startAutoSlide]);
+
+ // 마운트 시 자동 슬라이드 시작, unmount 시 정리
+ useEffect(() => {
+ if (!isPaused) {
+ startAutoSlide();
+ }
+ return clearTimers;
+ }, [isPaused, startAutoSlide, clearTimers]);
+
+ if (itemCount === 0) {
+ return (
+
+ 아이템 없음
+
+ );
+ }
+
+ return (
+
+ {/* 콘텐츠 (슬라이드 애니메이션) */}
+
+
+ {Array.from({ length: itemCount }).map((_, i) => (
+
+ {renderItem(i)}
+
+ ))}
+
+
+
+ {/* 인디케이터 (콘텐츠 하단에 겹침) */}
+ {showIndicator && itemCount > 1 && (
+
+ {isPaused && (
+
+ 일시정지
+
+ )}
+ {Array.from({ length: itemCount }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx
new file mode 100644
index 00000000..5e339fc5
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+/**
+ * 그리드 표시 모드
+ *
+ * CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영)
+ * 각 셀에 @container 적용하여 내부 아이템 반응형
+ *
+ * 반응형 자동 조정:
+ * - containerWidth에 따라 열 수를 자동 축소
+ * - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦
+ * - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단
+ */
+
+import React, { useMemo } from "react";
+import type { DashboardCell } from "../../types";
+
+// ===== 상수 =====
+
+/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
+const MIN_CELL_WIDTH = 80;
+
+// ===== Props =====
+
+export interface GridModeProps {
+ /** 셀 배치 정보 */
+ cells: DashboardCell[];
+ /** 설정된 열 수 (최대값) */
+ columns: number;
+ /** 설정된 행 수 */
+ rows: number;
+ /** 아이템 간 간격 (px) */
+ gap?: number;
+ /** 컨테이너 너비 (px, 반응형 자동 조정용) */
+ containerWidth?: number;
+ /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */
+ renderItem: (itemId: string) => React.ReactNode;
+}
+
+// ===== 반응형 열 수 계산 =====
+
+/**
+ * 컨테이너 너비에 맞는 실제 열 수를 계산
+ *
+ * 설정된 columns가 최대값이고, 공간이 부족하면 축소.
+ * gap도 고려하여 계산.
+ *
+ * 예: columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160
+ * 사용 가능 너비 = 400 - (3-1)*8 = 384
+ * 셀당 너비 = 384/3 = 128 < 160 -> 열 축소
+ * columns=2: 사용 가능 = 400 - (2-1)*8 = 392, 셀당 = 196 >= 160 -> OK
+ */
+function computeResponsiveColumns(
+ configColumns: number,
+ containerWidth: number,
+ gap: number
+): number {
+ if (containerWidth <= 0) return configColumns;
+
+ for (let cols = configColumns; cols >= 1; cols--) {
+ const totalGap = (cols - 1) * gap;
+ const cellWidth = (containerWidth - totalGap) / cols;
+ if (cellWidth >= MIN_CELL_WIDTH) return cols;
+ }
+
+ return 1;
+}
+
+/**
+ * 열 수가 줄어들 때 셀 배치를 자동 재배열
+ *
+ * 원본 gridColumn/gridRow를 actualColumns에 맞게 재매핑
+ * 셀이 원래 위치를 유지하려고 시도하되, 넘치면 아래로 이동
+ */
+function remapCells(
+ cells: DashboardCell[],
+ configColumns: number,
+ actualColumns: number,
+ configRows: number
+): { remappedCells: DashboardCell[]; actualRows: number } {
+ // 열 수가 같으면 원본 그대로
+ if (actualColumns >= configColumns) {
+ return { remappedCells: cells, actualRows: configRows };
+ }
+
+ // 셀을 원래 위치 순서대로 정렬 (행 우선)
+ const sorted = [...cells].sort((a, b) => {
+ const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0;
+ const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0;
+ if (aRow !== bRow) return aRow - bRow;
+ const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0;
+ const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0;
+ return aCol - bCol;
+ });
+
+ // 순서대로 새 위치에 배치
+ let maxRow = 0;
+ const remapped = sorted.map((cell, index) => {
+ const newCol = (index % actualColumns) + 1;
+ const newRow = Math.floor(index / actualColumns) + 1;
+ maxRow = Math.max(maxRow, newRow);
+ return {
+ ...cell,
+ gridColumn: `${newCol} / ${newCol + 1}`,
+ gridRow: `${newRow} / ${newRow + 1}`,
+ };
+ });
+
+ return { remappedCells: remapped, actualRows: maxRow };
+}
+
+// ===== 메인 컴포넌트 =====
+
+export function GridModeComponent({
+ cells,
+ columns,
+ rows,
+ gap = 8,
+ containerWidth,
+ renderItem,
+}: GridModeProps) {
+ // 반응형 열 수 계산
+ const actualColumns = useMemo(
+ () =>
+ containerWidth
+ ? computeResponsiveColumns(columns, containerWidth, gap)
+ : columns,
+ [columns, containerWidth, gap]
+ );
+
+ // 열 수가 줄었으면 셀 재배열
+ const { remappedCells, actualRows } = useMemo(
+ () => remapCells(cells, columns, actualColumns, rows),
+ [cells, columns, actualColumns, rows]
+ );
+
+ if (!remappedCells.length) {
+ return (
+
+ 셀 없음
+
+ );
+ }
+
+ return (
+
+ {remappedCells.map((cell) => (
+
+ {cell.itemId ? (
+ renderItem(cell.itemId)
+ ) : (
+
+
+ 빈 셀
+
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx
new file mode 100644
index 00000000..300b637d
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+/**
+ * 스크롤 표시 모드
+ *
+ * 가로 스크롤 + CSS scroll-snap으로 아이템 단위 스냅
+ * 터치 스와이프 네이티브 지원
+ */
+
+import React, { useRef, useState, useEffect, useCallback } from "react";
+
+// ===== Props =====
+
+export interface ScrollModeProps {
+ /** 총 아이템 수 */
+ itemCount: number;
+ /** 페이지 인디케이터 표시 여부 */
+ showIndicator?: boolean;
+ /** 현재 인덱스에 해당하는 아이템 렌더링 */
+ renderItem: (index: number) => React.ReactNode;
+}
+
+// ===== 메인 컴포넌트 =====
+
+export function ScrollModeComponent({
+ itemCount,
+ showIndicator = true,
+ renderItem,
+}: ScrollModeProps) {
+ const scrollRef = useRef(null);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ // 스크롤 위치로 현재 인덱스 계산
+ const handleScroll = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el || !el.clientWidth) return;
+ const index = Math.round(el.scrollLeft / el.clientWidth);
+ setActiveIndex(Math.min(index, itemCount - 1));
+ }, [itemCount]);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ el.addEventListener("scroll", handleScroll, { passive: true });
+ return () => el.removeEventListener("scroll", handleScroll);
+ }, [handleScroll]);
+
+ if (itemCount === 0) {
+ return (
+
+ 아이템 없음
+
+ );
+ }
+
+ return (
+
+ {/* 스크롤 영역 */}
+
+ {Array.from({ length: itemCount }).map((_, i) => (
+
+ {renderItem(i)}
+
+ ))}
+
+
+ {/* 페이지 인디케이터 */}
+ {showIndicator && itemCount > 1 && (
+
+ {Array.from({ length: itemCount }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts
new file mode 100644
index 00000000..c2baaa55
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts
@@ -0,0 +1,367 @@
+/**
+ * pop-dashboard 데이터 페처
+ *
+ * @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정
+ * 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체.
+ *
+ * 보안:
+ * - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리
+ * - 멀티테넌시: autoFilter 자동 전달
+ * - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용
+ */
+
+import { apiClient } from "@/lib/api/client";
+import { dashboardApi } from "@/lib/api/dashboard";
+import { dataApi } from "@/lib/api/data";
+import { tableManagementApi } from "@/lib/api/tableManagement";
+import type { TableInfo } from "@/lib/api/tableManagement";
+import type { DataSourceConfig, DataSourceFilter } from "../../types";
+
+// ===== 타입 re-export =====
+
+export type { TableInfo };
+
+// ===== 반환 타입 =====
+
+export interface AggregatedResult {
+ value: number;
+ rows?: Record[];
+ error?: string;
+}
+
+export interface ColumnInfo {
+ name: string;
+ type: string;
+ udtName: string;
+}
+
+// ===== SQL 값 이스케이프 =====
+
+/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */
+function escapeSQL(value: unknown): string {
+ if (value === null || value === undefined) return "NULL";
+ if (typeof value === "number") return String(value);
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
+ // 문자열: 작은따옴표 이스케이프
+ const str = String(value).replace(/'/g, "''");
+ return `'${str}'`;
+}
+
+// ===== 설정 완료 여부 검증 =====
+
+/**
+ * DataSourceConfig의 필수값이 모두 채워졌는지 검증
+ * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
+ * SQL을 생성하지 않도록 사전 차단
+ *
+ * @returns null이면 유효, 문자열이면 미완료 사유
+ */
+function validateDataSourceConfig(config: DataSourceConfig): string | null {
+ // 테이블명 필수
+ if (!config.tableName || !config.tableName.trim()) {
+ return "테이블이 선택되지 않았습니다";
+ }
+
+ // 집계 함수가 설정되었으면 대상 컬럼도 필수
+ // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
+ if (config.aggregation) {
+ const aggType = config.aggregation.type?.toLowerCase();
+ const aggCol = config.aggregation.column?.trim();
+ if (aggType !== "count" && !aggCol) {
+ return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
+ }
+ }
+
+ // 조인이 있으면 조인 조건 필수
+ if (config.joins?.length) {
+ for (const join of config.joins) {
+ if (!join.targetTable?.trim()) {
+ return "조인 대상 테이블이 선택되지 않았습니다";
+ }
+ if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
+ return "조인 조건 컬럼이 설정되지 않았습니다";
+ }
+ }
+ }
+
+ return null;
+}
+
+// ===== 필터 조건 SQL 생성 =====
+
+/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
+function buildWhereClause(filters: DataSourceFilter[]): string {
+ // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
+ const validFilters = filters.filter((f) => f.column?.trim());
+ if (!validFilters.length) return "";
+
+ const conditions = validFilters.map((f) => {
+ const col = sanitizeIdentifier(f.column);
+
+ switch (f.operator) {
+ case "between": {
+ const arr = Array.isArray(f.value) ? f.value : [f.value, f.value];
+ return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`;
+ }
+ case "in": {
+ const arr = Array.isArray(f.value) ? f.value : [f.value];
+ const vals = arr.map(escapeSQL).join(", ");
+ return `${col} IN (${vals})`;
+ }
+ case "like":
+ return `${col} LIKE ${escapeSQL(f.value)}`;
+ default:
+ return `${col} ${f.operator} ${escapeSQL(f.value)}`;
+ }
+ });
+
+ return `WHERE ${conditions.join(" AND ")}`;
+}
+
+// ===== 식별자 검증 (테이블명, 컬럼명) =====
+
+/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */
+function sanitizeIdentifier(name: string): string {
+ // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용
+ return name.replace(/[^a-zA-Z0-9_.]/g, "");
+}
+
+// ===== 집계 SQL 빌더 =====
+
+/**
+ * DataSourceConfig를 SELECT SQL로 변환
+ *
+ * @param config - 데이터 소스 설정
+ * @returns SQL 문자열
+ */
+export function buildAggregationSQL(config: DataSourceConfig): string {
+ const tableName = sanitizeIdentifier(config.tableName);
+
+ // SELECT 절
+ let selectClause: string;
+ if (config.aggregation) {
+ const aggType = config.aggregation.type.toUpperCase();
+ const aggCol = config.aggregation.column?.trim()
+ ? sanitizeIdentifier(config.aggregation.column)
+ : "";
+
+ // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
+ if (!aggCol) {
+ selectClause = aggType === "COUNT"
+ ? "COUNT(*) as value"
+ : `${aggType}(${tableName}.*) as value`;
+ } else {
+ selectClause = `${aggType}(${aggCol}) as value`;
+ }
+
+ // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
+ if (config.aggregation.groupBy?.length) {
+ const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", ");
+ selectClause = `${groupCols}, ${selectClause}`;
+ }
+ } else {
+ selectClause = "*";
+ }
+
+ // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
+ let fromClause = tableName;
+ if (config.joins?.length) {
+ for (const join of config.joins) {
+ // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
+ if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
+ continue;
+ }
+ const joinTable = sanitizeIdentifier(join.targetTable);
+ const joinType = join.joinType.toUpperCase();
+ const srcCol = sanitizeIdentifier(join.on.sourceColumn);
+ const tgtCol = sanitizeIdentifier(join.on.targetColumn);
+ fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`;
+ }
+ }
+
+ // WHERE 절
+ const whereClause = config.filters?.length
+ ? buildWhereClause(config.filters)
+ : "";
+
+ // GROUP BY 절
+ let groupByClause = "";
+ if (config.aggregation?.groupBy?.length) {
+ groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`;
+ }
+
+ // ORDER BY 절
+ let orderByClause = "";
+ if (config.sort?.length) {
+ const sortCols = config.sort
+ .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`)
+ .join(", ");
+ orderByClause = `ORDER BY ${sortCols}`;
+ }
+
+ // LIMIT 절
+ const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : "";
+
+ return [
+ `SELECT ${selectClause}`,
+ `FROM ${fromClause}`,
+ whereClause,
+ groupByClause,
+ orderByClause,
+ limitClause,
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
+
+// ===== 메인 데이터 페처 =====
+
+/**
+ * DataSourceConfig 기반으로 데이터를 조회하여 집계 결과 반환
+ *
+ * API 선택 전략:
+ * 1. 집계 있음 -> buildAggregationSQL -> dashboardApi.executeQuery()
+ * 2. 조인만 있음 (2개 테이블) -> dataApi.getJoinedData() (추후 지원)
+ * 3. 단순 조회 -> dataApi.getTableData()
+ *
+ * @INFRA-EXTRACT: useDataSource 완성 후 이 함수를 훅으로 교체
+ */
+export async function fetchAggregatedData(
+ config: DataSourceConfig
+): Promise {
+ try {
+ // 설정 완료 여부 검증 (미완료 시 SQL 전송 차단)
+ const validationError = validateDataSourceConfig(config);
+ if (validationError) {
+ return { value: 0, rows: [], error: validationError };
+ }
+
+ // 집계 또는 조인이 있으면 SQL 직접 실행
+ if (config.aggregation || (config.joins && config.joins.length > 0)) {
+ const sql = buildAggregationSQL(config);
+
+ // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
+ let queryResult: { columns: string[]; rows: any[] };
+ try {
+ // 1차: apiClient (axios 기반, 인증/세션 안정적)
+ const response = await apiClient.post("/dashboards/execute-query", { query: sql });
+ if (response.data?.success && response.data?.data) {
+ queryResult = response.data.data;
+ } else {
+ throw new Error(response.data?.message || "쿼리 실행 실패");
+ }
+ } catch {
+ // 2차: dashboardApi (fetch 기반, 폴백)
+ queryResult = await dashboardApi.executeQuery(sql);
+ }
+
+ if (queryResult.rows.length === 0) {
+ return { value: 0, rows: [] };
+ }
+
+ // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨
+ // Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리
+ const processedRows = queryResult.rows.map((row: Record) => {
+ const converted: Record = { ...row };
+ for (const key of Object.keys(converted)) {
+ const val = converted[key];
+ if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
+ converted[key] = Number(val);
+ }
+ }
+ return converted;
+ });
+
+ // 첫 번째 행의 value 컬럼 추출
+ const firstRow = processedRows[0];
+ const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0));
+
+ return {
+ value: Number.isFinite(numericValue) ? numericValue : 0,
+ rows: processedRows,
+ };
+ }
+
+ // 단순 조회
+ const tableResult = await dataApi.getTableData(config.tableName, {
+ page: 1,
+ size: config.limit ?? 100,
+ sortBy: config.sort?.[0]?.column,
+ sortOrder: config.sort?.[0]?.direction,
+ filters: config.filters?.reduce(
+ (acc, f) => {
+ acc[f.column] = f.value;
+ return acc;
+ },
+ {} as Record
+ ),
+ });
+
+ // 단순 조회 시에는 행 수를 value로 사용
+ return {
+ value: tableResult.total ?? tableResult.data.length,
+ rows: tableResult.data,
+ };
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "데이터 조회 실패";
+ return { value: 0, error: message };
+ }
+}
+
+// ===== 설정 패널용 헬퍼 =====
+
+/**
+ * 테이블 목록 조회 (설정 패널 드롭다운용)
+ * dashboardApi.getTableSchema()로 특정 테이블의 스키마를 가져오되,
+ * 테이블 목록은 별도로 필요하므로 간단히 반환
+ */
+export async function fetchTableColumns(
+ tableName: string
+): Promise {
+ // 1차: tableManagementApi (apiClient/axios 기반, 인증 안정적)
+ try {
+ const response = await tableManagementApi.getTableSchema(tableName);
+ if (response.success && response.data) {
+ const cols = Array.isArray(response.data) ? response.data : [];
+ if (cols.length > 0) {
+ return cols.map((col: any) => ({
+ name: col.columnName || col.column_name || col.name,
+ type: col.dataType || col.data_type || col.type || "unknown",
+ udtName: col.dbType || col.udt_name || col.udtName || "unknown",
+ }));
+ }
+ }
+ } catch {
+ // tableManagementApi 실패 시 dashboardApi로 폴백
+ }
+
+ // 2차: dashboardApi (fetch 기반, 폴백)
+ try {
+ const schema = await dashboardApi.getTableSchema(tableName);
+ return schema.columns.map((col) => ({
+ name: col.name,
+ type: col.type,
+ udtName: col.udtName,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * 테이블 목록 조회 (설정 패널 Combobox용)
+ * tableManagementApi.getTableList() 래핑
+ *
+ * @INFRA-EXTRACT: useDataSource 완성 후 교체 예정
+ */
+export async function fetchTableList(): Promise {
+ try {
+ const response = await tableManagementApi.getTableList();
+ if (response.success && response.data) {
+ return response.data;
+ }
+ return [];
+ } catch {
+ return [];
+ }
+}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts
new file mode 100644
index 00000000..2ed27a98
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts
@@ -0,0 +1,259 @@
+/**
+ * pop-dashboard 수식 파싱 및 평가 유틸리티
+ *
+ * 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현.
+ */
+
+import type { FormulaConfig, FormulaDisplayFormat } from "../../types";
+
+// ===== 토큰 타입 =====
+
+type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen";
+
+interface Token {
+ type: TokenType;
+ value: string;
+}
+
+// ===== 토크나이저 =====
+
+/** 수식 문자열을 토큰 배열로 분리 */
+function tokenize(expression: string): Token[] {
+ const tokens: Token[] = [];
+ let i = 0;
+ const expr = expression.replace(/\s+/g, "");
+
+ while (i < expr.length) {
+ const ch = expr[i];
+
+ // 숫자 (정수, 소수)
+ if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) {
+ let num = "";
+ while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) {
+ num += expr[i];
+ i++;
+ }
+ tokens.push({ type: "number", value: num });
+ continue;
+ }
+
+ // 변수 (A, B, C 등 알파벳)
+ if (/[A-Za-z]/.test(ch)) {
+ let varName = "";
+ while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) {
+ varName += expr[i];
+ i++;
+ }
+ tokens.push({ type: "variable", value: varName });
+ continue;
+ }
+
+ // 연산자
+ if ("+-*/".includes(ch)) {
+ tokens.push({ type: "operator", value: ch });
+ i++;
+ continue;
+ }
+
+ // 괄호
+ if (ch === "(") {
+ tokens.push({ type: "lparen", value: "(" });
+ i++;
+ continue;
+ }
+ if (ch === ")") {
+ tokens.push({ type: "rparen", value: ")" });
+ i++;
+ continue;
+ }
+
+ // 알 수 없는 문자는 건너뜀
+ i++;
+ }
+
+ return tokens;
+}
+
+// ===== 재귀 하강 파서 =====
+
+/**
+ * 사칙연산 수식을 안전하게 평가 (재귀 하강 파서)
+ *
+ * 문법:
+ * expr = term (('+' | '-') term)*
+ * term = factor (('*' | '/') factor)*
+ * factor = NUMBER | VARIABLE | '(' expr ')'
+ *
+ * @param expression - 수식 문자열 (예: "A / B * 100")
+ * @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
+ * @returns 계산 결과 (0으로 나누기 시 0 반환)
+ */
+export function evaluateFormula(
+ expression: string,
+ values: Record
+): number {
+ const tokens = tokenize(expression);
+ let pos = 0;
+
+ function peek(): Token | undefined {
+ return tokens[pos];
+ }
+
+ function consume(): Token {
+ return tokens[pos++];
+ }
+
+ // factor = NUMBER | VARIABLE | '(' expr ')'
+ function parseFactor(): number {
+ const token = peek();
+ if (!token) return 0;
+
+ if (token.type === "number") {
+ consume();
+ return parseFloat(token.value);
+ }
+
+ if (token.type === "variable") {
+ consume();
+ return values[token.value] ?? 0;
+ }
+
+ if (token.type === "lparen") {
+ consume(); // '(' 소비
+ const result = parseExpr();
+ if (peek()?.type === "rparen") {
+ consume(); // ')' 소비
+ }
+ return result;
+ }
+
+ // 예상치 못한 토큰
+ consume();
+ return 0;
+ }
+
+ // term = factor (('*' | '/') factor)*
+ function parseTerm(): number {
+ let result = parseFactor();
+ while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) {
+ const op = consume().value;
+ const right = parseFactor();
+ if (op === "*") {
+ result *= right;
+ } else {
+ // 0으로 나누기 방지
+ result = right === 0 ? 0 : result / right;
+ }
+ }
+ return result;
+ }
+
+ // expr = term (('+' | '-') term)*
+ function parseExpr(): number {
+ let result = parseTerm();
+ while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) {
+ const op = consume().value;
+ const right = parseTerm();
+ result = op === "+" ? result + right : result - right;
+ }
+ return result;
+ }
+
+ const result = parseExpr();
+ return Number.isFinite(result) ? result : 0;
+}
+
+/**
+ * 수식 결과를 displayFormat에 맞게 포맷팅
+ *
+ * @param config - 수식 설정
+ * @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
+ * @returns 포맷된 문자열
+ */
+export function formatFormulaResult(
+ config: FormulaConfig,
+ values: Record
+): string {
+ const formatMap: Record string> = {
+ value: () => {
+ const result = evaluateFormula(config.expression, values);
+ return formatNumber(result);
+ },
+ fraction: () => {
+ // "1,234 / 5,678" 형태
+ const ids = config.values.map((v) => v.id);
+ if (ids.length >= 2) {
+ return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`;
+ }
+ return formatNumber(evaluateFormula(config.expression, values));
+ },
+ percent: () => {
+ const result = evaluateFormula(config.expression, values);
+ return `${(result * 100).toFixed(1)}%`;
+ },
+ ratio: () => {
+ // "1,234 : 5,678" 형태
+ const ids = config.values.map((v) => v.id);
+ if (ids.length >= 2) {
+ return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`;
+ }
+ return formatNumber(evaluateFormula(config.expression, values));
+ },
+ };
+
+ return formatMap[config.displayFormat]();
+}
+
+/**
+ * 수식에 사용된 변수 ID가 모두 존재하는지 검증
+ *
+ * @param expression - 수식 문자열
+ * @param availableIds - 사용 가능한 변수 ID 배열
+ * @returns 유효 여부
+ */
+export function validateExpression(
+ expression: string,
+ availableIds: string[]
+): boolean {
+ const tokens = tokenize(expression);
+ const usedVars = tokens
+ .filter((t) => t.type === "variable")
+ .map((t) => t.value);
+
+ return usedVars.every((v) => availableIds.includes(v));
+}
+
+/**
+ * 큰 숫자 축약 (Container Query 축소 시 사용)
+ *
+ * 1234 -> "1,234"
+ * 12345 -> "1.2만"
+ * 1234567 -> "123.5만"
+ * 123456789 -> "1.2억"
+ */
+export function abbreviateNumber(value: number): string {
+ const abs = Math.abs(value);
+ const sign = value < 0 ? "-" : "";
+
+ if (abs >= 100_000_000) {
+ return `${sign}${(abs / 100_000_000).toFixed(1)}억`;
+ }
+ if (abs >= 10_000) {
+ return `${sign}${(abs / 10_000).toFixed(1)}만`;
+ }
+ return `${sign}${formatNumber(abs)}`;
+}
+
+// ===== 내부 헬퍼 =====
+
+/** 숫자를 천 단위 콤마 포맷 */
+function formatNumber(value: number): string {
+ if (Number.isInteger(value)) {
+ return value.toLocaleString("ko-KR");
+ }
+ // 소수점 이하 최대 2자리
+ return value.toLocaleString("ko-KR", {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ });
+}
diff --git a/frontend/lib/registry/pop-components/pop-icon.tsx b/frontend/lib/registry/pop-components/pop-icon.tsx
new file mode 100644
index 00000000..3ecb9d49
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-icon.tsx
@@ -0,0 +1,990 @@
+"use client";
+
+import React, { useState, useRef } from "react";
+import { useRouter } from "next/navigation";
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
+import { GridMode } from "@/components/pop/designer/types/pop-layout";
+import {
+ Home,
+ ArrowLeft,
+ Settings,
+ Search,
+ Plus,
+ Check,
+ X as XIcon,
+ Edit,
+ Trash2,
+ RefreshCw,
+ type LucideIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+const LUCIDE_ICON_MAP: Record = {
+ Home, ArrowLeft, Settings, Search, Plus, Check, X: XIcon,
+ Edit, Trash2, RefreshCw,
+};
+
+// ========================================
+// 타입 정의
+// ========================================
+export type IconType = "quick" | "emoji" | "image";
+export type IconSizeMode = "auto" | "fixed";
+export type LabelPosition = "bottom" | "right" | "none";
+export type NavigateMode = "none" | "screen" | "url" | "back";
+
+export interface IconSizeByMode {
+ mobile_portrait: number;
+ mobile_landscape: number;
+ tablet_portrait: number;
+ tablet_landscape: number;
+}
+
+export interface GradientConfig {
+ from: string;
+ to: string;
+ direction?: "to-b" | "to-r" | "to-br";
+}
+
+export interface ImageConfig {
+ fileObjid?: number;
+ imageUrl?: string;
+ // 임시 저장용 (브라우저 캐시)
+ tempDataUrl?: string;
+ tempFileName?: string;
+}
+
+export interface PopIconAction {
+ type: "navigate";
+ navigate: {
+ mode: NavigateMode;
+ screenId?: string;
+ url?: string;
+ };
+}
+
+export interface QuickSelectItem {
+ type: "lucide" | "emoji";
+ value: string;
+ label: string;
+ gradient: GradientConfig;
+}
+
+export interface PopIconConfig {
+ iconType: IconType;
+ // 빠른 선택용
+ quickSelectType?: "lucide" | "emoji";
+ quickSelectValue?: string;
+ // 이미지용
+ imageConfig?: ImageConfig;
+ imageScale?: number;
+ // 공통
+ label?: string;
+ labelPosition?: LabelPosition;
+ labelColor?: string;
+ labelFontSize?: number;
+ backgroundColor?: string;
+ gradient?: GradientConfig;
+ borderRadiusPercent?: number;
+ sizeMode: IconSizeMode;
+ fixedSize?: number;
+ sizeByMode?: IconSizeByMode;
+ action: PopIconAction;
+}
+
+// ========================================
+// 상수
+// ========================================
+export const ICON_TYPE_LABELS: Record = {
+ quick: "빠른 선택",
+ emoji: "이모지 직접 입력",
+ image: "이미지",
+};
+
+// 섹션 구분선 컴포넌트
+function SectionDivider({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+export const NAVIGATE_MODE_LABELS: Record = {
+ none: "없음",
+ screen: "POP 화면",
+ url: "외부 URL",
+ back: "뒤로가기",
+};
+
+export const LABEL_POSITION_LABELS: Record = {
+ bottom: "아래",
+ right: "오른쪽",
+ none: "없음",
+};
+
+export const DEFAULT_ICON_SIZE_BY_MODE: IconSizeByMode = {
+ mobile_portrait: 48,
+ mobile_landscape: 56,
+ tablet_portrait: 64,
+ tablet_landscape: 72,
+};
+
+// 빠른 선택 아이템 (Lucide 10개 + 이모지)
+export const QUICK_SELECT_ITEMS: QuickSelectItem[] = [
+ // 기본 아이콘 (Lucide) - 10개
+ { type: "lucide", value: "Home", label: "홈", gradient: { from: "#0984e3", to: "#0774c4" } },
+ { type: "lucide", value: "ArrowLeft", label: "뒤로", gradient: { from: "#636e72", to: "#525d61" } },
+ { type: "lucide", value: "Settings", label: "설정", gradient: { from: "#636e72", to: "#525d61" } },
+ { type: "lucide", value: "Search", label: "검색", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
+ { type: "lucide", value: "Plus", label: "추가", gradient: { from: "#00b894", to: "#00a86b" } },
+ { type: "lucide", value: "Check", label: "확인", gradient: { from: "#00b894", to: "#00a86b" } },
+ { type: "lucide", value: "X", label: "취소", gradient: { from: "#e17055", to: "#d35845" } },
+ { type: "lucide", value: "Edit", label: "수정", gradient: { from: "#0984e3", to: "#0774c4" } },
+ { type: "lucide", value: "Trash2", label: "삭제", gradient: { from: "#e17055", to: "#d35845" } },
+ { type: "lucide", value: "RefreshCw", label: "새로고침", gradient: { from: "#4ecdc4", to: "#26a69a" } },
+ // 이모지
+ { type: "emoji", value: "📋", label: "작업지시", gradient: { from: "#ff6b6b", to: "#ee5a5a" } },
+ { type: "emoji", value: "📊", label: "생산실적", gradient: { from: "#4ecdc4", to: "#26a69a" } },
+ { type: "emoji", value: "📦", label: "입고", gradient: { from: "#00b894", to: "#00a86b" } },
+ { type: "emoji", value: "🚚", label: "출고", gradient: { from: "#0984e3", to: "#0774c4" } },
+ { type: "emoji", value: "📈", label: "재고현황", gradient: { from: "#6c5ce7", to: "#5b4cdb" } },
+ { type: "emoji", value: "🔍", label: "품질검사", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
+ { type: "emoji", value: "⚠️", label: "불량관리", gradient: { from: "#e17055", to: "#d35845" } },
+ { type: "emoji", value: "⚙️", label: "설비관리", gradient: { from: "#636e72", to: "#525d61" } },
+ { type: "emoji", value: "🦺", label: "안전관리", gradient: { from: "#f39c12", to: "#e67e22" } },
+ { type: "emoji", value: "🏭", label: "외주", gradient: { from: "#6c5ce7", to: "#5b4cdb" } },
+ { type: "emoji", value: "↩️", label: "반품", gradient: { from: "#e17055", to: "#d35845" } },
+ { type: "emoji", value: "🤝", label: "사급자재", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
+ { type: "emoji", value: "🔄", label: "교환", gradient: { from: "#4ecdc4", to: "#26a69a" } },
+ { type: "emoji", value: "📍", label: "재고이동", gradient: { from: "#4ecdc4", to: "#26a69a" } },
+];
+
+// ========================================
+// 헬퍼 함수
+// ========================================
+function getIconSizeForMode(config: PopIconConfig | undefined, gridMode: GridMode): number {
+ if (!config) return DEFAULT_ICON_SIZE_BY_MODE[gridMode];
+ if (config.sizeMode === "fixed" && config.fixedSize) {
+ return config.fixedSize;
+ }
+ const sizes = config.sizeByMode || DEFAULT_ICON_SIZE_BY_MODE;
+ return sizes[gridMode];
+}
+
+function buildGradientStyle(gradient?: GradientConfig): React.CSSProperties {
+ if (!gradient) return {};
+ const direction = gradient.direction || "to-b";
+ const dirMap: Record = {
+ "to-b": "to bottom",
+ "to-r": "to right",
+ "to-br": "to bottom right"
+ };
+ return {
+ background: `linear-gradient(${dirMap[direction]}, ${gradient.from}, ${gradient.to})`,
+ };
+}
+
+function getImageUrl(imageConfig?: ImageConfig): string | undefined {
+ if (!imageConfig) return undefined;
+ // 임시 저장된 이미지 우선
+ if (imageConfig.tempDataUrl) return imageConfig.tempDataUrl;
+ if (imageConfig.fileObjid) return `/api/files/preview/${imageConfig.fileObjid}`;
+ return imageConfig.imageUrl;
+}
+
+// Lucide 아이콘 동적 렌더링
+function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) {
+ const IconComponent = LUCIDE_ICON_MAP[name];
+ if (!IconComponent) return null;
+ return ;
+}
+
+// screenId에서 실제 ID만 추출 (URL이 입력된 경우 처리)
+function extractScreenId(input: string): string {
+ if (!input) return "";
+
+ // URL 형태인 경우 (/pop/screens/123 또는 http://...pop/screens/123)
+ const urlMatch = input.match(/\/pop\/screens\/(\d+)/);
+ if (urlMatch) {
+ return urlMatch[1];
+ }
+
+ // http:// 또는 https://로 시작하는 경우 (다른 URL 형태)
+ if (input.startsWith("http://") || input.startsWith("https://")) {
+ // URL에서 마지막 숫자 부분 추출 시도
+ const lastNumberMatch = input.match(/\/(\d+)\/?$/);
+ if (lastNumberMatch) {
+ return lastNumberMatch[1];
+ }
+ }
+
+ // 숫자만 있는 경우 그대로 반환
+ if (/^\d+$/.test(input.trim())) {
+ return input.trim();
+ }
+
+ // 그 외의 경우 원본 반환 (에러 처리는 호출부에서)
+ return input;
+}
+
+// ========================================
+// 메인 컴포넌트
+// ========================================
+interface PopIconComponentProps {
+ config?: PopIconConfig;
+ label?: string;
+ isDesignMode?: boolean;
+ gridMode?: GridMode;
+}
+
+export function PopIconComponent({
+ config,
+ label,
+ isDesignMode,
+ gridMode = "tablet_landscape"
+}: PopIconComponentProps) {
+ const router = useRouter();
+ const iconType = config?.iconType || "quick";
+ const iconSize = getIconSizeForMode(config, gridMode);
+
+ // 디자인 모드 확인 다이얼로그 상태
+ const [showNavigateDialog, setShowNavigateDialog] = useState(false);
+ const [pendingNavigate, setPendingNavigate] = useState<{ mode: string; target: string } | null>(null);
+
+ // 클릭 핸들러
+ const handleClick = () => {
+ const navigate = config?.action?.navigate;
+ if (!navigate || navigate.mode === "none") return;
+
+ // 디자인 모드: 확인 다이얼로그 표시
+ if (isDesignMode) {
+ if (navigate.mode === "screen") {
+ if (!navigate.screenId) {
+ toast.error("화면 ID가 설정되지 않았습니다.");
+ return;
+ }
+ const cleanScreenId = extractScreenId(navigate.screenId);
+ setPendingNavigate({ mode: "screen", target: cleanScreenId });
+ setShowNavigateDialog(true);
+ } else if (navigate.mode === "url") {
+ if (!navigate.url) {
+ toast.error("URL이 설정되지 않았습니다.");
+ return;
+ }
+ setPendingNavigate({ mode: "url", target: navigate.url });
+ setShowNavigateDialog(true);
+ } else if (navigate.mode === "back") {
+ toast.warning("뒤로가기는 실제 화면에서 테스트해주세요.");
+ }
+ return;
+ }
+
+ // 실제 모드: 직접 실행
+ switch (navigate.mode) {
+ case "screen":
+ if (navigate.screenId) {
+ const cleanScreenId = extractScreenId(navigate.screenId);
+ window.location.href = `/pop/screens/${cleanScreenId}`;
+ }
+ break;
+ case "url":
+ if (navigate.url) window.location.href = navigate.url;
+ break;
+ case "back":
+ router.back();
+ break;
+ }
+ };
+
+ // 확인 후 이동 실행
+ const handleConfirmNavigate = () => {
+ if (!pendingNavigate) return;
+
+ if (pendingNavigate.mode === "screen") {
+ const targetUrl = `/pop/screens/${pendingNavigate.target}`;
+ console.log("[PopIcon] 화면 이동:", { target: pendingNavigate.target, url: targetUrl });
+ window.location.href = targetUrl;
+ } else if (pendingNavigate.mode === "url") {
+ console.log("[PopIcon] URL 이동:", pendingNavigate.target);
+ window.location.href = pendingNavigate.target;
+ }
+
+ setShowNavigateDialog(false);
+ setPendingNavigate(null);
+ };
+
+ // 배경 스타일 (이미지 타입일 때는 배경 없음)
+ const backgroundStyle: React.CSSProperties = iconType === "image"
+ ? { backgroundColor: "transparent" }
+ : config?.gradient
+ ? buildGradientStyle(config.gradient)
+ : { backgroundColor: config?.backgroundColor || "#e0e0e0" };
+
+ // 테두리 반경 (0% = 사각형, 100% = 원형)
+ const radiusPercent = config?.borderRadiusPercent ?? 20;
+ const borderRadius = iconType === "image" ? "0%" : `${radiusPercent / 2}%`;
+
+ // 라벨 위치에 따른 레이아웃
+ const isLabelRight = config?.labelPosition === "right";
+ const showLabel = config?.labelPosition !== "none" && (config?.label || label);
+
+ // 아이콘 렌더링
+ const renderIcon = () => {
+ // 빠른 선택
+ if (iconType === "quick") {
+ if (config?.quickSelectType === "lucide" && config?.quickSelectValue) {
+ return (
+
+ );
+ } else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) {
+ return {config.quickSelectValue} ;
+ }
+ // 기본값
+ return 📦 ;
+ }
+
+ // 이모지 직접 입력
+ if (iconType === "emoji") {
+ if (config?.quickSelectValue) {
+ return {config.quickSelectValue} ;
+ }
+ return 📦 ;
+ }
+
+ // 이미지 (배경 없이 이미지만 표시)
+ if (iconType === "image" && config?.imageConfig) {
+ const scale = config?.imageScale || 100;
+ return (
+
+ );
+ }
+
+ return 📦 ;
+ };
+
+ return (
+
+ {/* 아이콘 컨테이너 */}
+
+ {renderIcon()}
+
+
+ {/* 라벨 */}
+ {showLabel && (
+
+ {config?.label || label}
+
+ )}
+
+ {/* 디자인 모드 네비게이션 확인 다이얼로그 */}
+
+
+
+ 페이지 이동 확인
+
+ {pendingNavigate?.mode === "screen"
+ ? "POP 화면으로 이동합니다."
+ : "외부 URL로 이동합니다."
+ }
+
+
+ ※ 저장하지 않은 변경사항은 사라집니다.
+
+
+
+
+
+ 확인 후 이동
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowNavigateDialog(false);
+ }}
+ >
+ 취소
+
+
+
+
+
+ );
+}
+
+// ========================================
+// 설정 패널
+// ========================================
+interface PopIconConfigPanelProps {
+ config: PopIconConfig;
+ onUpdate: (config: PopIconConfig) => void;
+}
+
+export function PopIconConfigPanel({ config, onUpdate }: PopIconConfigPanelProps) {
+ const iconType = config?.iconType || "quick";
+
+ return (
+
+ {/* 아이콘 타입 선택 */}
+
+
+ onUpdate({ ...config, iconType: v as IconType })}
+ >
+
+
+
+
+ {Object.entries(ICON_TYPE_LABELS).map(([key, label]) => (
+ {label}
+ ))}
+
+
+
+
+ {/* 타입별 설정 */}
+ {iconType === "quick" &&
}
+ {iconType === "emoji" &&
}
+ {iconType === "image" &&
}
+
+ {/* 라벨 설정 */}
+
+
+
+ {/* 스타일 설정 (이미지 타입 제외) */}
+ {iconType !== "image" && (
+ <>
+
+
+ >
+ )}
+
+ {/* 액션 설정 */}
+
+
+
+ );
+}
+
+// 빠른 선택 그리드
+function QuickSelectGrid({ config, onUpdate }: PopIconConfigPanelProps) {
+ return (
+
+
빠른 선택
+
+ {QUICK_SELECT_ITEMS.map((item, idx) => (
+ onUpdate({
+ ...config,
+ quickSelectType: item.type,
+ quickSelectValue: item.value,
+ label: item.label,
+ gradient: item.gradient,
+ })}
+ className={cn(
+ "p-2 rounded border hover:border-primary transition-colors flex items-center justify-center",
+ config?.quickSelectValue === item.value && "border-primary bg-primary/10"
+ )}
+ title={item.label}
+ >
+ {item.type === "lucide" ? (
+
+ ) : (
+ {item.value}
+ )}
+
+ ))}
+
+
+ );
+}
+
+// 이모지 직접 입력
+function EmojiInput({ config, onUpdate }: PopIconConfigPanelProps) {
+ const [customEmoji, setCustomEmoji] = useState(config?.quickSelectValue || "");
+
+ const handleEmojiChange = (value: string) => {
+ setCustomEmoji(value);
+ // 이모지가 입력되면 바로 적용
+ if (value.trim()) {
+ onUpdate({
+ ...config,
+ quickSelectType: "emoji",
+ quickSelectValue: value,
+ gradient: config?.gradient || { from: "#6c5ce7", to: "#5b4cdb" },
+ });
+ }
+ };
+
+ return (
+
+
이모지 입력
+
handleEmojiChange(e.target.value)}
+ placeholder="이모지를 입력하세요 (예: 📦, 🚀)"
+ className="h-8 text-xs"
+ maxLength={4}
+ />
+
+ Windows: Win + . / Mac: Ctrl + Cmd + Space
+
+
+ {/* 배경 그라디언트 설정 */}
+
+
+ {/* 미리보기 */}
+ {customEmoji && (
+
+ {customEmoji}
+
+ )}
+
+ );
+}
+
+// 이미지 업로드
+function ImageUpload({ config, onUpdate }: PopIconConfigPanelProps) {
+ const [error, setError] = useState(null);
+ const fileInputRef = useRef(null);
+
+ // 파일 선택 시 브라우저 캐시에 임시 저장 (DB 업로드 X)
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setError(null);
+
+ // 이미지 파일 검증
+ if (!file.type.startsWith("image/")) {
+ setError("이미지 파일만 선택할 수 있습니다.");
+ return;
+ }
+
+ // 파일 크기 제한 (5MB)
+ if (file.size > 5 * 1024 * 1024) {
+ setError("파일 크기는 5MB 이하여야 합니다.");
+ return;
+ }
+
+ // FileReader로 Base64 변환 (브라우저 캐시)
+ const reader = new FileReader();
+ reader.onload = () => {
+ onUpdate({
+ ...config,
+ imageConfig: {
+ tempDataUrl: reader.result as string,
+ tempFileName: file.name,
+ // 기존 DB 파일 정보 제거
+ fileObjid: undefined,
+ imageUrl: undefined,
+ },
+ });
+ };
+ reader.onerror = () => {
+ setError("파일을 읽는 중 오류가 발생했습니다.");
+ };
+ reader.readAsDataURL(file);
+
+ // input 초기화 (같은 파일 다시 선택 가능하도록)
+ e.target.value = "";
+ };
+
+ // 이미지 삭제
+ const handleDelete = () => {
+ onUpdate({
+ ...config,
+ imageConfig: undefined,
+ imageScale: undefined,
+ });
+ };
+
+ // 미리보기 URL 가져오기
+ const getPreviewUrl = (): string | undefined => {
+ if (config?.imageConfig?.tempDataUrl) return config.imageConfig.tempDataUrl;
+ if (config?.imageConfig?.fileObjid) return `/api/files/preview/${config.imageConfig.fileObjid}`;
+ return config?.imageConfig?.imageUrl;
+ };
+
+ const previewUrl = getPreviewUrl();
+ const hasImage = !!previewUrl;
+ const isTemp = !!config?.imageConfig?.tempDataUrl;
+
+ return (
+
+
이미지
+
+ {/* 파일 선택 + 삭제 버튼 */}
+
+
+ fileInputRef.current?.click()}
+ className={hasImage ? "flex-1 h-8 text-xs" : "w-full h-8 text-xs"}
+ >
+ 파일 선택
+
+ {hasImage && (
+
+ 삭제
+
+ )}
+
+
+ {/* 에러 메시지 */}
+ {error &&
{error}
}
+
+ {/* 또는 URL 직접 입력 */}
+
onUpdate({
+ ...config,
+ imageConfig: {
+ imageUrl: e.target.value,
+ // URL 입력 시 임시 파일 제거
+ tempDataUrl: undefined,
+ tempFileName: undefined,
+ fileObjid: undefined,
+ }
+ })}
+ placeholder="또는 URL 직접 입력..."
+ className="h-8 text-xs"
+ disabled={isTemp}
+ />
+
+ {/* 현재 이미지 미리보기 + 크기 조절 */}
+ {hasImage && (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {isTemp && (
+
+ 임시
+
+ )}
+
+ {config?.imageConfig?.tempFileName && (
+
+ {config.imageConfig.tempFileName}
+
+ )}
+
이미지 크기: {config?.imageScale || 100}%
+
onUpdate({ ...config, imageScale: Number(e.target.value) })}
+ className="w-full"
+ />
+ {isTemp && (
+
+ ※ 화면 저장 시 서버에 업로드됩니다.
+
+ )}
+
+ )}
+
+ );
+}
+
+// 라벨 설정
+function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) {
+ return (
+
+ );
+}
+
+// 스타일 설정
+function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) {
+ return (
+
+
+ 모서리: {config?.borderRadiusPercent ?? 20}%
+
+ onUpdate({
+ ...config,
+ borderRadiusPercent: Number(e.target.value)
+ })}
+ className="w-full"
+ />
+
+ );
+}
+
+// 액션 설정
+function ActionSettings({ config, onUpdate }: PopIconConfigPanelProps) {
+ const navigate = config?.action?.navigate || { mode: "none" as NavigateMode };
+
+ return (
+
+ onUpdate({
+ ...config,
+ action: { type: "navigate", navigate: { ...navigate, mode: v as NavigateMode } }
+ })}
+ >
+
+
+
+
+ {Object.entries(NAVIGATE_MODE_LABELS).map(([key, label]) => (
+ {label}
+ ))}
+
+
+
+ {/* 없음이 아닐 때만 추가 설정 표시 */}
+ {navigate.mode !== "none" && (
+ <>
+ {navigate.mode === "screen" && (
+ onUpdate({
+ ...config,
+ action: { type: "navigate", navigate: { ...navigate, screenId: e.target.value } }
+ })}
+ placeholder="화면 ID"
+ className="h-8 text-xs mt-2"
+ />
+ )}
+ {navigate.mode === "url" && (
+ onUpdate({
+ ...config,
+ action: { type: "navigate", navigate: { ...navigate, url: e.target.value } }
+ })}
+ placeholder="https://..."
+ className="h-8 text-xs mt-2"
+ />
+ )}
+
+ {/* 테스트 버튼 */}
+ {
+ if (navigate.mode === "screen" && navigate.screenId) {
+ const cleanScreenId = extractScreenId(navigate.screenId);
+ window.open(`/pop/screens/${cleanScreenId}`, "_blank");
+ } else if (navigate.mode === "url" && navigate.url) {
+ window.open(navigate.url, "_blank");
+ } else if (navigate.mode === "back") {
+ alert("뒤로가기는 실제 화면에서 테스트해주세요.");
+ } else {
+ alert("먼저 액션을 설정해주세요.");
+ }
+ }}
+ >
+ 🧪 액션 테스트 (새 탭에서 열기)
+
+ >
+ )}
+
+ );
+}
+
+// ========================================
+// 미리보기 컴포넌트
+// ========================================
+function PopIconPreviewComponent({ config }: { config?: PopIconConfig }) {
+ return (
+
+ );
+}
+
+// ========================================
+// 레지스트리 등록
+// ========================================
+PopComponentRegistry.registerComponent({
+ id: "pop-icon",
+ name: "아이콘",
+ description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
+ category: "action",
+ icon: "MousePointer",
+ component: PopIconComponent,
+ configPanel: PopIconConfigPanel,
+ preview: PopIconPreviewComponent,
+ defaultProps: {
+ iconType: "quick",
+ quickSelectType: "emoji",
+ quickSelectValue: "📦",
+ label: "아이콘",
+ labelPosition: "bottom",
+ labelColor: "#000000",
+ labelFontSize: 12,
+ borderRadiusPercent: 20,
+ sizeMode: "auto",
+ action: { type: "navigate", navigate: { mode: "none" } },
+ } as PopIconConfig,
+ touchOptimized: true,
+ supportedDevices: ["mobile", "tablet"],
+});
diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx
new file mode 100644
index 00000000..380cc103
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx
@@ -0,0 +1,689 @@
+"use client";
+
+import { useState, useCallback, useEffect, useRef, useMemo } from "react";
+import { cn } from "@/lib/utils";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Switch } from "@/components/ui/switch";
+import { Search, ChevronRight, Loader2, X } from "lucide-react";
+import { usePopEvent } from "@/hooks/pop";
+import { dataApi } from "@/lib/api/data";
+import type {
+ PopSearchConfig,
+ DatePresetOption,
+ ModalSelectConfig,
+ ModalSearchMode,
+ ModalFilterTab,
+} from "./types";
+import {
+ DATE_PRESET_LABELS,
+ computeDateRange,
+ DEFAULT_SEARCH_CONFIG,
+ normalizeInputType,
+ MODAL_FILTER_TAB_LABELS,
+ getGroupKey,
+} from "./types";
+
+// ========================================
+// 메인 컴포넌트
+// ========================================
+
+interface PopSearchComponentProps {
+ config: PopSearchConfig;
+ label?: string;
+ screenId?: string;
+ componentId?: string;
+}
+
+const DEFAULT_CONFIG = DEFAULT_SEARCH_CONFIG;
+
+export function PopSearchComponent({
+ config: rawConfig,
+ label,
+ screenId,
+ componentId,
+}: PopSearchComponentProps) {
+ const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
+ const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
+ const [value, setValue] = useState(config.defaultValue ?? "");
+ const [modalDisplayText, setModalDisplayText] = useState("");
+ const [simpleModalOpen, setSimpleModalOpen] = useState(false);
+
+ const fieldKey = config.fieldName || componentId || "search";
+ const normalizedType = normalizeInputType(config.inputType as string);
+ const isModalType = normalizedType === "modal";
+
+ const emitFilterChanged = useCallback(
+ (newValue: unknown) => {
+ setValue(newValue);
+ setSharedData(`search_${fieldKey}`, newValue);
+
+ if (componentId) {
+ publish(`__comp_output__${componentId}__filter_value`, {
+ fieldName: fieldKey,
+ value: newValue,
+ });
+ }
+
+ publish("filter_changed", { [fieldKey]: newValue });
+ },
+ [fieldKey, publish, setSharedData, componentId]
+ );
+
+ useEffect(() => {
+ if (!componentId) return;
+ const unsub = subscribe(
+ `__comp_input__${componentId}__set_value`,
+ (payload: unknown) => {
+ const data = payload as { value?: unknown } | unknown;
+ const incoming = typeof data === "object" && data && "value" in data
+ ? (data as { value: unknown }).value
+ : data;
+ emitFilterChanged(incoming);
+ }
+ );
+ return unsub;
+ }, [componentId, subscribe, emitFilterChanged]);
+
+ const handleModalOpen = useCallback(() => {
+ if (!config.modalConfig) return;
+ setSimpleModalOpen(true);
+ }, [config.modalConfig]);
+
+ const handleSimpleModalSelect = useCallback(
+ (row: Record) => {
+ const mc = config.modalConfig;
+ const display = mc?.displayField ? String(row[mc.displayField] ?? "") : "";
+ const filterVal = mc?.valueField ? String(row[mc.valueField] ?? "") : "";
+
+ setModalDisplayText(display);
+ emitFilterChanged(filterVal);
+ setSimpleModalOpen(false);
+ },
+ [config.modalConfig, emitFilterChanged]
+ );
+
+ const showLabel = config.labelVisible !== false && !!config.labelText;
+
+ return (
+
+ {showLabel && (
+
+ {config.labelText}
+
+ )}
+
+
+
+
+ {isModalType && config.modalConfig && (
+
+ )}
+
+ );
+}
+
+// ========================================
+// 서브타입 분기 렌더러
+// ========================================
+
+interface InputRendererProps {
+ config: PopSearchConfig;
+ value: unknown;
+ onChange: (v: unknown) => void;
+ modalDisplayText?: string;
+ onModalOpen?: () => void;
+}
+
+function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
+ const normalized = normalizeInputType(config.inputType as string);
+ switch (normalized) {
+ case "text":
+ case "number":
+ return ;
+ case "select":
+ return ;
+ case "date-preset":
+ return ;
+ case "toggle":
+ return ;
+ case "modal":
+ return ;
+ default:
+ return ;
+ }
+}
+
+// ========================================
+// text 서브타입
+// ========================================
+
+function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
+ const [inputValue, setInputValue] = useState(value);
+ const debounceRef = useRef | null>(null);
+
+ useEffect(() => { setInputValue(value); }, [value]);
+ useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const v = e.target.value;
+ setInputValue(v);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ const ms = config.debounceMs ?? 500;
+ if (ms > 0) debounceRef.current = setTimeout(() => onChange(v), ms);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && config.triggerOnEnter !== false) {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ onChange(inputValue);
+ }
+ };
+
+ const isNumber = config.inputType === "number";
+
+ return (
+
+
+
+
+ );
+}
+
+// ========================================
+// select 서브타입
+// ========================================
+
+function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
+ return (
+ onChange(v)}>
+
+
+
+
+ {(config.options || []).map((opt) => (
+ {opt.label}
+ ))}
+
+
+ );
+}
+
+// ========================================
+// date-preset 서브타입
+// ========================================
+
+function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
+ const presets: DatePresetOption[] = config.datePresets || ["today", "this-week", "this-month"];
+ const currentPreset = value && typeof value === "object" && "preset" in (value as Record)
+ ? (value as Record).preset
+ : value;
+
+ const handleSelect = (preset: DatePresetOption) => {
+ if (preset === "custom") { onChange({ preset: "custom", from: "", to: "" }); return; }
+ const range = computeDateRange(preset);
+ if (range) onChange(range);
+ };
+
+ return (
+
+ {presets.map((preset) => (
+ handleSelect(preset)}>
+ {DATE_PRESET_LABELS[preset]}
+
+ ))}
+
+ );
+}
+
+// ========================================
+// toggle 서브타입
+// ========================================
+
+function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
+ return (
+
+ onChange(checked)} />
+ {value ? "ON" : "OFF"}
+
+ );
+}
+
+// ========================================
+// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
+// ========================================
+
+function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
+ return (
+ { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
+ >
+ {displayText || config.placeholder || "선택..."}
+
+
+ );
+}
+
+// ========================================
+// 미구현 서브타입 플레이스홀더
+// ========================================
+
+function PlaceholderInput({ inputType }: { inputType: string }) {
+ return (
+
+ {inputType} (후속 구현 예정)
+
+ );
+}
+
+// ========================================
+// 검색 방식별 문자열 매칭
+// ========================================
+
+function matchSearchMode(cellValue: string, term: string, mode: ModalSearchMode): boolean {
+ const lower = cellValue.toLowerCase();
+ const tLower = term.toLowerCase();
+ switch (mode) {
+ case "starts-with": return lower.startsWith(tLower);
+ case "equals": return lower === tLower;
+ case "contains":
+ default: return lower.includes(tLower);
+ }
+}
+
+// ========================================
+// 아이콘 색상 생성 (이름 기반 결정적 색상)
+// ========================================
+
+const ICON_COLORS = [
+ "bg-red-500", "bg-orange-500", "bg-amber-500", "bg-yellow-500",
+ "bg-lime-500", "bg-green-500", "bg-emerald-500", "bg-teal-500",
+ "bg-cyan-500", "bg-sky-500", "bg-blue-500", "bg-indigo-500",
+ "bg-violet-500", "bg-purple-500", "bg-fuchsia-500", "bg-pink-500",
+];
+
+function getIconColor(text: string): string {
+ let hash = 0;
+ for (let i = 0; i < text.length; i++) {
+ hash = text.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length];
+}
+
+// ========================================
+// 모달 Dialog: 테이블 / 아이콘 뷰 + 필터 탭
+// ========================================
+
+interface ModalDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ modalConfig: ModalSelectConfig;
+ title: string;
+ onSelect: (row: Record) => void;
+}
+
+function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: ModalDialogProps) {
+ const [searchText, setSearchText] = useState("");
+ const [allRows, setAllRows] = useState[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [activeFilterTab, setActiveFilterTab] = useState(null);
+ const debounceRef = useRef | null>(null);
+
+ const {
+ tableName,
+ displayColumns,
+ searchColumns,
+ searchMode = "contains",
+ filterTabs,
+ columnLabels,
+ displayStyle = "table",
+ displayField,
+ } = modalConfig;
+
+ const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
+ const hasFilterTabs = filterTabs && filterTabs.length > 0;
+
+ // 데이터 로드
+ const fetchData = useCallback(async () => {
+ if (!tableName) return;
+ setLoading(true);
+ try {
+ const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
+ setAllRows(result.data || []);
+ } catch {
+ setAllRows([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [tableName]);
+
+ useEffect(() => {
+ if (open) {
+ setSearchText("");
+ setActiveFilterTab(hasFilterTabs ? filterTabs![0] : null);
+ fetchData();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, fetchData, hasFilterTabs]);
+
+ // 필터링된 행 계산
+ const filteredRows = useMemo(() => {
+ let items = allRows;
+
+ // 텍스트 검색 필터
+ if (searchText.trim()) {
+ const cols = searchColumns && searchColumns.length > 0 ? searchColumns : colsToShow;
+ items = items.filter((row) =>
+ cols.some((col) => {
+ const val = row[col];
+ return val != null && matchSearchMode(String(val), searchText, searchMode);
+ })
+ );
+ }
+
+ // 필터 탭 (초성/알파벳) 적용
+ if (activeFilterTab && displayField) {
+ items = items.filter((row) => {
+ const val = row[displayField];
+ if (val == null) return false;
+ const key = getGroupKey(String(val), activeFilterTab);
+ return key !== "#";
+ });
+ }
+
+ return items;
+ }, [allRows, searchText, searchColumns, colsToShow, searchMode, activeFilterTab, displayField]);
+
+ // 그룹화 (필터 탭 활성화 시)
+ const groupedRows = useMemo(() => {
+ if (!activeFilterTab || !displayField) return null;
+
+ const groups = new Map[]>();
+ for (const row of filteredRows) {
+ const val = row[displayField];
+ const key = val != null ? getGroupKey(String(val), activeFilterTab) : "#";
+ if (key === "#") continue;
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key)!.push(row);
+ }
+
+ // 정렬
+ const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "ko"));
+ return sorted;
+ }, [filteredRows, activeFilterTab, displayField]);
+
+ const handleSearchChange = (e: React.ChangeEvent) => {
+ const v = e.target.value;
+ setSearchText(v);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {}, 300);
+ };
+
+ const getColLabel = (colName: string) => columnLabels?.[colName] || colName;
+
+ return (
+
+
+
+ {title} 선택
+ {/* 필터 탭 버튼 */}
+ {hasFilterTabs && (
+
+ {filterTabs!.map((tab) => (
+ setActiveFilterTab(activeFilterTab === tab ? null : tab)}
+ >
+ {MODAL_FILTER_TAB_LABELS[tab]}
+
+ ))}
+
+ )}
+
+
+ {/* 검색 입력 */}
+
+
+
+ {searchText && (
+ setSearchText("")}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ >
+
+
+ )}
+
+
+ {/* 결과 영역 */}
+
+ {loading ? (
+
+
+
+ ) : filteredRows.length === 0 ? (
+
+ {searchText ? "검색 결과가 없습니다" : "데이터가 없습니다"}
+
+ ) : displayStyle === "icon" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {filteredRows.length}건 표시 / {displayStyle === "icon" ? "아이콘" : "행"}을 클릭하면 선택됩니다
+
+
+
+ );
+}
+
+// ========================================
+// 테이블 뷰
+// ========================================
+
+function TableView({
+ rows,
+ groupedRows,
+ colsToShow,
+ displayField,
+ getColLabel,
+ activeFilterTab,
+ onSelect,
+}: {
+ rows: Record[];
+ groupedRows: [string, Record[]][] | null;
+ colsToShow: string[];
+ displayField: string;
+ getColLabel: (col: string) => string;
+ activeFilterTab: ModalFilterTab | null;
+ onSelect: (row: Record) => void;
+}) {
+ const renderRow = (row: Record, i: number) => (
+ onSelect(row)}>
+ {colsToShow.length > 0
+ ? colsToShow.map((col) => (
+ {String(row[col] ?? "")}
+ ))
+ : Object.entries(row).slice(0, 3).map(([k, v]) => (
+ {String(v ?? "")}
+ ))}
+
+ );
+
+ if (groupedRows && activeFilterTab) {
+ return (
+
+ {colsToShow.length > 0 && (
+
+ {colsToShow.map((col) => (
+
+ {getColLabel(col)}
+
+ ))}
+
+ )}
+ {groupedRows.map(([groupKey, groupRows]) => (
+
+
+
+
+ {groupRows.map((row, i) => renderRow(row, i))}
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {colsToShow.length > 0 && (
+
+
+ {colsToShow.map((col) => (
+
+ {getColLabel(col)}
+
+ ))}
+
+
+ )}
+
+ {rows.map((row, i) => renderRow(row, i))}
+
+
+ );
+}
+
+// ========================================
+// 아이콘 뷰
+// ========================================
+
+function IconView({
+ rows,
+ groupedRows,
+ displayField,
+ onSelect,
+}: {
+ rows: Record[];
+ groupedRows: [string, Record[]][] | null;
+ displayField: string;
+ onSelect: (row: Record) => void;
+}) {
+ const renderIconCard = (row: Record, i: number) => {
+ const text = displayField ? String(row[displayField] ?? "") : "";
+ const firstChar = text.charAt(0) || "?";
+ const color = getIconColor(text);
+
+ return (
+ onSelect(row)}
+ >
+
+ {firstChar}
+
+
{text}
+
+ );
+ };
+
+ if (groupedRows) {
+ return (
+
+ {groupedRows.map(([groupKey, groupRows]) => (
+
+
+
+ {groupRows.map((row, i) => renderIconCard(row, i))}
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {rows.map((row, i) => renderIconCard(row, i))}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx
new file mode 100644
index 00000000..3993dc48
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx
@@ -0,0 +1,648 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { cn } from "@/lib/utils";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
+import type {
+ PopSearchConfig,
+ SearchInputType,
+ DatePresetOption,
+ ModalSelectConfig,
+ ModalDisplayStyle,
+ ModalSearchMode,
+ ModalFilterTab,
+} from "./types";
+import {
+ SEARCH_INPUT_TYPE_LABELS,
+ DATE_PRESET_LABELS,
+ MODAL_DISPLAY_STYLE_LABELS,
+ MODAL_SEARCH_MODE_LABELS,
+ MODAL_FILTER_TAB_LABELS,
+ normalizeInputType,
+} from "./types";
+import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
+import type { TableInfo, ColumnTypeInfo } from "@/lib/api/tableManagement";
+
+// ========================================
+// 기본값
+// ========================================
+
+const DEFAULT_CONFIG: PopSearchConfig = {
+ inputType: "text",
+ fieldName: "",
+ placeholder: "검색어 입력",
+ debounceMs: 500,
+ triggerOnEnter: true,
+ labelPosition: "top",
+ labelText: "",
+ labelVisible: true,
+};
+
+// ========================================
+// 설정 패널 메인
+// ========================================
+
+interface ConfigPanelProps {
+ config: PopSearchConfig | undefined;
+ onUpdate: (config: PopSearchConfig) => void;
+}
+
+export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
+ const [step, setStep] = useState(0);
+ const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
+ const cfg: PopSearchConfig = {
+ ...rawCfg,
+ inputType: normalizeInputType(rawCfg.inputType as string),
+ };
+
+ const update = (partial: Partial) => {
+ onUpdate({ ...cfg, ...partial });
+ };
+
+ const STEPS = ["기본 설정", "상세 설정"];
+
+ return (
+
+ {/* Stepper 헤더 */}
+
+ {STEPS.map((s, i) => (
+ setStep(i)}
+ className={cn(
+ "flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-colors",
+ step === i
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:text-foreground"
+ )}
+ >
+
+ {i + 1}
+
+ {s}
+
+ ))}
+
+
+ {step === 0 &&
}
+ {step === 1 &&
}
+
+
+ setStep(step - 1)}
+ >
+
+ 이전
+
+ setStep(step + 1)}
+ >
+ 다음
+
+
+
+
+ );
+}
+
+// ========================================
+// STEP 1: 기본 설정
+// ========================================
+
+interface StepProps {
+ cfg: PopSearchConfig;
+ update: (partial: Partial) => void;
+}
+
+function StepBasicSettings({ cfg, update }: StepProps) {
+ return (
+
+
+ 입력 타입
+ update({ inputType: v as SearchInputType })}
+ >
+
+
+
+
+ {Object.entries(SEARCH_INPUT_TYPE_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ 플레이스홀더
+ update({ placeholder: e.target.value })}
+ placeholder="입력 힌트 텍스트"
+ className="h-8 text-xs"
+ />
+
+
+
+ update({ labelVisible: Boolean(checked) })}
+ />
+ 라벨 표시
+
+
+ {cfg.labelVisible !== false && (
+ <>
+
+ 라벨 텍스트
+ update({ labelText: e.target.value })}
+ placeholder="예: 거래처명"
+ className="h-8 text-xs"
+ />
+
+
+ 라벨 위치
+ update({ labelPosition: v as "top" | "left" })}
+ >
+
+
+
+
+ 위 (기본)
+ 왼쪽
+
+
+
+ >
+ )}
+
+ );
+}
+
+// ========================================
+// STEP 2: 타입별 상세 설정
+// ========================================
+
+function StepDetailSettings({ cfg, update }: StepProps) {
+ const normalized = normalizeInputType(cfg.inputType as string);
+ switch (normalized) {
+ case "text":
+ case "number":
+ return ;
+ case "select":
+ return ;
+ case "date-preset":
+ return ;
+ case "modal":
+ return ;
+ case "toggle":
+ return (
+
+
+ 토글은 추가 설정이 없습니다. ON/OFF 값이 바로 전달됩니다.
+
+
+ );
+ default:
+ return (
+
+
+ {cfg.inputType} 타입의 상세 설정은 후속 구현 예정입니다.
+
+
+ );
+ }
+}
+
+// ========================================
+// text/number 상세 설정
+// ========================================
+
+function TextDetailSettings({ cfg, update }: StepProps) {
+ return (
+
+
+
디바운스 (ms)
+
update({ debounceMs: Math.max(0, Number(e.target.value)) })}
+ min={0}
+ max={5000}
+ step={100}
+ className="h-8 text-xs"
+ />
+
+ 입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500)
+
+
+
+ update({ triggerOnEnter: Boolean(checked) })}
+ />
+ Enter 키로 즉시 발행
+
+
+ );
+}
+
+// ========================================
+// select 상세 설정
+// ========================================
+
+function SelectDetailSettings({ cfg, update }: StepProps) {
+ const options = cfg.options || [];
+
+ const addOption = () => {
+ update({
+ options: [...options, { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }],
+ });
+ };
+
+ const removeOption = (index: number) => {
+ update({ options: options.filter((_, i) => i !== index) });
+ };
+
+ const updateOption = (index: number, field: "value" | "label", val: string) => {
+ update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) });
+ };
+
+ return (
+
+
옵션 목록
+ {options.length === 0 && (
+
옵션이 없습니다. 아래 버튼으로 추가하세요.
+ )}
+ {options.map((opt, i) => (
+
+ updateOption(i, "value", e.target.value)} placeholder="값" className="h-7 flex-1 text-[10px]" />
+ updateOption(i, "label", e.target.value)} placeholder="라벨" className="h-7 flex-1 text-[10px]" />
+ removeOption(i)} className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive">
+
+
+
+ ))}
+
+
+ 옵션 추가
+
+
+ );
+}
+
+// ========================================
+// date-preset 상세 설정
+// ========================================
+
+function DatePresetDetailSettings({ cfg, update }: StepProps) {
+ const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
+ const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
+
+ const togglePreset = (preset: DatePresetOption) => {
+ const next = activePresets.includes(preset)
+ ? activePresets.filter((p) => p !== preset)
+ : [...activePresets, preset];
+ update({ datePresets: next.length > 0 ? next : ["today"] });
+ };
+
+ return (
+
+
활성화할 프리셋
+ {ALL_PRESETS.map((preset) => (
+
+ togglePreset(preset)}
+ />
+ {DATE_PRESET_LABELS[preset]}
+
+ ))}
+ {activePresets.includes("custom") && (
+
+ "직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
+
+ )}
+
+ );
+}
+
+// ========================================
+// modal 상세 설정
+// ========================================
+
+const DEFAULT_MODAL_CONFIG: ModalSelectConfig = {
+ displayStyle: "table",
+ displayField: "",
+ valueField: "",
+ searchMode: "contains",
+};
+
+function ModalDetailSettings({ cfg, update }: StepProps) {
+ const mc: ModalSelectConfig = { ...DEFAULT_MODAL_CONFIG, ...(cfg.modalConfig || {}) };
+
+ const updateModal = (partial: Partial) => {
+ update({ modalConfig: { ...mc, ...partial } });
+ };
+
+ const [tables, setTables] = useState([]);
+ const [columns, setColumns] = useState([]);
+ const [tablesLoading, setTablesLoading] = useState(false);
+ const [columnsLoading, setColumnsLoading] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setTablesLoading(true);
+ tableManagementApi.getTableList().then((res) => {
+ if (!cancelled && res.success && res.data) setTables(res.data);
+ }).finally(() => !cancelled && setTablesLoading(false));
+ return () => { cancelled = true; };
+ }, []);
+
+ useEffect(() => {
+ if (!mc.tableName) { setColumns([]); return; }
+ let cancelled = false;
+ setColumnsLoading(true);
+ getTableColumns(mc.tableName).then((res) => {
+ if (!cancelled && res.success && res.data?.columns) setColumns(res.data.columns);
+ }).finally(() => !cancelled && setColumnsLoading(false));
+ return () => { cancelled = true; };
+ }, [mc.tableName]);
+
+ const toggleArrayItem = (field: "displayColumns" | "searchColumns", col: string) => {
+ const current = mc[field] || [];
+ const next = current.includes(col) ? current.filter((c) => c !== col) : [...current, col];
+ updateModal({ [field]: next });
+ };
+
+ const toggleFilterTab = (tab: ModalFilterTab) => {
+ const current = mc.filterTabs || [];
+ const next = current.includes(tab) ? current.filter((t) => t !== tab) : [...current, tab];
+ updateModal({ filterTabs: next });
+ };
+
+ const updateColumnLabel = (colName: string, label: string) => {
+ const current = mc.columnLabels || {};
+ if (!label.trim()) {
+ const { [colName]: _, ...rest } = current;
+ updateModal({ columnLabels: Object.keys(rest).length > 0 ? rest : undefined });
+ } else {
+ updateModal({ columnLabels: { ...current, [colName]: label } });
+ }
+ };
+
+ const selectedDisplayCols = mc.displayColumns || [];
+
+ return (
+
+ {/* 보여주기 방식 */}
+
+
보여주기 방식
+
updateModal({ displayStyle: v as ModalDisplayStyle })}
+ >
+
+
+
+
+ {Object.entries(MODAL_DISPLAY_STYLE_LABELS).map(([key, label]) => (
+ {label}
+ ))}
+
+
+
+ 테이블: 표 형태 / 아이콘: 아이콘 카드 형태
+
+
+
+ {/* 데이터 테이블 */}
+
+
데이터 테이블
+ {tablesLoading ? (
+
+
+ 테이블 목록 로딩...
+
+ ) : (
+
+ updateModal({ tableName: v, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined })
+ }
+ >
+
+
+
+
+ {tables.map((t) => (
+
+ {t.displayName || t.tableName}
+
+ ))}
+
+
+ )}
+
+
+ {mc.tableName && (
+ <>
+ {/* 표시할 컬럼 */}
+
+
표시할 컬럼
+ {columnsLoading ? (
+
+
+ 컬럼 로딩...
+
+ ) : (
+
+ {columns.map((col) => (
+
+ toggleArrayItem("displayColumns", col.columnName)}
+ />
+
+ {col.displayName || col.columnName}
+ ({col.columnName})
+
+
+ ))}
+
+ )}
+
+
+ {/* 컬럼 헤더 라벨 편집 (표시할 컬럼이 선택된 경우만) */}
+ {selectedDisplayCols.length > 0 && (
+
+
컬럼 헤더 라벨
+
+ {selectedDisplayCols.map((colName) => {
+ const colInfo = columns.find((c) => c.columnName === colName);
+ const defaultLabel = colInfo?.displayName || colName;
+ return (
+
+
+ {colName}
+
+ updateColumnLabel(colName, e.target.value)}
+ placeholder={defaultLabel}
+ className="h-6 flex-1 text-[10px]"
+ />
+
+ );
+ })}
+
+
+ 비워두면 기본 컬럼명이 사용됩니다
+
+
+ )}
+
+ {/* 검색 대상 컬럼 */}
+
+
검색 대상 컬럼
+
+ {columns.map((col) => (
+
+ toggleArrayItem("searchColumns", col.columnName)}
+ />
+
+ {col.displayName || col.columnName}
+ ({col.columnName})
+
+
+ ))}
+
+
+
+ {/* 검색 방식 */}
+
+
검색 방식
+
updateModal({ searchMode: v as ModalSearchMode })}
+ >
+
+
+
+
+ {Object.entries(MODAL_SEARCH_MODE_LABELS).map(([key, label]) => (
+ {label}
+ ))}
+
+
+
+ 포함: 어디든 일치 / 시작: 앞에서 일치 / 같음: 정확히 일치
+
+
+
+ {/* 필터 탭 (가나다/ABC) */}
+
+
필터 탭
+
+ {(Object.entries(MODAL_FILTER_TAB_LABELS) as [ModalFilterTab, string][]).map(([key, label]) => (
+
+ toggleFilterTab(key)}
+ />
+ {label}
+
+ ))}
+
+
+ 모달 상단에 초성(가나다) / 알파벳(ABC) 필터 탭 표시
+
+
+
+ {/* 검색창에 보일 값 */}
+
+
검색창에 보일 값
+
updateModal({ displayField: v === "__none__" ? "" : v })}
+ >
+
+
+
+
+ 선택 안 함
+ {columns.map((col) => (
+
+ {col.displayName || col.columnName} ({col.columnName})
+
+ ))}
+
+
+
+ 선택 후 검색 입력란에 표시될 값 (예: 회사명)
+
+
+
+ {/* 필터에 쓸 값 */}
+
+
필터에 쓸 값
+
updateModal({ valueField: v === "__none__" ? "" : v })}
+ >
+
+
+
+
+ 선택 안 함
+ {columns.map((col) => (
+
+ {col.displayName || col.columnName} ({col.columnName})
+
+ ))}
+
+
+
+ 연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx
new file mode 100644
index 00000000..87069f38
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-search/index.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { PopComponentRegistry } from "../../PopComponentRegistry";
+import { PopSearchComponent } from "./PopSearchComponent";
+import { PopSearchConfigPanel } from "./PopSearchConfig";
+import type { PopSearchConfig } from "./types";
+import { DEFAULT_SEARCH_CONFIG } from "./types";
+
+function PopSearchPreviewComponent({ config, label }: { config?: PopSearchConfig; label?: string }) {
+ const cfg = config || DEFAULT_SEARCH_CONFIG;
+ const displayLabel = cfg.labelText || label || cfg.fieldName || "검색";
+
+ return (
+
+
+ {displayLabel}
+
+
+
+ {cfg.placeholder || cfg.inputType}
+
+
+
+ );
+}
+
+PopComponentRegistry.registerComponent({
+ id: "pop-search",
+ name: "검색",
+ description: "조건 입력 (텍스트/날짜/선택/모달)",
+ category: "input",
+ icon: "Search",
+ component: PopSearchComponent,
+ configPanel: PopSearchConfigPanel,
+ preview: PopSearchPreviewComponent,
+ defaultProps: DEFAULT_SEARCH_CONFIG,
+ connectionMeta: {
+ sendable: [
+ { key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
+ ],
+ receivable: [
+ { key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
+ ],
+ },
+ touchOptimized: true,
+ supportedDevices: ["mobile", "tablet"],
+});
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts
new file mode 100644
index 00000000..6c49b1c5
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-search/types.ts
@@ -0,0 +1,225 @@
+// ===== pop-search 전용 타입 =====
+// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
+
+/** 검색 필드 입력 타입 (9종) */
+export type SearchInputType =
+ | "text"
+ | "number"
+ | "date"
+ | "date-preset"
+ | "select"
+ | "multi-select"
+ | "combo"
+ | "modal"
+ | "toggle";
+
+/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
+export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
+
+/** 레거시 타입 -> modal로 정규화 */
+export function normalizeInputType(t: string): SearchInputType {
+ if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal";
+ return t as SearchInputType;
+}
+
+/** 날짜 프리셋 옵션 */
+export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
+
+/** 셀렉트 옵션 (정적 목록) */
+export interface SelectOption {
+ value: string;
+ label: string;
+}
+
+/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */
+export interface SelectDataSource {
+ tableName: string;
+ valueColumn: string;
+ labelColumn: string;
+ sortColumn?: string;
+ sortDirection?: "asc" | "desc";
+}
+
+/** 모달 보여주기 방식: 테이블 or 아이콘 */
+export type ModalDisplayStyle = "table" | "icon";
+
+/** 모달 검색 방식 */
+export type ModalSearchMode = "contains" | "starts-with" | "equals";
+
+/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
+export type ModalFilterTab = "korean" | "alphabet";
+
+/** 모달 선택 설정 */
+export interface ModalSelectConfig {
+ displayStyle?: ModalDisplayStyle;
+
+ tableName?: string;
+ displayColumns?: string[];
+ /** 컬럼별 커스텀 헤더 라벨 { column_name: "표시 라벨" } */
+ columnLabels?: Record;
+ searchColumns?: string[];
+ searchMode?: ModalSearchMode;
+ /** 모달 상단 필터 탭 (가나다 / ABC) */
+ filterTabs?: ModalFilterTab[];
+
+ displayField: string;
+ valueField: string;
+}
+
+/** pop-search 전체 설정 */
+export interface PopSearchConfig {
+ inputType: SearchInputType | LegacySearchInputType;
+ fieldName: string;
+ placeholder?: string;
+ defaultValue?: unknown;
+
+ // text/number 전용
+ debounceMs?: number;
+ triggerOnEnter?: boolean;
+
+ // select/multi-select 전용
+ options?: SelectOption[];
+ optionsDataSource?: SelectDataSource;
+
+ // date-preset 전용
+ datePresets?: DatePresetOption[];
+
+ // modal 전용
+ modalConfig?: ModalSelectConfig;
+
+ // 라벨
+ labelText?: string;
+ labelVisible?: boolean;
+
+ // 스타일
+ labelPosition?: "top" | "left";
+}
+
+/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
+export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = {
+ inputType: "text",
+ fieldName: "",
+ placeholder: "검색어 입력",
+ debounceMs: 500,
+ triggerOnEnter: true,
+ labelPosition: "top",
+ labelText: "",
+ labelVisible: true,
+};
+
+/** 날짜 프리셋 라벨 매핑 */
+export const DATE_PRESET_LABELS: Record = {
+ today: "오늘",
+ "this-week": "이번주",
+ "this-month": "이번달",
+ custom: "직접",
+};
+
+/** 입력 타입 라벨 매핑 (설정 패널용) */
+export const SEARCH_INPUT_TYPE_LABELS: Record = {
+ text: "텍스트",
+ number: "숫자",
+ date: "날짜",
+ "date-preset": "날짜 프리셋",
+ select: "단일 선택",
+ "multi-select": "다중 선택",
+ combo: "자동완성",
+ modal: "모달",
+ toggle: "토글",
+};
+
+/** 모달 보여주기 방식 라벨 */
+export const MODAL_DISPLAY_STYLE_LABELS: Record = {
+ table: "테이블",
+ icon: "아이콘",
+};
+
+/** 모달 검색 방식 라벨 */
+export const MODAL_SEARCH_MODE_LABELS: Record = {
+ contains: "포함",
+ "starts-with": "시작",
+ equals: "같음",
+};
+
+/** 모달 필터 탭 라벨 */
+export const MODAL_FILTER_TAB_LABELS: Record = {
+ korean: "가나다",
+ alphabet: "ABC",
+};
+
+/** 한글 초성 추출 */
+const KOREAN_CONSONANTS = [
+ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
+ "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ",
+];
+
+/** 초성 -> 대표 초성 (쌍자음 합침) */
+const CONSONANT_GROUP: Record = {
+ "ㄱ": "ㄱ", "ㄲ": "ㄱ",
+ "ㄴ": "ㄴ",
+ "ㄷ": "ㄷ", "ㄸ": "ㄷ",
+ "ㄹ": "ㄹ",
+ "ㅁ": "ㅁ",
+ "ㅂ": "ㅂ", "ㅃ": "ㅂ",
+ "ㅅ": "ㅅ", "ㅆ": "ㅅ",
+ "ㅇ": "ㅇ",
+ "ㅈ": "ㅈ", "ㅉ": "ㅈ",
+ "ㅊ": "ㅊ",
+ "ㅋ": "ㅋ",
+ "ㅌ": "ㅌ",
+ "ㅍ": "ㅍ",
+ "ㅎ": "ㅎ",
+};
+
+/** 문자열 첫 글자의 그룹 키 추출 (한글 초성 / 영문 대문자 / 기타) */
+export function getGroupKey(
+ text: string,
+ mode: ModalFilterTab
+): string {
+ if (!text) return "#";
+ const ch = text.charAt(0);
+ const code = ch.charCodeAt(0);
+
+ if (mode === "korean") {
+ if (code >= 0xAC00 && code <= 0xD7A3) {
+ const idx = Math.floor((code - 0xAC00) / (21 * 28));
+ const consonant = KOREAN_CONSONANTS[idx];
+ return CONSONANT_GROUP[consonant] || consonant;
+ }
+ return "#";
+ }
+
+ // alphabet
+ if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) {
+ return ch.toUpperCase();
+ }
+ return "#";
+}
+
+/** 날짜 범위 계산 (date-preset -> 실제 날짜) */
+export function computeDateRange(
+ preset: DatePresetOption
+): { preset: DatePresetOption; from: string; to: string } | null {
+ const now = new Date();
+ const fmt = (d: Date) => d.toISOString().split("T")[0];
+
+ switch (preset) {
+ case "today":
+ return { preset, from: fmt(now), to: fmt(now) };
+ case "this-week": {
+ const day = now.getDay();
+ const mon = new Date(now);
+ mon.setDate(now.getDate() - ((day + 6) % 7));
+ const sun = new Date(mon);
+ sun.setDate(mon.getDate() + 6);
+ return { preset, from: fmt(mon), to: fmt(sun) };
+ }
+ case "this-month": {
+ const first = new Date(now.getFullYear(), now.getMonth(), 1);
+ const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+ return { preset, from: fmt(first), to: fmt(last) };
+ }
+ case "custom":
+ return null;
+ }
+}
diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx
new file mode 100644
index 00000000..567f6d1d
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx
@@ -0,0 +1,795 @@
+"use client";
+
+/**
+ * pop-string-list 런타임 컴포넌트
+ *
+ * 리스트 모드: 엑셀형 행/열 (CSS Grid)
+ * 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan)
+ * 오버플로우: visibleRows 제한 + "더보기" 점진 확장
+ */
+
+import { useState, useEffect, useCallback, useMemo } from "react";
+import { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import { dataApi } from "@/lib/api/data";
+import { executePopAction } from "@/hooks/pop/executePopAction";
+import { usePopEvent } from "@/hooks/pop/usePopEvent";
+import { toast } from "sonner";
+import type {
+ PopStringListConfig,
+ CardGridConfig,
+ ListColumnConfig,
+ CardCellDefinition,
+} from "./types";
+
+// ===== 유틸리티 =====
+
+/**
+ * 컬럼명에서 실제 데이터 키를 추출
+ * 조인 컬럼은 "테이블명.컬럼명" 형식으로 저장됨 -> "컬럼명"만 추출
+ * 일반 컬럼은 그대로 반환
+ */
+function resolveColumnName(name: string): string {
+ if (!name) return name;
+ const dotIdx = name.lastIndexOf(".");
+ return dotIdx >= 0 ? name.substring(dotIdx + 1) : name;
+}
+
+// ===== Props =====
+
+interface PopStringListComponentProps {
+ config?: PopStringListConfig;
+ className?: string;
+ screenId?: string;
+ componentId?: string;
+}
+
+// 테이블 행 데이터 타입
+type RowData = Record;
+
+// ===== 메인 컴포넌트 =====
+
+export function PopStringListComponent({
+ config,
+ className,
+ screenId,
+ componentId,
+}: PopStringListComponentProps) {
+ const displayMode = config?.displayMode || "list";
+ const header = config?.header;
+ const overflow = config?.overflow;
+ const dataSource = config?.dataSource;
+ const listColumns = config?.listColumns || [];
+ const cardGrid = config?.cardGrid;
+ const rowClickAction = config?.rowClickAction || "none";
+
+ // 데이터 상태
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ // 더보기 모드: 현재 표시 중인 행 수
+ const [displayCount, setDisplayCount] = useState(0);
+ // 페이지네이션 모드: 현재 페이지 (1부터 시작)
+ const [currentPage, setCurrentPage] = useState(1);
+
+ // 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
+ const [loadingRowIdx, setLoadingRowIdx] = useState(-1);
+
+ // 이벤트 버스
+ const { publish, subscribe } = usePopEvent(screenId || "");
+
+ // 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
+ const [externalFilters, setExternalFilters] = useState<
+ Map
+ >(new Map());
+
+ // 표준 입력 이벤트 구독
+ useEffect(() => {
+ if (!componentId) return;
+ const unsub = subscribe(
+ `__comp_input__${componentId}__filter_condition`,
+ (payload: unknown) => {
+ const data = payload as {
+ value?: { fieldName?: string; value?: unknown };
+ filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
+ _connectionId?: string;
+ };
+ const connId = data?._connectionId || "default";
+ setExternalFilters(prev => {
+ const next = new Map(prev);
+ if (data?.value?.value) {
+ next.set(connId, {
+ fieldName: data.value.fieldName || "",
+ value: data.value.value,
+ filterConfig: data.filterConfig,
+ });
+ } else {
+ next.delete(connId);
+ }
+ return next;
+ });
+ }
+ );
+ return unsub;
+ }, [componentId, subscribe]);
+
+ // 카드 버튼 클릭 핸들러
+ const handleCardButtonClick = useCallback(
+ async (cell: CardCellDefinition, row: RowData) => {
+ if (!cell.buttonAction) return;
+
+ // 확인 다이얼로그 (간단 구현: window.confirm)
+ if (cell.buttonConfirm?.enabled) {
+ const msg = cell.buttonConfirm.message || "이 작업을 실행하시겠습니까?";
+ if (!window.confirm(msg)) return;
+ }
+
+ const rowIndex = rows.indexOf(row);
+ setLoadingRowIdx(rowIndex);
+
+ try {
+ const result = await executePopAction(cell.buttonAction, row as Record, {
+ publish,
+ screenId,
+ });
+
+ if (result.success) {
+ toast.success("작업이 완료되었습니다.");
+ } else {
+ toast.error(result.error || "작업에 실패했습니다.");
+ }
+ } catch {
+ toast.error("알 수 없는 오류가 발생했습니다.");
+ } finally {
+ setLoadingRowIdx(-1);
+ }
+ },
+ [rows, publish, screenId]
+ );
+
+ // 행 클릭 핸들러 (selected_row 발행 + 모달 닫기 옵션)
+ const handleRowClick = useCallback(
+ (row: RowData) => {
+ if (rowClickAction === "none") return;
+
+ // selected_row 이벤트 발행
+ if (componentId) {
+ publish(`__comp_output__${componentId}__selected_row`, row);
+ }
+
+ // 모달 내부에서 사용 시: 선택 후 모달 닫기 + 데이터 반환
+ if (rowClickAction === "select-and-close-modal") {
+ publish("__pop_modal_close__", { selectedRow: row });
+ }
+ },
+ [rowClickAction, componentId, publish]
+ );
+
+ // 오버플로우 설정 (JSON 복원 시 string 유입 방어)
+ const overflowMode = overflow?.mode || "loadMore";
+ const visibleRows = Number(overflow?.visibleRows) || 5;
+ const loadMoreCount = Number(overflow?.loadMoreCount) || 5;
+ const maxExpandRows = Number(overflow?.maxExpandRows) || 50;
+ const showExpandButton = overflow?.showExpandButton ?? true;
+ const pageSize = Number(overflow?.pageSize) || visibleRows;
+ const paginationStyle = overflow?.paginationStyle || "bottom";
+
+ // --- 외부 필터 적용 (복수 필터 AND 결합) ---
+ const filteredRows = useMemo(() => {
+ if (externalFilters.size === 0) return rows;
+
+ const matchSingleFilter = (
+ row: RowData,
+ filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
+ ): boolean => {
+ const searchValue = String(filter.value).toLowerCase();
+ if (!searchValue) return true;
+
+ const fc = filter.filterConfig;
+ const columns: string[] =
+ fc?.targetColumns?.length
+ ? fc.targetColumns
+ : fc?.targetColumn
+ ? [fc.targetColumn]
+ : filter.fieldName
+ ? [filter.fieldName]
+ : [];
+
+ if (columns.length === 0) return true;
+
+ const mode = fc?.filterMode || "contains";
+
+ const matchCell = (cellValue: string) => {
+ switch (mode) {
+ case "equals":
+ return cellValue === searchValue;
+ case "starts_with":
+ return cellValue.startsWith(searchValue);
+ case "contains":
+ default:
+ return cellValue.includes(searchValue);
+ }
+ };
+
+ return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()));
+ };
+
+ const allFilters = [...externalFilters.values()];
+ return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
+ }, [rows, externalFilters]);
+
+ // --- 더보기 모드 ---
+ useEffect(() => {
+ setDisplayCount(visibleRows);
+ }, [visibleRows]);
+
+ const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, filteredRows.length);
+ const hasMore = showExpandButton && filteredRows.length > effectiveLimit && effectiveLimit < maxExpandRows;
+ const isExpanded = effectiveLimit > visibleRows;
+
+ const handleLoadMore = useCallback(() => {
+ setDisplayCount((prev) => {
+ const current = prev || visibleRows;
+ return Math.min(current + loadMoreCount, maxExpandRows, filteredRows.length);
+ });
+ }, [visibleRows, loadMoreCount, maxExpandRows, filteredRows.length]);
+
+ const handleCollapse = useCallback(() => {
+ setDisplayCount(visibleRows);
+ }, [visibleRows]);
+
+ // --- 페이지네이션 모드 ---
+ const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [pageSize, filteredRows.length]);
+
+ const handlePageChange = useCallback((page: number) => {
+ setCurrentPage(Math.max(1, Math.min(page, totalPages)));
+ }, [totalPages]);
+
+ // --- 모드별 visibleData 결정 ---
+ const visibleData = useMemo(() => {
+ if (overflowMode === "pagination") {
+ const start = (currentPage - 1) * pageSize;
+ return filteredRows.slice(start, start + pageSize);
+ }
+ return filteredRows.slice(0, effectiveLimit);
+ }, [overflowMode, filteredRows, currentPage, pageSize, effectiveLimit]);
+
+ // dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용)
+ const dsTableName = dataSource?.tableName;
+ const dsSortColumn = dataSource?.sort?.column;
+ const dsSortDirection = dataSource?.sort?.direction;
+ const dsLimitMode = dataSource?.limit?.mode;
+ const dsLimitCount = dataSource?.limit?.count;
+ const dsFiltersKey = useMemo(
+ () => JSON.stringify(dataSource?.filters || []),
+ [dataSource?.filters]
+ );
+
+ // 데이터 조회
+ useEffect(() => {
+ if (!dsTableName) {
+ setLoading(false);
+ setRows([]);
+ return;
+ }
+
+ const fetchData = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ // 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터)
+ const filters: Record = {};
+ const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>;
+ if (parsedFilters.length > 0) {
+ parsedFilters.forEach((f) => {
+ if (f.column && f.value) {
+ filters[f.column] = f.value;
+ }
+ });
+ }
+
+ // 정렬 조건
+ const sortBy = dsSortColumn;
+ const sortOrder = dsSortDirection;
+
+ // 개수 제한 (string 유입 방어: Number 캐스팅)
+ const size =
+ dsLimitMode === "limited" && dsLimitCount
+ ? Number(dsLimitCount)
+ : maxExpandRows;
+
+ const result = await dataApi.getTableData(dsTableName, {
+ page: 1,
+ size,
+ sortBy: sortOrder ? sortBy : undefined,
+ sortOrder,
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
+ });
+
+ setRows(result.data || []);
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "데이터 조회 실패";
+ setError(message);
+ setRows([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]);
+
+ // 로딩 상태
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ // 에러 상태
+ if (error) {
+ return (
+
+ );
+ }
+
+ // 테이블 미선택
+ if (!dataSource?.tableName) {
+ return (
+
+
+ 테이블을 선택하세요
+
+
+ );
+ }
+
+ // 데이터 없음
+ if (rows.length === 0) {
+ return (
+
+
+ 데이터가 없습니다
+
+
+ );
+ }
+
+ const isPaginationSide = overflowMode === "pagination" && paginationStyle === "side" && totalPages > 1;
+
+ return (
+
+ {/* 헤더 */}
+ {header?.enabled && header.label && (
+
+ {header.label}
+
+ )}
+
+ {/* 컨텐츠 */}
+
+ {displayMode === "list" ? (
+
+ ) : (
+
+ )}
+ {isPaginationSide && (
+ <>
+ handlePageChange(currentPage - 1)}
+ disabled={currentPage <= 1}
+ className="absolute left-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
+ >
+
+
+ handlePageChange(currentPage + 1)}
+ disabled={currentPage >= totalPages}
+ className="absolute right-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
+ >
+
+
+ >
+ )}
+
+
+ {/* side 모드 페이지 인디케이터 (컨텐츠 아래 별도 영역) */}
+ {isPaginationSide && (
+
+
+ {currentPage} / {totalPages}
+
+
+ )}
+
+ {/* 더보기 모드 컨트롤 */}
+ {overflowMode === "loadMore" && showExpandButton && (hasMore || isExpanded) && (
+
+ {hasMore && (
+
+
+ 더보기 ({rows.length - effectiveLimit}건 남음)
+
+ )}
+ {isExpanded && (
+
+
+ 접기
+
+ )}
+
+ )}
+
+ {/* 페이지네이션 bottom 모드 컨트롤 */}
+ {overflowMode === "pagination" && paginationStyle === "bottom" && totalPages > 1 && (
+
+
+ {rows.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, rows.length)}
+
+
+ handlePageChange(currentPage - 1)}
+ disabled={currentPage <= 1}
+ className="h-6 w-6"
+ >
+
+
+
+ {currentPage} / {totalPages}
+
+ handlePageChange(currentPage + 1)}
+ disabled={currentPage >= totalPages}
+ className="h-6 w-6"
+ >
+
+
+
+
+ )}
+
+ );
+}
+
+// ===== 리스트 모드 =====
+
+interface ListModeViewProps {
+ columns: ListColumnConfig[];
+ data: RowData[];
+ onRowClick?: (row: RowData) => void;
+}
+
+function ListModeView({ columns, data, onRowClick }: ListModeViewProps) {
+ // 런타임 컬럼 전환 상태
+ // key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
+ const [activeColumns, setActiveColumns] = useState>({});
+
+ if (columns.length === 0) {
+ return (
+
+
+ 컬럼을 설정하세요
+
+
+ );
+ }
+
+ const gridCols = columns.map((c) => c.width || "1fr").join(" ");
+
+ return (
+
+ {/* 헤더 행 */}
+
+ {columns.map((col, colIdx) => {
+ const hasAlternates = (col.alternateColumns || []).length > 0;
+ const currentColName = activeColumns[colIdx] || col.columnName;
+ // 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시
+ const currentLabel =
+ currentColName === col.columnName
+ ? col.label
+ : resolveColumnName(currentColName);
+
+ if (hasAlternates) {
+ // 전환 가능한 헤더: Popover 드롭다운
+ return (
+
+
+
+ {currentLabel}
+
+
+
+
+
+ {/* 원래 컬럼 */}
+ {
+ setActiveColumns((prev) => {
+ const next = { ...prev };
+ delete next[colIdx];
+ return next;
+ });
+ }}
+ >
+ {col.label} (기본)
+
+ {/* 대체 컬럼들 */}
+ {(col.alternateColumns || []).map((altCol) => {
+ const altLabel = resolveColumnName(altCol);
+ return (
+ {
+ setActiveColumns((prev) => ({
+ ...prev,
+ [colIdx]: altCol,
+ }));
+ }}
+ >
+ {altLabel}
+
+ );
+ })}
+
+
+
+ );
+ }
+
+ // 전환 없는 일반 헤더
+ return (
+
+ {col.label}
+
+ );
+ })}
+
+
+ {/* 데이터 행 */}
+ {data.map((row, i) => (
+
onRowClick?.(row)}
+ >
+ {columns.map((col, colIdx) => {
+ const currentColName = activeColumns[colIdx] || col.columnName;
+ const resolvedKey = resolveColumnName(currentColName);
+ return (
+
+ {String(row[resolvedKey] ?? "")}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+// ===== 카드 모드 =====
+
+interface CardModeViewProps {
+ cardGrid?: CardGridConfig;
+ data: RowData[];
+ handleCardButtonClick?: (cell: CardCellDefinition, row: RowData) => void;
+ loadingRowId?: number;
+}
+
+function CardModeView({ cardGrid, data, handleCardButtonClick, loadingRowId }: CardModeViewProps) {
+ if (!cardGrid || (cardGrid.cells || []).length === 0) {
+ return (
+
+
+ 카드 레이아웃을 설정하세요
+
+
+ );
+ }
+
+ return (
+
+ {data.map((row, i) => (
+
0
+ ? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
+ : "1fr",
+ gridTemplateRows:
+ cardGrid.rowHeights && cardGrid.rowHeights.length > 0
+ ? cardGrid.rowHeights
+ .map((h) => {
+ if (!h) return "minmax(32px, auto)";
+ // px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장
+ if (h.endsWith("px")) {
+ return `minmax(${h}, auto)`;
+ }
+ // fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용
+ const px = Math.round(parseFloat(h) * 32) || 32;
+ return `minmax(${px}px, auto)`;
+ })
+ .join(" ")
+ : `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`,
+ gap: `${Number(cardGrid.gap) || 0}px`,
+ }}
+ >
+ {(cardGrid.cells || []).map((cell) => {
+ // 가로 정렬 매핑
+ const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
+ const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
+ return (
+
+ {renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+// ===== 셀 컨텐츠 렌더링 =====
+
+function renderCellContent(
+ cell: CardCellDefinition,
+ row: RowData,
+ onButtonClick?: (cell: CardCellDefinition, row: RowData) => void,
+ isButtonLoading?: boolean,
+): React.ReactNode {
+ const value = row[cell.columnName];
+ const displayValue = value != null ? String(value) : "";
+
+ switch (cell.type) {
+ case "image":
+ return displayValue ? (
+
+ ) : (
+
+ No Image
+
+ );
+
+ case "badge":
+ return (
+
+ {displayValue}
+
+ );
+
+ case "button":
+ return (
+ {
+ e.stopPropagation();
+ onButtonClick?.(cell, row);
+ }}
+ >
+ {cell.label || displayValue}
+
+ );
+
+ case "text":
+ default: {
+ // 글자 크기 매핑
+ const fontSizeClass =
+ cell.fontSize === "sm"
+ ? "text-[10px]"
+ : cell.fontSize === "lg"
+ ? "text-sm"
+ : "text-xs"; // md (기본)
+ const isLabelLeft = cell.labelPosition === "left";
+
+ return (
+
+ {cell.label && (
+
+ {cell.label}{isLabelLeft ? ":" : ""}
+
+ )}
+ {displayValue}
+
+ );
+ }
+ }
+}
diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx
new file mode 100644
index 00000000..4208301b
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx
@@ -0,0 +1,2815 @@
+"use client";
+
+/**
+ * pop-string-list 설정 패널 (Stepper/Wizard 방식)
+ *
+ * 6단계 순차 진행:
+ * 1) 모드 선택 (리스트/카드)
+ * 2) 헤더 설정
+ * 3) 오버플로우 설정
+ * 4) 데이터 선택 (테이블 + 컬럼 통합)
+ * 5) 조인 설정 (선택)
+ * 6-A) 리스트 컬럼 배치 (리스트 모드)
+ * 6-B) 카드 그리드 디자이너 (카드 모드)
+ */
+
+import { useState, useEffect, useRef, useCallback, Fragment } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Check, ChevronsUpDown, ChevronLeft, ChevronRight, Plus, Minus, Trash2 } from "lucide-react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { cn } from "@/lib/utils";
+import type {
+ PopStringListConfig,
+ StringListDisplayMode,
+ ListColumnConfig,
+ CardGridConfig,
+ CardCellDefinition,
+} from "./types";
+import type { CardListDataSource, CardColumnJoin } from "../types";
+import {
+ fetchTableList,
+ fetchTableColumns,
+ type TableInfo,
+ type ColumnInfo,
+} from "../pop-dashboard/utils/dataFetcher";
+
+// ===== Props =====
+
+interface ConfigPanelProps {
+ config: PopStringListConfig | undefined;
+ onUpdate: (config: PopStringListConfig) => void;
+}
+
+// ===== 기본 설정값 =====
+
+const DEFAULT_CONFIG: PopStringListConfig = {
+ displayMode: "list",
+ header: { enabled: true, label: "" },
+ overflow: { visibleRows: 5, mode: "loadMore", showExpandButton: true, loadMoreCount: 5, maxExpandRows: 50, pageSize: 5, paginationStyle: "bottom" },
+ dataSource: { tableName: "" },
+ listColumns: [],
+ cardGrid: undefined,
+};
+
+// Stepper 단계 정의
+const STEP_LABELS = [
+ "모드 선택",
+ "헤더 설정",
+ "오버플로우",
+ "데이터 선택",
+ "조인 설정",
+ "레이아웃",
+] as const;
+
+const TOTAL_STEPS = STEP_LABELS.length;
+
+// ===== 메인 컴포넌트 =====
+
+export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps) {
+ const [step, setStep] = useState(0);
+ const [tables, setTables] = useState([]);
+ const [columns, setColumns] = useState([]);
+ const [selectedColumns, setSelectedColumns] = useState([]);
+
+ // 설정값 (undefined 대비 기본값 병합)
+ const cfg: PopStringListConfig = {
+ ...DEFAULT_CONFIG,
+ ...config,
+ header: { ...DEFAULT_CONFIG.header, ...config?.header },
+ overflow: { ...DEFAULT_CONFIG.overflow, ...config?.overflow },
+ dataSource: { ...DEFAULT_CONFIG.dataSource, ...config?.dataSource },
+ };
+
+ // 설정 업데이트 헬퍼
+ const update = (partial: Partial) => {
+ onUpdate({ ...cfg, ...partial });
+ };
+
+ // 테이블 목록 로드
+ useEffect(() => {
+ fetchTableList()
+ .then(setTables)
+ .catch(() => setTables([])); // 네트워크 오류 시 빈 배열
+ }, []);
+
+ // 테이블 변경 시 컬럼 로드
+ useEffect(() => {
+ if (!cfg.dataSource.tableName) {
+ setColumns([]);
+ return;
+ }
+ fetchTableColumns(cfg.dataSource.tableName)
+ .then(setColumns)
+ .catch(() => setColumns([])); // 네트워크 오류 시 빈 배열
+ }, [cfg.dataSource.tableName]);
+
+ // 선택된 컬럼 복원 (config에 저장된 값 우선)
+ useEffect(() => {
+ if (cfg.selectedColumns && cfg.selectedColumns.length > 0) {
+ setSelectedColumns(cfg.selectedColumns);
+ } else if (cfg.displayMode === "list" && cfg.listColumns) {
+ setSelectedColumns(cfg.listColumns.map((c) => c.columnName));
+ } else if (cfg.displayMode === "card" && cfg.cardGrid) {
+ setSelectedColumns((cfg.cardGrid.cells || []).map((c) => c.columnName));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cfg.dataSource.tableName]); // 테이블 변경 시에만 복원
+
+ // 다음/이전 단계
+ const canGoNext = (): boolean => {
+ switch (step) {
+ case 0: return true; // 모드 선택 (기본값 있음)
+ case 1: return true; // 헤더 (선택사항)
+ case 2: return true; // 오버플로우 (기본값 있음)
+ case 3: return !!cfg.dataSource.tableName && selectedColumns.length > 0; // 테이블 + 컬럼
+ case 4: return true; // 조인 (선택사항)
+ case 5: return true; // 레이아웃
+ default: return false;
+ }
+ };
+
+ const goNext = () => {
+ if (step < TOTAL_STEPS - 1 && canGoNext()) setStep(step + 1);
+ };
+
+ const goPrev = () => {
+ if (step > 0) setStep(step - 1);
+ };
+
+ return (
+
+ {/* Stepper 인디케이터 */}
+
+ {STEP_LABELS.map((label, i) => (
+ setStep(i)}
+ className={`flex-1 cursor-pointer rounded-sm py-0.5 text-center text-[10px] transition-colors ${
+ i === step
+ ? "bg-primary text-primary-foreground font-medium"
+ : i < step
+ ? "bg-primary/20 text-primary"
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
+ }`}
+ title={label}
+ >
+ {i + 1}
+
+ ))}
+
+
{STEP_LABELS[step]}
+
+ {/* 단계별 컨텐츠 */}
+
+ {step === 0 && (
+ update({ displayMode })}
+ />
+ )}
+ {step === 1 && (
+ update({ header })}
+ />
+ )}
+ {step === 2 && (
+ update({ overflow })}
+ />
+ )}
+ {step === 3 && (
+ {
+ setSelectedColumns([]);
+ update({
+ dataSource: { ...cfg.dataSource, tableName },
+ selectedColumns: [],
+ listColumns: [],
+ cardGrid: undefined,
+ });
+ }}
+ columns={columns}
+ selectedColumns={selectedColumns}
+ onColumnsChange={(cols) => {
+ setSelectedColumns(cols);
+ if (cfg.displayMode === "list") {
+ const currentList = cfg.listColumns || [];
+ // 기존 리스트에서: 체크 해제된 메인 컬럼만 제거
+ // 조인 컬럼 (이름에 "."이 포함)은 항상 보존
+ const preserved = currentList.filter(
+ (lc) => cols.includes(lc.columnName) || lc.columnName.includes(".")
+ );
+ // 새로 체크된 메인 컬럼만 리스트 끝에 추가
+ const existingNames = new Set(preserved.map((lc) => lc.columnName));
+ const added = cols
+ .filter((colName) => !existingNames.has(colName))
+ .map((colName) => ({ columnName: colName, label: colName } as ListColumnConfig));
+ update({ selectedColumns: cols, listColumns: [...preserved, ...added] });
+ } else {
+ update({ selectedColumns: cols });
+ }
+ }}
+ />
+ )}
+ {step === 4 && (
+ {
+ // 조인 변경 후: 유효한 조인 컬럼명 셋 계산
+ const validJoinColNames = new Set(
+ (dataSource.joins || []).flatMap((j) =>
+ (j.selectedTargetColumns || []).map((col) => `${j.targetTable}.${col}`)
+ )
+ );
+ // listColumns에서 고아 조인 컬럼 제거 + alternateColumns 정리
+ const currentList = cfg.listColumns || [];
+ const cleanedList = currentList
+ .filter((lc) => {
+ if (!lc.columnName.includes(".")) return true; // 메인 컬럼: 유지
+ return validJoinColNames.has(lc.columnName); // 조인 컬럼: 유효한 것만
+ })
+ .map((lc) => {
+ const alts = lc.alternateColumns;
+ if (!alts) return lc;
+ const cleanedAlts = alts.filter((a) => {
+ if (!a.includes(".")) return true; // 메인 컬럼: 유지
+ return validJoinColNames.has(a); // 조인 컬럼: 유효한 것만
+ });
+ return {
+ ...lc,
+ alternateColumns: cleanedAlts.length > 0 ? cleanedAlts : undefined,
+ };
+ });
+ update({ dataSource, listColumns: cleanedList });
+ }}
+ />
+ )}
+ {step === 5 &&
+ (cfg.displayMode === "list" ? (
+
+ selectedColumns.includes(c.name)
+ )}
+ joinedColumns={
+ // 조인에서 선택된 대상 컬럼들을 {테이블명.컬럼명} 형태로 수집
+ (cfg.dataSource.joins || []).flatMap((j) =>
+ (j.selectedTargetColumns || []).map((col) => ({
+ name: `${j.targetTable}.${col}`,
+ displayName: col,
+ sourceTable: j.targetTable,
+ }))
+ )
+ }
+ onChange={(listColumns) => update({ listColumns })}
+ />
+ ) : (
+ update({ cardGrid })}
+ />
+ ))}
+
+
+ {/* 이전/다음 버튼 */}
+
+
+
+ 이전
+
+
+ {step + 1} / {TOTAL_STEPS}
+
+
+ 다음
+
+
+
+
+ );
+}
+
+// ===== STEP 0: 모드 선택 =====
+
+function StepModeSelect({
+ displayMode,
+ onChange,
+}: {
+ displayMode: StringListDisplayMode;
+ onChange: (mode: StringListDisplayMode) => void;
+}) {
+ return (
+
+
onChange("list")}
+ className={`flex-1 rounded-md border p-3 text-center transition-colors ${
+ displayMode === "list"
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-border hover:border-primary/50"
+ }`}
+ >
+ 리스트
+
+ 엑셀형 행/열 테이블
+
+
+
onChange("card")}
+ className={`flex-1 rounded-md border p-3 text-center transition-colors ${
+ displayMode === "card"
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-border hover:border-primary/50"
+ }`}
+ >
+ 카드
+
+ 셀 병합 가능한 카드
+
+
+
+ );
+}
+
+// ===== STEP 1: 헤더 설정 =====
+
+function StepHeader({
+ header,
+ onChange,
+}: {
+ header: PopStringListConfig["header"];
+ onChange: (header: PopStringListConfig["header"]) => void;
+}) {
+ return (
+
+
+ 헤더 표시
+ onChange({ ...header, enabled })}
+ />
+
+ {header.enabled && (
+
+ 헤더 라벨
+ onChange({ ...header, label: e.target.value })}
+ placeholder="리스트 제목 입력"
+ className="mt-1 h-8 text-xs"
+ />
+
+ )}
+
+ );
+}
+
+// ===== STEP 2: 오버플로우 설정 =====
+
+function StepOverflow({
+ overflow,
+ onChange,
+}: {
+ overflow: PopStringListConfig["overflow"];
+ onChange: (overflow: PopStringListConfig["overflow"]) => void;
+}) {
+ const mode = overflow.mode || "loadMore";
+
+ return (
+
+
+ 기본 표시 행 수
+
+ onChange({ ...overflow, visibleRows: Number(e.target.value) || 5 })
+ }
+ className="mt-1 h-8 text-xs"
+ />
+
+
+
+ 오버플로우 방식
+
+ onChange({ ...overflow, mode: v as "loadMore" | "pagination" })
+ }
+ >
+
+
+
+
+
+ 더보기 (점진 확장)
+
+
+ 페이지네이션
+
+
+
+
+
+ {mode === "loadMore" && (
+ <>
+
+ 더보기 버튼
+
+ onChange({ ...overflow, showExpandButton })
+ }
+ />
+
+ {overflow.showExpandButton && (
+ <>
+
+
더보기 추가 행 수
+
+ onChange({
+ ...overflow,
+ loadMoreCount: Number(e.target.value) || 5,
+ })
+ }
+ className="mt-1 h-8 text-xs"
+ />
+
+ 클릭할 때마다 추가로 표시할 행 수
+
+
+
+ 최대 확장 행 수
+
+ onChange({
+ ...overflow,
+ maxExpandRows: Number(e.target.value) || 50,
+ })
+ }
+ className="mt-1 h-8 text-xs"
+ />
+
+ >
+ )}
+ >
+ )}
+
+ {mode === "pagination" && (
+ <>
+
+ 페이지당 행 수
+
+ onChange({
+ ...overflow,
+ pageSize: Number(e.target.value) || 5,
+ })
+ }
+ className="mt-1 h-8 text-xs"
+ />
+
+
+
네비게이션 스타일
+
+ onChange({
+ ...overflow,
+ paginationStyle: v as "bottom" | "side",
+ })
+ }
+ >
+
+
+
+
+
+ 하단 페이지 표시
+
+
+ 좌우 화살표
+
+
+
+
+ {(overflow.paginationStyle || "bottom") === "bottom"
+ ? "컴포넌트 하단에 이전/다음 버튼과 페이지 번호 표시"
+ : "컴포넌트 좌우에 화살표 버튼 표시"}
+
+
+ >
+ )}
+
+ );
+}
+
+// ===== STEP 3: 데이터 선택 (테이블 + 컬럼 통합) =====
+
+function StepDataSelect({
+ tables,
+ tableName,
+ onTableChange,
+ columns,
+ selectedColumns,
+ onColumnsChange,
+}: {
+ tables: TableInfo[];
+ tableName: string;
+ onTableChange: (tableName: string) => void;
+ columns: ColumnInfo[];
+ selectedColumns: string[];
+ onColumnsChange: (selected: string[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const selectedDisplay = tableName
+ ? tables.find((t) => t.tableName === tableName)?.displayName || tableName
+ : "";
+
+ const toggleColumn = (colName: string) => {
+ if (selectedColumns.includes(colName)) {
+ onColumnsChange(selectedColumns.filter((c) => c !== colName));
+ } else {
+ onColumnsChange([...selectedColumns, colName]);
+ }
+ };
+
+ return (
+
+ {/* 테이블 선택 */}
+
+
데이터 테이블
+
+
+
+ {tableName ? selectedDisplay : "테이블 검색 / 선택"}
+
+
+
+
+
+
+
+
+ 검색 결과가 없습니다
+
+
+ {
+ onTableChange("");
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ 선택 안 함
+
+ {tables.map((t) => (
+ {
+ onTableChange(t.tableName);
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {t.displayName || t.tableName}
+ {t.displayName && t.displayName !== t.tableName && (
+
+ {t.tableName}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* 컬럼 선택 (테이블 선택 후 표시) */}
+ {tableName && columns.length > 0 && (
+
+
+ 사용할 컬럼 ({selectedColumns.length}개 선택됨)
+
+
+ {columns.map((col) => (
+
+ toggleColumn(col.name)}
+ className="h-3 w-3"
+ />
+ {col.name}
+
+ {col.type}
+
+
+ ))}
+
+
+ )}
+
+ {tableName && columns.length === 0 && (
+
+ 컬럼 로딩 중...
+
+ )}
+
+ );
+}
+
+// ===== STEP 4: 조인 설정 (UX 개선 - 자동매칭 + 타입필터링) =====
+
+// DB 타입을 짧은 약어로 변환
+const shortType = (t: string): string => {
+ const lower = t.toLowerCase();
+ if (lower.includes("character varying") || lower === "varchar") return "varchar";
+ if (lower === "text") return "text";
+ if (lower.includes("timestamp")) return "timestamp";
+ if (lower === "integer" || lower === "int4") return "int";
+ if (lower === "bigint" || lower === "int8") return "bigint";
+ if (lower === "numeric" || lower === "decimal") return "numeric";
+ if (lower === "boolean" || lower === "bool") return "bool";
+ if (lower === "date") return "date";
+ if (lower === "uuid") return "uuid";
+ if (lower === "jsonb" || lower === "json") return "json";
+ return t.length > 12 ? t.slice(0, 10) + ".." : t;
+};
+
+// 조인 항목 하나를 관리하는 서브 컴포넌트
+function JoinItem({
+ join,
+ index,
+ tables,
+ mainColumns,
+ mainTableName,
+ onUpdate,
+ onRemove,
+}: {
+ join: CardColumnJoin;
+ index: number;
+ tables: TableInfo[];
+ mainColumns: ColumnInfo[];
+ mainTableName: string;
+ onUpdate: (partial: Partial) => void;
+ onRemove: () => void;
+}) {
+ const [targetColumns, setTargetColumns] = useState([]);
+ const [tableOpen, setTableOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ // 대상 테이블 변경 시 컬럼 로딩
+ useEffect(() => {
+ if (!join.targetTable) {
+ setTargetColumns([]);
+ return;
+ }
+ setLoading(true);
+ fetchTableColumns(join.targetTable)
+ .then(setTargetColumns)
+ .catch(() => setTargetColumns([]))
+ .finally(() => setLoading(false));
+ }, [join.targetTable]);
+
+ // 자동 매칭: 이름 + 타입이 모두 같은 컬럼 쌍 찾기
+ const autoMatches = mainColumns.filter((mc) =>
+ targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type)
+ );
+
+ // 현재 연결된 쌍이 자동매칭 항목인지 확인
+ const isAutoMatch =
+ join.sourceColumn !== "" &&
+ join.sourceColumn === join.targetColumn &&
+ autoMatches.some((m) => m.name === join.sourceColumn);
+
+ // 수동 매칭: 소스 컬럼 선택 시 같은 타입의 대상 컬럼만 필터
+ const compatibleTargetCols = join.sourceColumn
+ ? targetColumns.filter((tc) => {
+ const srcCol = mainColumns.find((mc) => mc.name === join.sourceColumn);
+ return srcCol ? tc.type === srcCol.type : true;
+ })
+ : targetColumns;
+
+ // 메인 테이블 제외한 테이블 목록
+ const selectableTables = tables.filter((t) => t.tableName !== mainTableName);
+
+ // 연결 조건이 설정되었는지 여부
+ const hasJoinCondition = join.sourceColumn !== "" && join.targetColumn !== "";
+
+ // 선택된 대상 컬럼 관리 (연결 조건 컬럼은 제외한 나머지)
+ const selectedTargetCols = join.selectedTargetColumns || [];
+
+ // 가져올 수 있는 대상 컬럼 (연결 조건으로 사용된 컬럼 제외)
+ const pickableTargetCols = targetColumns.filter(
+ (tc) => tc.name !== join.targetColumn
+ );
+
+ const toggleTargetCol = (colName: string) => {
+ const prev = selectedTargetCols;
+ const next = prev.includes(colName)
+ ? prev.filter((c) => c !== colName)
+ : [...prev, colName];
+ onUpdate({ selectedTargetColumns: next });
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+ 연결 #{index + 1}
+
+
+
+
+
+ {/* 대상 테이블 선택 (검색 가능 Combobox) */}
+
+
연결할 테이블
+
+
+
+ {join.targetTable || "테이블 선택..."}
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다
+
+
+ {selectableTables.map((t) => (
+ {
+ onUpdate({
+ targetTable: t.tableName,
+ sourceColumn: "",
+ targetColumn: "",
+ selectedTargetColumns: [],
+ });
+ setTableOpen(false);
+ }}
+ className="text-[10px]"
+ >
+
+
+ {t.tableName}
+ {(t.displayName || t.description) && (
+
+ {t.displayName || t.description}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* 대상 테이블 선택 후 컬럼 매칭 영역 */}
+ {join.targetTable && (
+ <>
+ {loading ? (
+
컬럼 불러오는 중...
+ ) : (
+ <>
+ {/* 자동 매칭 결과 - 테이블 헤더 + 컬럼명만 표시 */}
+ {autoMatches.length > 0 && (
+
+
+ 연결 조건 선택
+
+ {/* 테이블명 헤더 */}
+
+
+
{mainTableName}
+
+
{join.targetTable}
+
+
+ {/* 매칭 행 */}
+
+ {autoMatches.map((mc) => {
+ const isSelected =
+ join.sourceColumn === mc.name && join.targetColumn === mc.name;
+ return (
+
{
+ if (isSelected) {
+ onUpdate({ sourceColumn: "", targetColumn: "" });
+ } else {
+ onUpdate({
+ sourceColumn: mc.name,
+ targetColumn: mc.name,
+ });
+ }
+ }}
+ className={cn(
+ "flex w-full items-center gap-1 rounded px-1.5 py-0.5 text-left text-[10px] transition-colors",
+ isSelected
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
+ >
+
+ {isSelected && (
+
+ )}
+
+ {mc.name}
+ =
+ {mc.name}
+
+ {shortType(mc.type)}
+
+
+ );
+ })}
+
+
+ )}
+
+ {autoMatches.length === 0 && (
+
+ 이름이 같은 컬럼이 없습니다. 아래에서 직접 지정하세요.
+
+ )}
+
+ {/* 수동 매칭 (고급) */}
+ {!isAutoMatch && (
+
+
+ 직접 지정
+
+
+
+ onUpdate({ sourceColumn: v, targetColumn: "" })
+ }
+ >
+
+
+
+
+ {mainColumns.map((mc) => (
+
+ {mc.name}
+
+ ({shortType(mc.type)})
+
+
+ ))}
+
+
+
+ =
+
+ onUpdate({ targetColumn: v })}
+ disabled={!join.sourceColumn}
+ >
+
+
+
+
+ {compatibleTargetCols.length > 0 ? (
+ compatibleTargetCols.map((tc) => (
+
+ {tc.name}
+
+ ({shortType(tc.type)})
+
+
+ ))
+ ) : (
+
+ 호환 가능한 컬럼 없음
+
+ )}
+
+
+
+
+ )}
+ >
+ )}
+
+ {/* 표시 방식 (JOIN 타입) - 자연어 + 설명 */}
+
+
표시 방식
+
+
onUpdate({ joinType: "LEFT" })}
+ className={cn(
+ "flex-1 rounded border px-2 py-1 transition-colors",
+ join.joinType === "LEFT"
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-border hover:bg-muted"
+ )}
+ >
+ 일치하지 않아도 표시
+
+ 연결 데이터 없으면 빈칸
+
+
+
onUpdate({ joinType: "INNER" })}
+ className={cn(
+ "flex-1 rounded border px-2 py-1 transition-colors",
+ join.joinType === "INNER"
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-border hover:bg-muted"
+ )}
+ >
+ 일치하는 행만
+
+ 연결 데이터 없으면 숨김
+
+
+
+
+
+ {/* 가져올 컬럼 선택 (연결 조건 설정 후 활성화) */}
+ {hasJoinCondition && !loading && (
+
+
+ 가져올 컬럼 ({selectedTargetCols.length}개 선택)
+
+ {pickableTargetCols.length > 0 ? (
+
+ {pickableTargetCols.map((tc) => {
+ const isChecked = selectedTargetCols.includes(tc.name);
+ return (
+
toggleTargetCol(tc.name)}
+ className={cn(
+ "flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-[10px] transition-colors",
+ isChecked ? "bg-primary/10 text-primary" : "hover:bg-muted"
+ )}
+ >
+
+ {isChecked && (
+
+ )}
+
+ {tc.name}
+
+ {shortType(tc.type)}
+
+
+ );
+ })}
+
+ ) : (
+
+ 가져올 수 있는 컬럼이 없습니다
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function StepJoinConfig({
+ dataSource,
+ tables,
+ mainColumns,
+ onChange,
+}: {
+ dataSource: CardListDataSource;
+ tables: TableInfo[];
+ mainColumns: ColumnInfo[];
+ onChange: (dataSource: CardListDataSource) => void;
+}) {
+ const joins = dataSource.joins || [];
+
+ const addJoin = () => {
+ const newJoin: CardColumnJoin = {
+ targetTable: "",
+ joinType: "LEFT",
+ sourceColumn: "",
+ targetColumn: "",
+ };
+ onChange({ ...dataSource, joins: [...joins, newJoin] });
+ };
+
+ const removeJoin = (index: number) => {
+ const next = joins.filter((_, i) => i !== index);
+ onChange({ ...dataSource, joins: next });
+ };
+
+ const updateJoin = (index: number, partial: Partial) => {
+ const next = joins.map((j, i) =>
+ i === index ? { ...j, ...partial } : j
+ );
+ onChange({ ...dataSource, joins: next });
+ };
+
+ return (
+
+
+ 다른 테이블의 데이터를 연결하여 함께 표시할 수 있습니다 (선택사항)
+
+ {joins.map((join, i) => (
+
updateJoin(i, partial)}
+ onRemove={() => removeJoin(i)}
+ />
+ ))}
+
+
+ 테이블 연결 추가
+
+
+ );
+}
+
+// ===== STEP 6-A: 리스트 컬럼 배치 =====
+
+// 조인 테이블 컬럼 정보
+interface JoinedColumnInfo {
+ name: string; // "테이블명.컬럼명" 형태
+ displayName: string; // 컬럼명만
+ sourceTable: string; // 테이블명
+}
+
+function StepListLayout({
+ listColumns,
+ availableColumns,
+ joinedColumns,
+ onChange,
+}: {
+ listColumns: ListColumnConfig[];
+ availableColumns: ColumnInfo[];
+ joinedColumns: JoinedColumnInfo[];
+ onChange: (listColumns: ListColumnConfig[]) => void;
+}) {
+ const widthBarRef = useRef(null);
+ const isDraggingRef = useRef(false);
+ const columnsRef = useRef(listColumns);
+ columnsRef.current = listColumns;
+ const [dragIdx, setDragIdx] = useState(null);
+ const [dragOverIdx, setDragOverIdx] = useState(null);
+ // 드래그 핸들에서만 draggable 활성화 (Select/Input 충돌 방지)
+ const [draggableRow, setDraggableRow] = useState(null);
+ // 컬럼 전환 설정 펼침 인덱스
+ const [expandedAltIdx, setExpandedAltIdx] = useState(null);
+
+ // 리스트에 현재 포함된 컬럼명 셋
+ const listColumnNames = new Set(listColumns.map((c) => c.columnName));
+
+ // 추가 가능한 컬럼: (메인 + 조인) 중 현재 리스트에 없는 것
+ const addableColumns = [
+ ...availableColumns
+ .filter((c) => !listColumnNames.has(c.name))
+ .map((c) => ({ value: c.name, label: c.name, source: "main" as const })),
+ ...joinedColumns
+ .filter((c) => !listColumnNames.has(c.name))
+ .map((c) => ({
+ value: c.name,
+ label: `${c.displayName} (${c.sourceTable})`,
+ source: "join" as const,
+ })),
+ ];
+
+ // 컬럼 추가 (독립 헤더로 추가 시 다른 컬럼의 alternateColumns에서 제거)
+ const addColumn = (columnValue: string) => {
+ const joinCol = joinedColumns.find((c) => c.name === columnValue);
+ const newCol: ListColumnConfig = {
+ columnName: columnValue,
+ label: joinCol?.displayName || columnValue,
+ };
+ // 다른 컬럼의 alternateColumns에서 이 컬럼 제거 (독립 헤더가 되므로)
+ const cleaned = listColumns.map((col) => {
+ const alts = col.alternateColumns;
+ if (!alts || !alts.includes(columnValue)) return col;
+ const newAlts = alts.filter((a) => a !== columnValue);
+ return { ...col, alternateColumns: newAlts.length > 0 ? newAlts : undefined };
+ });
+ onChange([...cleaned, newCol]);
+ };
+
+ // 컬럼 삭제 (리스트에서만 삭제, STEP 3 체크 유지)
+ const removeColumn = (index: number) => {
+ const next = listColumns.filter((_, i) => i !== index);
+ onChange(next);
+ // 펼침 인덱스 초기화 (삭제로 인덱스가 밀리므로)
+ setExpandedAltIdx(null);
+ };
+
+ // 전환 후보: (메인 + 조인) - 자기 자신 - 리스트에 독립 헤더로 있는 것
+ const getAlternateCandidates = (currentColumnName: string) => {
+ return [
+ ...availableColumns
+ .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name))
+ .map((c) => ({ value: c.name, label: c.name, source: "main" as const })),
+ ...joinedColumns
+ .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name))
+ .map((c) => ({
+ value: c.name,
+ label: c.displayName,
+ source: "join" as const,
+ sourceTable: c.sourceTable,
+ })),
+ ];
+ };
+
+ const updateColumn = (index: number, partial: Partial) => {
+ const next = listColumns.map((col, i) =>
+ i === index ? { ...col, ...partial } : col
+ );
+ onChange(next);
+ };
+
+ // 너비 드래그 핸들러
+ const handleWidthDrag = useCallback(
+ (e: React.MouseEvent, dividerIndex: number) => {
+ e.preventDefault();
+ isDraggingRef.current = true;
+ const startX = e.clientX;
+ const bar = widthBarRef.current;
+ if (!bar) return;
+ const barWidth = bar.offsetWidth;
+ if (barWidth === 0) return;
+ const cols = columnsRef.current;
+ const startFrs = cols.map((c) => {
+ const num = parseFloat(c.width || "1");
+ return isNaN(num) || num <= 0 ? 1 : num;
+ });
+ const totalFr = startFrs.reduce((a, b) => a + b, 0);
+
+ const onMove = (moveEvent: MouseEvent) => {
+ const delta = moveEvent.clientX - startX;
+ const frDelta = (delta / barWidth) * totalFr;
+ const newFrs = [...startFrs];
+ newFrs[dividerIndex] = Math.max(0.3, startFrs[dividerIndex] + frDelta);
+ newFrs[dividerIndex + 1] = Math.max(
+ 0.3,
+ startFrs[dividerIndex + 1] - frDelta
+ );
+ const next = columnsRef.current.map((col, i) => ({
+ ...col,
+ width: `${Math.round(newFrs[i] * 10) / 10}fr`,
+ }));
+ onChange(next);
+ };
+ const onUp = () => {
+ isDraggingRef.current = false;
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ };
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ },
+ [onChange]
+ );
+
+ // 순서 드래그앤드롭 - 핸들에서 mousedown 시에만 draggable 활성화
+ const handleDragStart = (e: React.DragEvent, idx: number) => {
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", String(idx));
+ setDragIdx(idx);
+ };
+
+ const handleDragOver = (e: React.DragEvent, idx: number) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ setDragOverIdx(idx);
+ };
+
+ const handleDrop = (e: React.DragEvent, idx: number) => {
+ e.preventDefault();
+ if (dragIdx === null || dragIdx === idx) {
+ setDragIdx(null);
+ setDragOverIdx(null);
+ setDraggableRow(null);
+ return;
+ }
+ const next = [...listColumns];
+ const [moved] = next.splice(dragIdx, 1);
+ next.splice(idx, 0, moved);
+ onChange(next);
+ setDragIdx(null);
+ setDragOverIdx(null);
+ setDraggableRow(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIdx(null);
+ setDragOverIdx(null);
+ setDraggableRow(null);
+ };
+
+ if (listColumns.length === 0 && addableColumns.length === 0) {
+ return (
+
+ 컬럼을 먼저 선택하세요
+
+ );
+ }
+
+ return (
+
+ {/* 컬럼 너비 드래그 바 */}
+
+ {listColumns.map((col, i) => {
+ const fr = parseFloat(col.width || "1") || 1;
+ return (
+
+
+ {col.label || col.columnName}
+
+ {i < listColumns.length - 1 && (
+ handleWidthDrag(e, i)}
+ title="드래그하여 너비 조정"
+ />
+ )}
+
+ );
+ })}
+
+
+ {/* 컬럼별 설정 (드래그 순서 + 컬럼 선택 + 라벨 + 정렬) */}
+
+ {listColumns.map((col, i) => (
+
+ handleDragStart(e, i)}
+ onDragOver={(e) => handleDragOver(e, i)}
+ onDrop={(e) => handleDrop(e, i)}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "flex items-center gap-1 rounded px-1 py-0.5 transition-colors",
+ dragIdx === i && "opacity-40",
+ dragOverIdx === i && dragIdx !== i && "bg-primary/10 border-t-2 border-primary"
+ )}
+ >
+ {/* 드래그 핸들 - mousedown 시에만 행 draggable 활성화 */}
+
setDraggableRow(i)}
+ onMouseUp={() => setDraggableRow(null)}
+ >
+
+
+
+ {/* 컬럼 선택 드롭다운 (메인 + 조인 테이블 컬럼) */}
+
{
+ const colInfo = availableColumns.find((c) => c.name === v);
+ const joinInfo = joinedColumns.find((c) => c.name === v);
+ updateColumn(i, {
+ columnName: v,
+ label: colInfo?.name || joinInfo?.displayName || v,
+ alternateColumns: undefined, // 컬럼 변경 시 전환 옵션 초기화
+ });
+ }}
+ >
+
+
+
+
+ {availableColumns.length > 0 && (
+ <>
+ {availableColumns.map((ac) => (
+
+ {ac.name}
+
+ ))}
+ >
+ )}
+ {joinedColumns.length > 0 && (
+ <>
+ {joinedColumns.map((jc) => (
+
+ {jc.displayName}
+
+ ({jc.sourceTable})
+
+
+ ))}
+ >
+ )}
+
+
+
+ {/* 라벨 */}
+
updateColumn(i, { label: e.target.value })}
+ placeholder="라벨"
+ className="h-7 flex-1 text-[10px]"
+ />
+
+ {/* 정렬 */}
+
+ updateColumn(i, { align: v as ListColumnConfig["align"] })
+ }
+ >
+
+
+
+
+
+ 좌
+
+
+ 중
+
+
+ 우
+
+
+
+
+ {/* 컬럼 전환 버튼 (전환 후보가 있을 때만) */}
+ {getAlternateCandidates(col.columnName).length > 0 && (
+
+ setExpandedAltIdx(expandedAltIdx === i ? null : i)
+ }
+ className={cn(
+ "flex h-7 w-7 shrink-0 items-center justify-center rounded text-[10px] transition-colors",
+ (col.alternateColumns || []).length > 0
+ ? "bg-primary/10 text-primary"
+ : "text-muted-foreground hover:bg-muted"
+ )}
+ title="컬럼 전환 설정"
+ >
+
+
+ )}
+
+ {/* 컬럼 삭제 버튼 */}
+
removeColumn(i)}
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
+ title="컬럼 삭제"
+ >
+
+
+
+
+ {/* 전환 가능 컬럼 (펼침 시만 표시, 메인+조인 중 리스트 미포함분) */}
+ {expandedAltIdx === i && (() => {
+ const candidates = getAlternateCandidates(col.columnName);
+ if (candidates.length === 0) return null;
+ return (
+
+ 전환:
+ {candidates.map((cand) => {
+ const alts = col.alternateColumns || [];
+ const isAlt = alts.includes(cand.value);
+ return (
+ {
+ const newAlts = isAlt
+ ? alts.filter((a) => a !== cand.value)
+ : [...alts, cand.value];
+ updateColumn(i, {
+ alternateColumns: newAlts.length > 0 ? newAlts : undefined,
+ });
+ }}
+ className={cn(
+ "rounded border px-1.5 py-0.5 text-[8px] transition-colors",
+ isAlt
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-border hover:bg-muted"
+ )}
+ >
+ {cand.label}
+ {cand.source === "join" && (
+ *
+ )}
+
+ );
+ })}
+
+ );
+ })()}
+
+ ))}
+
+
+ {/* 컬럼 추가 */}
+ {addableColumns.length > 0 && (
+
+
+
+
+
+ {addableColumns.some((c) => c.source === "main") && (
+ <>
+
+ 메인 테이블
+
+ {addableColumns
+ .filter((c) => c.source === "main")
+ .map((c) => (
+
+ {c.label}
+
+ ))}
+ >
+ )}
+ {addableColumns.some((c) => c.source === "join") && (
+ <>
+
+ 조인 테이블
+
+ {addableColumns
+ .filter((c) => c.source === "join")
+ .map((c) => (
+
+ {c.label}
+
+ ))}
+ >
+ )}
+
+
+ )}
+
+
+ 행을 드래그하여 순서 변경 | 상단 바 경계를 드래그하여 너비 조정
+
+
+ );
+}
+
+// ===== STEP 6-B: 시각적 카드 그리드 디자이너 =====
+
+// fr 문자열을 숫자로 파싱 (예: "2fr" -> 2, "1fr" -> 1)
+const parseFr = (v: string): number => {
+ const num = parseFloat(v);
+ return isNaN(num) || num <= 0 ? 1 : num;
+};
+
+// 카드 그리드 반응형 안전 제약
+// - 6열 초과: 모바일(320px)에서 셀 30px 미만 → 텍스트 깨짐
+// - 6행 초과: 카드 1장 높이 과도 → 스크롤 과다
+// - gap 16px 초과: 셀 공간 부족
+// - fr 0.3 미만: 셀 보이지 않음
+const GRID_LIMITS = {
+ cols: { min: 1, max: 6 },
+ rows: { min: 1, max: 6 },
+ gap: { min: 0, max: 16 },
+ minFr: 0.3,
+} as const;
+
+// 행 높이 기본값 (px 기반 고정 높이)
+const DEFAULT_ROW_HEIGHT = 32;
+const MIN_ROW_HEIGHT = 24;
+
+// px 문자열에서 숫자 추출 (예: "32px" → 32)
+const parsePx = (v: string): number => {
+ const num = parseInt(v);
+ return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num;
+};
+
+// fr → px 마이그레이션 (기존 저장 데이터 호환)
+const migrateRowHeight = (v: string): string => {
+ if (!v || v.endsWith("fr")) {
+ return `${Math.round(parseFr(v) * DEFAULT_ROW_HEIGHT)}px`;
+ }
+ if (v.endsWith("px")) return v;
+ // 단위 없는 숫자인 경우
+ const num = parseInt(v);
+ return `${isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num}px`;
+};
+
+function StepCardDesigner({
+ cardGrid,
+ columns,
+ selectedColumns,
+ onChange,
+}: {
+ cardGrid: CardGridConfig | undefined;
+ columns: ColumnInfo[];
+ selectedColumns: string[];
+ onChange: (cardGrid: CardGridConfig) => void;
+}) {
+ // 셀에서 컬럼 선택 시 사용자가 선택한 컬럼만 표시
+ const availableColumns = columns.filter((c) =>
+ selectedColumns.includes(c.name)
+ );
+ const [selectedCellId, setSelectedCellId] = useState
(null);
+ const [mergeMode, setMergeMode] = useState(false);
+ const [mergeCellKeys, setMergeCellKeys] = useState>(new Set());
+ const widthBarRef = useRef(null);
+ const rowBarRef = useRef(null);
+ const gridRef = useRef(null);
+ const gridConfigRef = useRef(undefined);
+ const isDraggingRef = useRef(false);
+ const [gridLines, setGridLines] = useState<{
+ colLines: number[];
+ rowLines: number[];
+ }>({ colLines: [], rowLines: [] });
+
+ // 기본 카드 그리드 (rowHeights는 px 기반 고정 높이)
+ const rawGrid: CardGridConfig = cardGrid || {
+ rows: 1,
+ cols: 1,
+ colWidths: ["1fr"],
+ rowHeights: [`${DEFAULT_ROW_HEIGHT}px`],
+ gap: 4,
+ showBorder: true,
+ cells: [],
+ };
+
+ // 기존 fr 데이터 → px 자동 마이그레이션 + 길이 정규화
+ const migratedRowHeights = (
+ rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)
+ ).map(migrateRowHeight);
+
+ // colWidths/rowHeights 배열 길이와 cols/rows 수 불일치 보정
+ const safeColWidths = rawGrid.colWidths || [];
+ const normalizedColWidths =
+ safeColWidths.length >= rawGrid.cols
+ ? safeColWidths.slice(0, rawGrid.cols)
+ : [
+ ...safeColWidths,
+ ...Array(rawGrid.cols - safeColWidths.length).fill("1fr"),
+ ];
+ const normalizedRowHeights =
+ migratedRowHeights.length >= rawGrid.rows
+ ? migratedRowHeights.slice(0, rawGrid.rows)
+ : [
+ ...migratedRowHeights,
+ ...Array(rawGrid.rows - migratedRowHeights.length).fill(
+ `${DEFAULT_ROW_HEIGHT}px`
+ ),
+ ];
+
+ const grid: CardGridConfig = {
+ ...rawGrid,
+ colWidths: normalizedColWidths,
+ rowHeights: normalizedRowHeights,
+ };
+
+ gridConfigRef.current = grid;
+
+ const updateGrid = (partial: Partial) => {
+ onChange({ ...grid, ...partial });
+ };
+
+ // ---- 점유 맵 ----
+
+ const buildOccupationMap = (): Record => {
+ const map: Record = {};
+ grid.cells.forEach((cell) => {
+ const rs = Number(cell.rowSpan) || 1;
+ const cs = Number(cell.colSpan) || 1;
+ for (let r = cell.row; r < cell.row + rs; r++) {
+ for (let c = cell.col; c < cell.col + cs; c++) {
+ map[`${r}-${c}`] = cell.id;
+ }
+ }
+ });
+ return map;
+ };
+
+ const occupationMap = buildOccupationMap();
+
+ const getCellByOrigin = (r: number, c: number) =>
+ grid.cells.find((cell) => cell.row === r && cell.col === c);
+
+ // ---- 셀 CRUD ----
+
+ const addCellAt = (row: number, col: number) => {
+ const newCell: CardCellDefinition = {
+ id: `cell-${Date.now()}`,
+ row,
+ col,
+ rowSpan: 1,
+ colSpan: 1,
+ columnName: "",
+ type: "text",
+ };
+ updateGrid({ cells: [...grid.cells, newCell] });
+ setSelectedCellId(newCell.id);
+ };
+
+ const removeCell = (id: string) => {
+ updateGrid({ cells: grid.cells.filter((c) => c.id !== id) });
+ if (selectedCellId === id) setSelectedCellId(null);
+ };
+
+ const updateCell = (id: string, partial: Partial