@@ -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/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx
index 2999ed74..6242cd89 100644
--- a/frontend/components/screen/panels/V2PropertiesPanel.tsx
+++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx
@@ -237,6 +237,8 @@ export const V2PropertiesPanel: React.FC = ({
const extraProps: Record = {};
if (componentId === "v2-select") {
extraProps.inputType = inputType;
+ extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
+ extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
}
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx
index fe21b790..f0021eeb 100644
--- a/frontend/components/v2/V2Select.tsx
+++ b/frontend/components/v2/V2Select.tsx
@@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
-import { V2SelectProps, SelectOption } from "@/types/v2-components";
+import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components";
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import V2FormContext from "./V2FormContext";
@@ -655,6 +655,7 @@ export const V2Select = forwardRef(
const labelColumn = config.labelColumn;
const apiEndpoint = config.apiEndpoint;
const staticOptions = config.options;
+ const configFilters = config.filters;
// 계층 코드 연쇄 선택 관련
const hierarchical = config.hierarchical;
@@ -662,6 +663,54 @@ export const V2Select = forwardRef(
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
const formContext = useContext(V2FormContext);
+
+ /**
+ * 필터 조건을 API 전달용 JSON으로 변환
+ * field/user 타입은 런타임 값으로 치환
+ */
+ const resolvedFiltersJson = useMemo(() => {
+ if (!configFilters || configFilters.length === 0) return undefined;
+
+ const resolved: Array<{ column: string; operator: string; value: unknown }> = [];
+
+ for (const f of configFilters) {
+ const vt = f.valueType || "static";
+
+ // isNull/isNotNull은 값 불필요
+ if (f.operator === "isNull" || f.operator === "isNotNull") {
+ resolved.push({ column: f.column, operator: f.operator, value: null });
+ continue;
+ }
+
+ let resolvedValue: unknown = f.value;
+
+ if (vt === "field" && f.fieldRef) {
+ // 다른 폼 필드 참조
+ if (formContext) {
+ resolvedValue = formContext.getValue(f.fieldRef);
+ } else {
+ const fd = (props as any).formData;
+ resolvedValue = fd?.[f.fieldRef];
+ }
+ // 참조 필드 값이 비어있으면 이 필터 건너뜀
+ if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue;
+ } else if (vt === "user" && f.userField) {
+ // 로그인 사용자 정보 참조 (props에서 가져옴)
+ const userMap: Record = {
+ companyCode: (props as any).companyCode,
+ userId: (props as any).userId,
+ deptCode: (props as any).deptCode,
+ userName: (props as any).userName,
+ };
+ resolvedValue = userMap[f.userField];
+ if (!resolvedValue) continue;
+ }
+
+ resolved.push({ column: f.column, operator: f.operator, value: resolvedValue });
+ }
+
+ return resolved.length > 0 ? JSON.stringify(resolved) : undefined;
+ }, [configFilters, formContext, props]);
// 부모 필드의 값 계산
const parentValue = useMemo(() => {
@@ -684,6 +733,13 @@ export const V2Select = forwardRef(
}
}, [parentValue, hierarchical, source]);
+ // 필터 조건이 변경되면 옵션 다시 로드
+ useEffect(() => {
+ if (resolvedFiltersJson !== undefined) {
+ setOptionsLoaded(false);
+ }
+ }, [resolvedFiltersJson]);
+
useEffect(() => {
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
if (optionsLoaded && source !== "static") {
@@ -731,11 +787,13 @@ export const V2Select = forwardRef(
}
} else if (source === "db" && table) {
// DB 테이블에서 로드
+ const dbParams: Record = {
+ value: valueColumn || "id",
+ label: labelColumn || "name",
+ };
+ if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${table}/options`, {
- params: {
- value: valueColumn || "id",
- label: labelColumn || "name",
- },
+ params: dbParams,
});
const data = response.data;
if (data.success && data.data) {
@@ -745,8 +803,10 @@ export const V2Select = forwardRef(
// 엔티티(참조 테이블)에서 로드
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
+ const entityParams: Record = { value: valueCol, label: labelCol };
+ if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${entityTable}/options`, {
- params: { value: valueCol, label: labelCol },
+ params: entityParams,
});
const data = response.data;
if (data.success && data.data) {
@@ -790,11 +850,13 @@ export const V2Select = forwardRef(
}
} else if (source === "select" || source === "distinct") {
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
- // tableName, columnName은 props에서 가져옴
- // 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
const isValidColumnName = columnName && !columnName.startsWith("comp_");
if (tableName && isValidColumnName) {
- const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
+ const distinctParams: Record = {};
+ if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson;
+ const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, {
+ params: distinctParams,
+ });
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
@@ -818,7 +880,7 @@ export const V2Select = forwardRef(
};
loadOptions();
- }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
+ }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]);
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => {
diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx
index f808ecf1..66ebb369 100644
--- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx
+++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx
@@ -5,56 +5,401 @@
* 통합 선택 컴포넌트의 세부 설정을 관리합니다.
*/
-import React, { useState, useEffect, useCallback } from "react";
+import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
-import { Plus, Trash2, Loader2 } from "lucide-react";
+import { Plus, Trash2, Loader2, Filter } from "lucide-react";
import { apiClient } from "@/lib/api/client";
+import type { V2SelectFilter } from "@/types/v2-components";
interface ColumnOption {
columnName: string;
columnLabel: string;
}
+interface CategoryValueOption {
+ valueCode: string;
+ valueLabel: string;
+}
+
+const OPERATOR_OPTIONS = [
+ { value: "=", label: "같음 (=)" },
+ { value: "!=", label: "다름 (!=)" },
+ { value: ">", label: "초과 (>)" },
+ { value: "<", label: "미만 (<)" },
+ { value: ">=", label: "이상 (>=)" },
+ { value: "<=", label: "이하 (<=)" },
+ { value: "in", label: "포함 (IN)" },
+ { value: "notIn", label: "미포함 (NOT IN)" },
+ { value: "like", label: "유사 (LIKE)" },
+ { value: "isNull", label: "NULL" },
+ { value: "isNotNull", label: "NOT NULL" },
+] as const;
+
+const VALUE_TYPE_OPTIONS = [
+ { value: "static", label: "고정값" },
+ { value: "field", label: "폼 필드 참조" },
+ { value: "user", label: "로그인 사용자" },
+] as const;
+
+const USER_FIELD_OPTIONS = [
+ { value: "companyCode", label: "회사코드" },
+ { value: "userId", label: "사용자ID" },
+ { value: "deptCode", label: "부서코드" },
+ { value: "userName", label: "사용자명" },
+] as const;
+
+/**
+ * 필터 조건 설정 서브 컴포넌트
+ */
+const FilterConditionsSection: React.FC<{
+ filters: V2SelectFilter[];
+ columns: ColumnOption[];
+ loadingColumns: boolean;
+ targetTable: string;
+ onFiltersChange: (filters: V2SelectFilter[]) => void;
+}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
+
+ const addFilter = () => {
+ onFiltersChange([
+ ...filters,
+ { column: "", operator: "=", valueType: "static", value: "" },
+ ]);
+ };
+
+ const updateFilter = (index: number, patch: Partial) => {
+ const updated = [...filters];
+ updated[index] = { ...updated[index], ...patch };
+
+ // valueType 변경 시 관련 필드 초기화
+ if (patch.valueType) {
+ if (patch.valueType === "static") {
+ updated[index].fieldRef = undefined;
+ updated[index].userField = undefined;
+ } else if (patch.valueType === "field") {
+ updated[index].value = undefined;
+ updated[index].userField = undefined;
+ } else if (patch.valueType === "user") {
+ updated[index].value = undefined;
+ updated[index].fieldRef = undefined;
+ }
+ }
+
+ // isNull/isNotNull 연산자는 값 불필요
+ if (patch.operator === "isNull" || patch.operator === "isNotNull") {
+ updated[index].value = undefined;
+ updated[index].fieldRef = undefined;
+ updated[index].userField = undefined;
+ updated[index].valueType = "static";
+ }
+
+ onFiltersChange(updated);
+ };
+
+ const removeFilter = (index: number) => {
+ onFiltersChange(filters.filter((_, i) => i !== index));
+ };
+
+ const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
+
+ return (
+
+
+
+
+ 데이터 필터 조건
+
+
+
+ 추가
+
+
+
+
+ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건
+
+
+ {loadingColumns && (
+
+
+ 컬럼 목록 로딩 중...
+
+ )}
+
+ {filters.length === 0 && (
+
+ 필터 조건이 없습니다
+
+ )}
+
+
+ {filters.map((filter, index) => (
+
+ ))}
+
+
+ );
+};
+
interface V2SelectConfigPanelProps {
config: Record;
onChange: (config: Record) => void;
- /** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */
+ /** 컬럼의 inputType (entity/category 타입 확인용) */
inputType?: string;
+ /** 현재 테이블명 (카테고리 값 조회용) */
+ tableName?: string;
+ /** 현재 컬럼명 (카테고리 값 조회용) */
+ columnName?: string;
}
-export const V2SelectConfigPanel: React.FC = ({ config, onChange, inputType }) => {
- // 엔티티 타입인지 확인
+export const V2SelectConfigPanel: React.FC = ({
+ config,
+ onChange,
+ inputType,
+ tableName,
+ columnName,
+}) => {
const isEntityType = inputType === "entity";
- // 엔티티 테이블의 컬럼 목록
+ const isCategoryType = inputType === "category";
+
const [entityColumns, setEntityColumns] = useState([]);
const [loadingColumns, setLoadingColumns] = useState(false);
- // 설정 업데이트 핸들러
+ // 카테고리 값 목록
+ const [categoryValues, setCategoryValues] = useState([]);
+ const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
+
+ // 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼)
+ const [filterColumns, setFilterColumns] = useState([]);
+ const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
+
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
+ // 필터 대상 테이블 결정
+ const filterTargetTable = useMemo(() => {
+ const src = config.source || "static";
+ if (src === "entity") return config.entityTable;
+ if (src === "db") return config.table;
+ if (src === "distinct" || src === "select") return tableName;
+ return null;
+ }, [config.source, config.entityTable, config.table, tableName]);
+
+ // 필터 대상 테이블의 컬럼 로드
+ useEffect(() => {
+ if (!filterTargetTable) {
+ setFilterColumns([]);
+ return;
+ }
+
+ const loadFilterColumns = async () => {
+ setLoadingFilterColumns(true);
+ try {
+ const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
+ const data = response.data.data || response.data;
+ const columns = data.columns || data || [];
+ setFilterColumns(
+ columns.map((col: any) => ({
+ columnName: col.columnName || col.column_name || col.name,
+ columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
+ }))
+ );
+ } catch {
+ setFilterColumns([]);
+ } finally {
+ setLoadingFilterColumns(false);
+ }
+ };
+
+ loadFilterColumns();
+ }, [filterTargetTable]);
+
+ // 카테고리 타입이면 source를 자동으로 category로 설정
+ useEffect(() => {
+ if (isCategoryType && config.source !== "category") {
+ onChange({ ...config, source: "category" });
+ }
+ }, [isCategoryType]);
+
+ // 카테고리 값 로드
+ const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
+ if (!catTable || !catColumn) {
+ setCategoryValues([]);
+ return;
+ }
+
+ setLoadingCategoryValues(true);
+ try {
+ const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
+ const data = response.data;
+ if (data.success && data.data) {
+ const flattenTree = (items: any[], depth: number = 0): CategoryValueOption[] => {
+ const result: CategoryValueOption[] = [];
+ for (const item of items) {
+ result.push({
+ valueCode: item.valueCode,
+ valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel,
+ });
+ if (item.children && item.children.length > 0) {
+ result.push(...flattenTree(item.children, depth + 1));
+ }
+ }
+ return result;
+ };
+ setCategoryValues(flattenTree(data.data));
+ }
+ } catch (error) {
+ console.error("카테고리 값 조회 실패:", error);
+ setCategoryValues([]);
+ } finally {
+ setLoadingCategoryValues(false);
+ }
+ }, []);
+
+ // 카테고리 소스일 때 값 로드
+ useEffect(() => {
+ if (config.source === "category") {
+ const catTable = config.categoryTable || tableName;
+ const catColumn = config.categoryColumn || columnName;
+ if (catTable && catColumn) {
+ loadCategoryValues(catTable, catColumn);
+ }
+ }
+ }, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
+
// 엔티티 테이블 변경 시 컬럼 목록 조회
- const loadEntityColumns = useCallback(async (tableName: string) => {
- if (!tableName) {
+ const loadEntityColumns = useCallback(async (tblName: string) => {
+ if (!tblName) {
setEntityColumns([]);
return;
}
setLoadingColumns(true);
try {
- const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`);
+ const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
const columnOptions: ColumnOption[] = columns.map((col: any) => {
const name = col.columnName || col.column_name || col.name;
- // displayName 우선 사용
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
return {
@@ -72,7 +417,6 @@ export const V2SelectConfigPanel: React.FC = ({ config
}
}, []);
- // 엔티티 테이블이 변경되면 컬럼 목록 로드
useEffect(() => {
if (config.source === "entity" && config.entityTable) {
loadEntityColumns(config.entityTable);
@@ -98,6 +442,9 @@ export const V2SelectConfigPanel: React.FC = ({ config
updateConfig("options", newOptions);
};
+ // 현재 source 결정 (카테고리 타입이면 강제 category)
+ const effectiveSource = isCategoryType ? "category" : config.source || "static";
+
return (
{/* 선택 모드 */}
@@ -125,21 +472,102 @@ export const V2SelectConfigPanel: React.FC
= ({ config
{/* 데이터 소스 */}
데이터 소스
-
updateConfig("source", value)}>
-
-
-
-
- 정적 옵션
- 공통 코드
- {/* 엔티티 타입일 때만 엔티티 옵션 표시 */}
- {isEntityType && 엔티티 }
-
-
+ {isCategoryType ? (
+
+ 카테고리 (자동 설정)
+
+ ) : (
+
updateConfig("source", value)}>
+
+
+
+
+ 정적 옵션
+ 공통 코드
+ 카테고리
+ {isEntityType && 엔티티 }
+
+
+ )}
+ {/* 카테고리 설정 */}
+ {effectiveSource === "category" && (
+
+
+
카테고리 정보
+
+
+
+
테이블
+
{config.categoryTable || tableName || "-"}
+
+
+
컬럼
+
{config.categoryColumn || columnName || "-"}
+
+
+
+
+
+ {/* 카테고리 값 로딩 중 */}
+ {loadingCategoryValues && (
+
+
+ 카테고리 값 로딩 중...
+
+ )}
+
+ {/* 카테고리 값 목록 표시 */}
+ {categoryValues.length > 0 && (
+
+
카테고리 값 ({categoryValues.length}개)
+
+ {categoryValues.map((cv) => (
+
+ {cv.valueCode}
+ {cv.valueLabel}
+
+ ))}
+
+
+ )}
+
+ {/* 기본값 설정 */}
+ {categoryValues.length > 0 && (
+
+
기본값
+
updateConfig("defaultValue", value === "_none_" ? "" : value)}
+ >
+
+
+
+
+ 선택 안함
+ {categoryValues.map((cv) => (
+
+ {cv.valueLabel}
+
+ ))}
+
+
+
화면 로드 시 자동 선택될 카테고리 값
+
+ )}
+
+ {/* 카테고리 값 없음 안내 */}
+ {!loadingCategoryValues && categoryValues.length === 0 && (
+
+ 카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.
+
+ )}
+
+ )}
+
{/* 정적 옵션 관리 */}
- {(config.source || "static") === "static" && (
+ {effectiveSource === "static" && (
옵션 목록
@@ -199,8 +627,8 @@ export const V2SelectConfigPanel: React.FC = ({ config
)}
- {/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */}
- {config.source === "code" && (
+ {/* 공통 코드 설정 */}
+ {effectiveSource === "code" && (
코드 그룹
{config.codeGroup ? (
@@ -212,7 +640,7 @@ export const V2SelectConfigPanel: React.FC
= ({ config
)}
{/* 엔티티(참조 테이블) 설정 */}
- {config.source === "entity" && (
+ {effectiveSource === "entity" && (
참조 테이블
@@ -228,7 +656,6 @@ export const V2SelectConfigPanel: React.FC = ({ config
- {/* 컬럼 로딩 중 표시 */}
{loadingColumns && (
@@ -236,7 +663,6 @@ export const V2SelectConfigPanel: React.FC = ({ config
)}
- {/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
값 컬럼 (코드)
@@ -296,18 +722,17 @@ export const V2SelectConfigPanel: React.FC = ({ config
- {/* 컬럼이 없는 경우 안내 */}
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
)}
- {/* 자동 채움 안내 */}
{config.entityTable && entityColumns.length > 0 && (
- 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 채워집니다.
+ 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로
+ 채워집니다.
)}
@@ -368,6 +793,20 @@ export const V2SelectConfigPanel: React.FC
= ({ config
/>
)}
+
+ {/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */}
+ {effectiveSource !== "static" && filterTargetTable && (
+ <>
+
+ updateConfig("filters", filters)}
+ />
+ >
+ )}
);
};
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/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
index 5ad6d0eb..d57ae60b 100644
--- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
@@ -162,6 +162,79 @@ export function RepeaterTable({
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
const initializedRef = useRef(false);
+ // 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용)
+ const editableColIndices = useMemo(
+ () => visibleColumns.reduce((acc, col, idx) => {
+ if (col.editable && !col.calculated) acc.push(idx);
+ return acc;
+ }, []),
+ [visibleColumns],
+ );
+
+ // 방향키로 리피터 셀 간 이동
+ const handleArrowNavigation = useCallback(
+ (e: React.KeyboardEvent) => {
+ const key = e.key;
+ if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return;
+
+ const target = e.target as HTMLElement;
+ const cell = target.closest("[data-repeater-row]") as HTMLElement | null;
+ if (!cell) return;
+
+ const row = Number(cell.dataset.repeaterRow);
+ const col = Number(cell.dataset.repeaterCol);
+ if (isNaN(row) || isNaN(col)) return;
+
+ // 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시
+ if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") {
+ const input = target as HTMLInputElement;
+ const len = input.value?.length ?? 0;
+ const pos = input.selectionStart ?? 0;
+ // 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동
+ if (key === "ArrowRight" && pos < len) return;
+ if (key === "ArrowLeft" && pos > 0) return;
+ }
+
+ let nextRow = row;
+ let nextColPos = editableColIndices.indexOf(col);
+
+ switch (key) {
+ case "ArrowUp":
+ nextRow = Math.max(0, row - 1);
+ break;
+ case "ArrowDown":
+ nextRow = Math.min(data.length - 1, row + 1);
+ break;
+ case "ArrowLeft":
+ nextColPos = Math.max(0, nextColPos - 1);
+ break;
+ case "ArrowRight":
+ nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1);
+ break;
+ }
+
+ const nextCol = editableColIndices[nextColPos];
+ if (nextRow === row && nextCol === col) return;
+
+ e.preventDefault();
+
+ const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`;
+ const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null;
+ if (!nextCell) return;
+
+ const focusable = nextCell.querySelector(
+ 'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])',
+ );
+ if (focusable) {
+ focusable.focus();
+ if (focusable.tagName === "INPUT") {
+ (focusable as HTMLInputElement).select();
+ }
+ }
+ },
+ [editableColIndices, data.length],
+ );
+
// DnD 센서 설정
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -648,7 +721,7 @@ export function RepeaterTable({
return (
-
+
{renderCell(row, col, rowIndex)}
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/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
index 1eaef469..30584fc4 100644
--- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
@@ -5777,12 +5777,6 @@ export const TableListComponent: React.FC = ({
renderCheckboxHeader()
) : (
- {/* 🆕 편집 불가 컬럼 표시 */}
- {column.editable === false && (
-
-
-
- )}
{columnLabels[column.columnName] || column.displayName}
{column.sortable !== false && sortColumn === column.columnName && (
{sortDirection === "asc" ? "↑" : "↓"}
diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx
index becd3c34..35f15596 100644
--- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx
+++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx
@@ -1333,7 +1333,38 @@ export const TableListConfigPanel: React.FC
= ({
/>
{column.label || column.columnName}
-
+ {isAdded && (
+ c.columnName === column.columnName)?.editable === false
+ ? "편집 잠금 (클릭하여 해제)"
+ : "편집 가능 (클릭하여 잠금)"
+ }
+ className={cn(
+ "ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
+ config.columns?.find((c) => c.columnName === column.columnName)?.editable === false
+ ? "text-destructive hover:bg-destructive/10"
+ : "text-muted-foreground hover:bg-muted",
+ )}
+ onClick={(e) => {
+ e.stopPropagation();
+ const currentCol = config.columns?.find((c) => c.columnName === column.columnName);
+ if (currentCol) {
+ updateColumn(column.columnName, {
+ editable: currentCol.editable === false ? undefined : false,
+ });
+ }
+ }}
+ >
+ {config.columns?.find((c) => c.columnName === column.columnName)?.editable === false ? (
+
+ ) : (
+
+ )}
+
+ )}
+
{column.input_type || column.dataType}
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 })}
+ >
+