From df8cbb3e80e7a68e2f248e71738bac0e8a440847 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 23 Feb 2026 13:54:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20=EC=95=A1=EC=85=98=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20+=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20(STEP=200~7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executePopAction / usePopAction 훅 신규 생성 - pop-button을 usePopAction 기반으로 리팩토링 - PopModalDefinition 타입 + MODAL_SIZE_PRESETS 정의 - PopDesignerContext 신규 생성 (모달 탭 상태 공유) - PopDesigner에 모달 탭 UI 추가 (메인 캔버스 / 모달 캔버스 전환) - PopCanvas에 접이식 ModalSizeSettingsPanel + ModalThumbnailPreview 구현 - PopViewerWithModals 신규 생성 (뷰어 모달 렌더링 + 스택 관리) - FULL 모달 전체화면 지원 (h-dvh, w-screen, rounded-none) - pop-string-list 카드 버튼 액션 연동 - pop-icon / SelectedItemsDetailInput lucide import 최적화 - tsconfig skipLibCheck 설정 추가 Co-authored-by: Cursor --- .../app/(pop)/pop/screens/[screenId]/page.tsx | 6 +- .../components/pop/designer/PopCanvas.tsx | 383 +++++++++++++++++- .../components/pop/designer/PopDesigner.tsx | 192 +++++++-- .../pop/designer/PopDesignerContext.tsx | 35 ++ .../pop/designer/renderers/PopRenderer.tsx | 13 +- .../pop/designer/types/pop-layout.ts | 91 +++++ .../pop/viewer/PopViewerWithModals.tsx | 174 ++++++++ frontend/hooks/pop/executePopAction.ts | 199 +++++++++ frontend/hooks/pop/index.ts | 8 + frontend/hooks/pop/usePopAction.ts | 218 ++++++++++ .../SelectedItemsDetailInputComponent.tsx | 32 +- .../registry/pop-components/pop-button.tsx | 229 ++++------- .../lib/registry/pop-components/pop-icon.tsx | 22 +- .../PopStringListComponent.tsx | 103 ++++- .../pop-components/pop-string-list/types.ts | 5 + frontend/tsconfig.json | 2 +- 16 files changed, 1492 insertions(+), 220 deletions(-) create mode 100644 frontend/components/pop/designer/PopDesignerContext.tsx create mode 100644 frontend/components/pop/viewer/PopViewerWithModals.tsx create mode 100644 frontend/hooks/pop/executePopAction.ts create mode 100644 frontend/hooks/pop/usePopAction.ts diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index d17ced93..4bf78be0 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -26,7 +26,7 @@ import { } from "@/components/pop/designer/types/pop-layout"; // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) import "@/lib/registry/pop-components"; -import PopRenderer from "@/components/pop/designer/renderers/PopRenderer"; +import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; import { useResponsiveModeWithOverride, type DeviceType, @@ -294,11 +294,11 @@ function PopScreenViewPage() { const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); return ( - diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 6ac2cea9..19a0fd55 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -13,8 +13,12 @@ import { GAP_PRESETS, GRID_BREAKPOINTS, DEFAULT_COMPONENT_GRID_SIZE, + PopModalDefinition, + ModalSizePreset, + MODAL_SIZE_PRESETS, + resolveModalWidth, } from "./types/pop-layout"; -import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useDrag } from "react-dnd"; import { Button } from "@/components/ui/button"; import { @@ -114,6 +118,12 @@ interface PopCanvasProps { onChangeGapPreset?: (preset: GapPreset) => void; /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ previewPageIndex?: number; + /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ + activeCanvasId?: string; + /** 캔버스 전환 콜백 */ + onActiveCanvasChange?: (canvasId: string) => void; + /** 모달 정의 업데이트 콜백 */ + onUpdateModal?: (modalId: string, updates: Partial) => void; } // ======================================== @@ -138,7 +148,41 @@ export default function PopCanvas({ onResetOverride, onChangeGapPreset, previewPageIndex, + activeCanvasId = "main", + onActiveCanvasChange, + onUpdateModal, }: PopCanvasProps) { + // 모달 탭 데이터 + const modalTabs = useMemo(() => { + const tabs: { id: string; label: string }[] = [{ id: "main", label: "메인화면" }]; + if (layout.modals?.length) { + for (const modal of layout.modals) { + const numbering = modal.id.replace("modal-", ""); + tabs.push({ id: modal.id, label: `모달화면 ${numbering}` }); + } + } + return tabs; + }, [layout.modals]); + + // activeCanvasId에 따라 렌더링할 layout 분기 + const activeLayout = useMemo((): PopLayoutDataV5 => { + if (activeCanvasId === "main") return layout; + const modal = layout.modals?.find(m => m.id === activeCanvasId); + if (!modal) return layout; // fallback + return { + ...layout, + gridConfig: modal.gridConfig, + components: modal.components, + overrides: modal.overrides, + }; + }, [layout, activeCanvasId]); + + // 현재 활성 모달 정의 (모달 캔버스일 때만) + const activeModal = useMemo(() => { + if (activeCanvasId === "main") return null; + return layout.modals?.find(m => m.id === activeCanvasId) || null; + }, [layout.modals, activeCanvasId]); + // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); @@ -165,12 +209,12 @@ export default function PopCanvas({ const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); - // 숨김 컴포넌트 ID 목록 - const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || []; + // 숨김 컴포넌트 ID 목록 (activeLayout 기반) + const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; // 동적 캔버스 높이 계산 (컴포넌트 배치 기반) const dynamicCanvasHeight = useMemo(() => { - const visibleComps = Object.values(layout.components).filter( + const visibleComps = Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); @@ -189,7 +233,7 @@ export default function PopCanvas({ const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2; return Math.max(MIN_CANVAS_HEIGHT, height); - }, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); + }, [activeLayout.components, activeLayout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); // 그리드 라벨 계산 (동적 행 수) const gridLabels = useMemo(() => { @@ -303,7 +347,7 @@ export default function PopCanvas({ }; // 현재 모드에서의 유효 위치들로 중첩 검사 - const effectivePositions = getAllEffectivePositions(layout, currentMode); + const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); const existingPositions = Array.from(effectivePositions.values()); const hasOverlap = existingPositions.some(pos => @@ -349,7 +393,7 @@ export default function PopCanvas({ const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean }; // 현재 모드에서의 유효 위치들 가져오기 - const effectivePositions = getAllEffectivePositions(layout, currentMode); + const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 @@ -401,42 +445,42 @@ export default function PopCanvas({ canDrop: monitor.canDrop(), }), }), - [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] + [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, activeLayout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] ); drop(canvasRef); - // 빈 상태 체크 - const isEmpty = Object.keys(layout.components).length === 0; + // 빈 상태 체크 (activeLayout 기반) + const isEmpty = Object.keys(activeLayout.components).length === 0; - // 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨) + // 숨김 처리된 컴포넌트 객체 목록 const hiddenComponents = useMemo(() => { return hiddenComponentIds - .map(id => layout.components[id]) + .map(id => activeLayout.components[id]) .filter(Boolean); - }, [hiddenComponentIds, layout.components]); + }, [hiddenComponentIds, activeLayout.components]); // 표시되는 컴포넌트 목록 (숨김 제외) const visibleComponents = useMemo(() => { - return Object.values(layout.components).filter( + return Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); - }, [layout.components, hiddenComponentIds]); + }, [activeLayout.components, hiddenComponentIds]); // 검토 필요 컴포넌트 목록 const reviewComponents = useMemo(() => { return visibleComponents.filter(comp => { - const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id]; + const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id]; return needsReview(currentMode, hasOverride); }); - }, [visibleComponents, layout.overrides, currentMode]); + }, [visibleComponents, activeLayout.overrides, currentMode]); // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; // 12칸 모드가 아닐 때만 패널 표시 // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 - const hasGridComponents = Object.keys(layout.components).length > 0; + const hasGridComponents = Object.keys(activeLayout.components).length > 0; const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); const showRightPanel = showReviewPanel || showHiddenPanel; @@ -576,6 +620,32 @@ export default function PopCanvas({ + {/* 모달 탭 바 (모달이 1개 이상 있을 때만 표시) */} + {modalTabs.length > 1 && ( +
+ {modalTabs.map(tab => ( + + ))} +
+ )} + + {/* 모달 사이즈 설정 패널 (모달 캔버스 활성 시) */} + {activeModal && ( + onUpdateModal?.(activeModal.id, updates)} + /> + )} + {/* 캔버스 영역 */}
); } + +// ======================================== +// 모달 사이즈 설정 패널 +// ======================================== + +const SIZE_PRESET_ORDER: ModalSizePreset[] = ["sm", "md", "lg", "xl", "full"]; + +const MODE_LABELS: { mode: GridMode; label: string; icon: typeof Smartphone; width: number }[] = [ + { mode: "mobile_portrait", label: "모바일 세로", icon: Smartphone, width: 375 }, + { mode: "mobile_landscape", label: "모바일 가로", icon: Smartphone, width: 667 }, + { mode: "tablet_portrait", label: "태블릿 세로", icon: Tablet, width: 768 }, + { mode: "tablet_landscape", label: "태블릿 가로", icon: Monitor, width: 1024 }, +]; + +function ModalSizeSettingsPanel({ + modal, + currentMode, + onUpdate, +}: { + modal: PopModalDefinition; + currentMode: GridMode; + onUpdate: (updates: Partial) => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const sizeConfig = modal.sizeConfig || { default: "md" }; + const usePerMode = !!sizeConfig.modeOverrides && Object.keys(sizeConfig.modeOverrides).length > 0; + + const currentModeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; + const currentModeWidth = currentModeInfo.width; + const currentModalWidth = resolveModalWidth( + { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, + currentMode, + currentModeWidth, + ); + + const handleDefaultChange = (preset: ModalSizePreset) => { + onUpdate({ + sizeConfig: { + ...sizeConfig, + default: preset, + }, + }); + }; + + const handleTogglePerMode = () => { + if (usePerMode) { + onUpdate({ + sizeConfig: { + default: sizeConfig.default, + }, + }); + } else { + onUpdate({ + sizeConfig: { + ...sizeConfig, + modeOverrides: { + mobile_portrait: sizeConfig.default, + mobile_landscape: sizeConfig.default, + tablet_portrait: sizeConfig.default, + tablet_landscape: sizeConfig.default, + }, + }, + }); + } + }; + + const handleModeChange = (mode: GridMode, preset: ModalSizePreset) => { + onUpdate({ + sizeConfig: { + ...sizeConfig, + modeOverrides: { + ...sizeConfig.modeOverrides, + [mode]: preset, + }, + }, + }); + }; + + return ( +
+ {/* 헤더 (항상 표시) */} + + + {/* 펼침 영역 */} + {isExpanded && ( +
+ {/* 기본 사이즈 선택 */} +
+ 모달 사이즈 +
+ {SIZE_PRESET_ORDER.map(preset => { + const info = MODAL_SIZE_PRESETS[preset]; + return ( + + ); + })} +
+
+ + {/* 모드별 개별 설정 토글 */} +
+ 모드별 개별 사이즈 + +
+ + {/* 모드별 설정 */} + {usePerMode && ( +
+ {MODE_LABELS.map(({ mode, label, icon: Icon }) => { + const modePreset = sizeConfig.modeOverrides?.[mode] ?? sizeConfig.default; + return ( +
+
+ + {label} +
+
+ {SIZE_PRESET_ORDER.map(preset => ( + + ))} +
+
+ ); + })} +
+ )} + + {/* 캔버스 축소판 미리보기 */} + +
+ )} +
+ ); +} + +// ======================================== +// 모달 사이즈 썸네일 미리보기 (캔버스 축소판 + 모달 오버레이) +// ======================================== + +function ModalThumbnailPreview({ + sizeConfig, + currentMode, +}: { + sizeConfig: { default: ModalSizePreset; modeOverrides?: Partial> }; + currentMode: GridMode; +}) { + const PREVIEW_WIDTH = 260; + const ASPECT_RATIO = 0.65; + + const modeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; + const modeWidth = modeInfo.width; + const modeHeight = modeWidth * ASPECT_RATIO; + + const scale = PREVIEW_WIDTH / modeWidth; + const previewHeight = Math.round(modeHeight * scale); + + const modalWidth = resolveModalWidth( + { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, + currentMode, + modeWidth, + ); + const scaledModalWidth = Math.min(Math.round(modalWidth * scale), PREVIEW_WIDTH); + const isFull = modalWidth >= modeWidth; + const scaledModalHeight = isFull ? previewHeight : Math.round(previewHeight * 0.75); + const Icon = modeInfo.icon; + + return ( +
+
+ 미리보기 +
+ + {modeInfo.label} +
+
+ +
+ {/* 반투명 배경 오버레이 (모달이 열렸을 때의 딤 효과) */} +
+ + {/* 모달 영역 (가운데 정렬, FULL이면 전체 채움) */} +
+
+ 모달 +
+
+ + {/* 하단 수치 표시 */} +
+ {isFull ? "FULL" : `${modalWidth}px`} / {modeWidth}px +
+
+
+ ); +} diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 16fa0da8..e3623f1b 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -28,11 +28,14 @@ import { createEmptyPopLayoutV5, isV5Layout, addComponentToV5Layout, + createComponentDefinitionV5, GRID_BREAKPOINTS, + PopModalDefinition, } from "./types/pop-layout"; import { getAllEffectivePositions } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; +import { PopDesignerContext } from "./PopDesignerContext"; // ======================================== // Props @@ -75,10 +78,18 @@ export default function PopDesigner({ // 그리드 모드 (4개 프리셋) const [currentMode, setCurrentMode] = useState("tablet_landscape"); - // 선택된 컴포넌트 - const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId - ? layout.components[selectedComponentId] || null - : null; + // 모달 캔버스 활성 상태 ("main" 또는 모달 ID) + const [activeCanvasId, setActiveCanvasId] = useState("main"); + + // 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회) + const selectedComponent: PopComponentDefinitionV5 | null = (() => { + if (!selectedComponentId) return null; + if (activeCanvasId === "main") { + return layout.components[selectedComponentId] || null; + } + const modal = layout.modals?.find(m => m.id === activeCanvasId); + return modal?.components[selectedComponentId] || null; + })(); // ======================================== // 히스토리 관리 @@ -209,56 +220,104 @@ export default function PopDesigner({ (type: PopComponentType, position: PopGridPosition) => { const componentId = `comp_${idCounter}`; setIdCounter((prev) => prev + 1); - const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); - setLayout(newLayout); - saveToHistory(newLayout); + + if (activeCanvasId === "main") { + // 메인 캔버스 + const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + setLayout(newLayout); + saveToHistory(newLayout); + } else { + // 모달 캔버스 + setLayout(prev => { + const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`); + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + return { ...m, components: { ...m.components, [componentId]: comp } }; + }), + }; + saveToHistory(newLayout); + return newLayout; + }); + } setSelectedComponentId(componentId); setHasChanges(true); }, - [idCounter, layout, saveToHistory] + [idCounter, layout, saveToHistory, activeCanvasId] ); const handleUpdateComponent = useCallback( (componentId: string, updates: Partial) => { // 함수적 업데이트로 stale closure 방지 setLayout((prev) => { - const existingComponent = prev.components[componentId]; - if (!existingComponent) return prev; + if (activeCanvasId === "main") { + // 메인 캔버스 + const existingComponent = prev.components[componentId]; + if (!existingComponent) return prev; - const newComponent = { - ...existingComponent, - ...updates, - }; - const newLayout = { - ...prev, - components: { - ...prev.components, - [componentId]: newComponent, - }, - }; - saveToHistory(newLayout); - return newLayout; + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...existingComponent, ...updates }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const existing = m.components[componentId]; + if (!existing) return m; + return { + ...m, + components: { + ...m.components, + [componentId]: { ...existing, ...updates }, + }, + }; + }), + }; + saveToHistory(newLayout); + return newLayout; + } }); setHasChanges(true); }, - [saveToHistory] + [saveToHistory, activeCanvasId] ); const handleDeleteComponent = useCallback( (componentId: string) => { - const newComponents = { ...layout.components }; - delete newComponents[componentId]; - - const newLayout = { - ...layout, - components: newComponents, - }; - setLayout(newLayout); - saveToHistory(newLayout); + setLayout(prev => { + if (activeCanvasId === "main") { + const newComponents = { ...prev.components }; + delete newComponents[componentId]; + const newLayout = { ...prev, components: newComponents }; + saveToHistory(newLayout); + return newLayout; + } else { + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const newComps = { ...m.components }; + delete newComps[componentId]; + return { ...m, components: newComps }; + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); setSelectedComponentId(null); setHasChanges(true); }, - [layout, saveToHistory] + [saveToHistory, activeCanvasId] ); const handleMoveComponent = useCallback( @@ -478,6 +537,59 @@ export default function PopDesigner({ setHasChanges(true); }, [layout, currentMode, saveToHistory]); + // ======================================== + // 모달 캔버스 관리 + // ======================================== + + /** 모달 ID 자동 생성 (계층적: modal-1, modal-1-1, modal-1-1-1) */ + const generateModalId = useCallback((parentCanvasId: string): string => { + const modals = layout.modals || []; + if (parentCanvasId === "main") { + const rootModals = modals.filter(m => !m.parentId); + return `modal-${rootModals.length + 1}`; + } + const prefix = parentCanvasId.replace("modal-", ""); + const children = modals.filter(m => m.parentId === parentCanvasId); + return `modal-${prefix}-${children.length + 1}`; + }, [layout.modals]); + + /** 모달 캔버스 생성하고 해당 탭으로 전환 */ + const createModalCanvas = useCallback((buttonComponentId: string, title: string): string => { + const modalId = generateModalId(activeCanvasId); + const newModal: PopModalDefinition = { + id: modalId, + parentId: activeCanvasId === "main" ? undefined : activeCanvasId, + title: title || "새 모달", + sourceButtonId: buttonComponentId, + gridConfig: { ...layout.gridConfig }, + components: {}, + }; + setLayout(prev => ({ + ...prev, + modals: [...(prev.modals || []), newModal], + })); + setHasChanges(true); + setActiveCanvasId(modalId); + return modalId; + }, [generateModalId, activeCanvasId, layout.gridConfig]); + + /** 모달 정의 업데이트 (제목, sizeConfig 등) */ + const handleUpdateModal = useCallback((modalId: string, updates: Partial) => { + setLayout(prev => ({ + ...prev, + modals: (prev.modals || []).map(m => + m.id === modalId ? { ...m, ...updates } : m + ), + })); + setHasChanges(true); + }, []); + + /** 특정 캔버스로 전환 */ + const navigateToCanvas = useCallback((canvasId: string) => { + setActiveCanvasId(canvasId); + setSelectedComponentId(null); + }, []); + // ======================================== // 뒤로가기 // ======================================== @@ -560,6 +672,14 @@ export default function PopDesigner({ // 렌더링 // ======================================== return ( +
{/* 헤더 */} @@ -645,6 +765,9 @@ export default function PopDesigner({ onResetOverride={handleResetOverride} onChangeGapPreset={handleChangeGapPreset} previewPageIndex={previewPageIndex} + activeCanvasId={activeCanvasId} + onActiveCanvasChange={navigateToCanvas} + onUpdateModal={handleUpdateModal} /> @@ -670,5 +793,6 @@ export default function PopDesigner({
+
); } diff --git a/frontend/components/pop/designer/PopDesignerContext.tsx b/frontend/components/pop/designer/PopDesignerContext.tsx new file mode 100644 index 00000000..8af42d64 --- /dev/null +++ b/frontend/components/pop/designer/PopDesignerContext.tsx @@ -0,0 +1,35 @@ +/** + * PopDesignerContext - 디자이너 전역 컨텍스트 + * + * ConfigPanel 등 하위 컴포넌트에서 디자이너 레벨 동작을 트리거하기 위한 컨텍스트. + * 예: pop-button 설정 패널에서 "모달 캔버스 생성" 버튼 클릭 시 + * 디자이너의 activeCanvasId를 변경하고 새 모달을 생성. + * + * Provider: PopDesigner.tsx + * Consumer: pop-button ConfigPanel (ModalCanvasButton) + */ + +"use client"; + +import { createContext, useContext } from "react"; + +export interface PopDesignerContextType { + /** 새 모달 캔버스 생성하고 해당 탭으로 전환 (모달 ID 반환) */ + createModalCanvas: (buttonComponentId: string, title: string) => string; + /** 특정 캔버스(메인 또는 모달)로 전환 */ + navigateToCanvas: (canvasId: string) => void; + /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ + activeCanvasId: string; + /** 현재 선택된 컴포넌트 ID */ + selectedComponentId: string | null; +} + +export const PopDesignerContext = createContext(null); + +/** + * 디자이너 컨텍스트 사용 훅 + * 뷰어 모드에서는 null 반환 (Provider 없음) + */ +export function usePopDesignerContext(): PopDesignerContextType | null { + return useContext(PopDesignerContext); +} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index d4933443..0620b783 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -277,6 +277,7 @@ export default function PopRenderer({ effectivePosition={position} isDesignMode={false} isSelected={false} + screenId={String(currentScreenId || "")} />
); @@ -362,6 +363,7 @@ function DraggableComponent({ isDesignMode={isDesignMode} isSelected={isSelected} previewPageIndex={previewPageIndex} + screenId="" /> {/* 리사이즈 핸들 (선택된 컴포넌트만) */} @@ -513,9 +515,11 @@ interface ComponentContentProps { isDesignMode: boolean; isSelected: boolean; previewPageIndex?: number; + /** 화면 ID (이벤트 버스/액션 실행용) */ + screenId?: string; } -function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex }: ComponentContentProps) { +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, screenId }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 @@ -541,6 +545,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect label={component.label} isDesignMode={isDesignMode} previewPageIndex={previewPageIndex} + screenId={screenId} />
); @@ -561,14 +566,14 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect } // 실제 모드: 컴포넌트 렌더링 - return renderActualComponent(component); + return renderActualComponent(component, screenId); } // ======================================== // 실제 컴포넌트 렌더링 (뷰어 모드) // ======================================== -function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode { +function renderActualComponent(component: PopComponentDefinitionV5, screenId?: string): React.ReactNode { // 레지스트리에서 등록된 실제 컴포넌트 조회 const registeredComp = PopComponentRegistry.getComponent(component.type); const ActualComp = registeredComp?.component; @@ -576,7 +581,7 @@ function renderActualComponent(component: PopComponentDefinitionV5): React.React if (ActualComp) { return (
- +
); } diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index c173c579..9cea1b8a 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -208,6 +208,9 @@ export interface PopLayoutDataV5 { mobile_landscape?: PopModeOverrideV5; tablet_portrait?: PopModeOverrideV5; }; + + // 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성) + modals?: PopModalDefinition[]; } /** @@ -385,6 +388,94 @@ 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 presetWidth = MODAL_SIZE_PRESETS[preset].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..3e6a92a1 --- /dev/null +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -0,0 +1,174 @@ +/** + * PopViewerWithModals - 뷰어 모드에서 모달 렌더링 래퍼 + * + * PopRenderer를 감싸서: + * 1. __pop_modal_open__ 이벤트 구독 → Dialog 열기 + * 2. __pop_modal_close__ 이벤트 구독 → Dialog 닫기 + * 3. 모달 스택 관리 (중첩 모달 지원) + * + * 모달 내부는 또 다른 PopRenderer로 렌더링 (독립 그리드). + */ + +"use client"; + +import { useState, useCallback, useEffect } 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"; + +// ======================================== +// 타입 +// ======================================== + +interface PopViewerWithModalsProps { + /** 전체 레이아웃 (모달 정의 포함) */ + layout: PopLayoutDataV5; + /** 뷰포트 너비 */ + viewportWidth: number; + /** 화면 ID (이벤트 버스용) */ + screenId: string; + /** 현재 그리드 모드 (PopRenderer 전달용) */ + currentMode?: GridMode; + /** Gap 오버라이드 */ + overrideGap?: number; + /** Padding 오버라이드 */ + overridePadding?: number; +} + +/** 열린 모달 상태 */ +interface OpenModal { + definition: PopModalDefinition; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +export default function PopViewerWithModals({ + layout, + viewportWidth, + screenId, + currentMode, + overrideGap, + overridePadding, +}: PopViewerWithModalsProps) { + const [modalStack, setModalStack] = useState([]); + const { subscribe } = usePopEvent(screenId); + + // 모달 열기 이벤트 구독 + useEffect(() => { + const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => { + const data = payload as { + modalId?: string; + title?: string; + mode?: string; + }; + + // fullscreen 모달: layout.modals에서 정의 찾기 + if (data?.modalId) { + const modalDef = layout.modals?.find(m => m.id === data.modalId); + if (modalDef) { + setModalStack(prev => [...prev, { definition: modalDef }]); + } + } + }); + + const unsubClose = subscribe("__pop_modal_close__", () => { + // 가장 최근 모달 닫기 + setModalStack(prev => prev.slice(0, -1)); + }); + + return () => { + unsubOpen(); + unsubClose(); + }; + }, [subscribe, layout.modals]); + + // 특정 인덱스의 모달 닫기 + const handleCloseModal = useCallback((index: number) => { + setModalStack(prev => prev.slice(0, index)); + }, []); + + return ( + <> + {/* 메인 화면 렌더링 */} + + + {/* 모달 스택 렌더링 */} + {modalStack.map((modal, index) => { + const { definition } = modal; + const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; + const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; + + // 모달의 layout 구성 (모달 자체를 하나의 레이아웃으로) + const modalLayout: PopLayoutDataV5 = { + ...layout, + gridConfig: definition.gridConfig, + components: definition.components, + overrides: definition.overrides, + }; + + // sizeConfig 기반 모달 너비 계산 + 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) handleCloseModal(index); + }} + > + { + if (!closeOnOverlay) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (!closeOnEsc) e.preventDefault(); + }} + > + + + {definition.title} + + +
+ +
+
+
+ ); + })} + + ); +} diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts new file mode 100644 index 00000000..5800125f --- /dev/null +++ b/frontend/hooks/pop/executePopAction.ts @@ -0,0 +1,199 @@ +/** + * executePopAction - POP 액션 실행 순수 함수 + * + * pop-button, pop-string-list 등 여러 컴포넌트에서 재사용 가능한 + * 액션 실행 코어 로직. React 훅에 의존하지 않음. + * + * 사용처: + * - usePopAction 훅 (pop-button용 래퍼) + * - pop-string-list 카드 버튼 (직접 호출) + * - 향후 pop-table 행 액션 등 + */ + +import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button"; +import { apiClient } from "@/lib/api/client"; +import { dataApi } from "@/lib/api/data"; + +// ======================================== +// 타입 정의 +// ======================================== + +/** 액션 실행 결과 */ +export interface ActionResult { + success: boolean; + data?: unknown; + error?: string; +} + +/** 이벤트 발행 함수 시그니처 (usePopEvent의 publish와 동일) */ +type PublishFn = (eventName: string, payload?: unknown) => void; + +/** executePopAction 옵션 */ +interface ExecuteOptions { + /** 필드 매핑 (소스 컬럼명 → 타겟 컬럼명) */ + fieldMapping?: Record; + /** 화면 ID (이벤트 발행 시 사용) */ + screenId?: string; + /** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */ + publish?: PublishFn; +} + +// ======================================== +// 내부 헬퍼 +// ======================================== + +/** + * 필드 매핑 적용 + * 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환 + */ +function applyFieldMapping( + rowData: Record, + mapping?: Record +): Record { + if (!mapping || Object.keys(mapping).length === 0) { + return { ...rowData }; + } + + const result: Record = {}; + for (const [sourceKey, value] of Object.entries(rowData)) { + // 매핑이 있으면 타겟 키로 변환, 없으면 원본 키 유지 + const targetKey = mapping[sourceKey] || sourceKey; + result[targetKey] = value; + } + return result; +} + +/** + * rowData에서 PK 추출 + * id > pk 순서로 시도, 없으면 rowData 전체를 복합키로 사용 + */ +function extractPrimaryKey( + rowData: Record +): string | number | Record { + if (rowData.id != null) return rowData.id as string | number; + if (rowData.pk != null) return rowData.pk as string | number; + // 복합키: rowData 전체를 Record로 전달 (dataApi.deleteRecord가 object 지원) + return rowData as Record; +} + +// ======================================== +// 메인 함수 +// ======================================== + +/** + * POP 액션 실행 (순수 함수) + * + * @param action - 버튼 메인 액션 설정 + * @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달) + * @param options - 필드 매핑, screenId, publish 함수 + * @returns 실행 결과 + */ +export async function executePopAction( + action: ButtonMainAction, + rowData?: Record, + options?: ExecuteOptions +): Promise { + const { fieldMapping, publish } = options || {}; + + try { + switch (action.type) { + // ── 저장 ── + case "save": { + if (!action.targetTable) { + return { success: false, error: "저장 대상 테이블이 설정되지 않았습니다." }; + } + const data = rowData + ? applyFieldMapping(rowData, fieldMapping) + : {}; + const result = await dataApi.createRecord(action.targetTable, data); + return { success: !!result?.success, data: result?.data, error: result?.message }; + } + + // ── 삭제 ── + case "delete": { + if (!action.targetTable) { + return { success: false, error: "삭제 대상 테이블이 설정되지 않았습니다." }; + } + if (!rowData) { + return { success: false, error: "삭제할 데이터가 없습니다." }; + } + const mappedData = applyFieldMapping(rowData, fieldMapping); + const pk = extractPrimaryKey(mappedData); + const result = await dataApi.deleteRecord(action.targetTable, pk); + return { success: !!result?.success, error: result?.message }; + } + + // ── API 호출 ── + case "api": { + if (!action.apiEndpoint) { + return { success: false, error: "API 엔드포인트가 설정되지 않았습니다." }; + } + const body = rowData + ? applyFieldMapping(rowData, fieldMapping) + : undefined; + const method = (action.apiMethod || "POST").toUpperCase(); + + let response; + switch (method) { + case "GET": + response = await apiClient.get(action.apiEndpoint, { params: body }); + break; + case "POST": + response = await apiClient.post(action.apiEndpoint, body); + break; + case "PUT": + response = await apiClient.put(action.apiEndpoint, body); + break; + case "DELETE": + response = await apiClient.delete(action.apiEndpoint, { data: body }); + break; + default: + response = await apiClient.post(action.apiEndpoint, body); + } + + const resData = response?.data; + return { + success: resData?.success !== false, + data: resData?.data ?? resData, + }; + } + + // ── 모달 열기 ── + case "modal": { + if (!publish) { + return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; + } + publish("__pop_modal_open__", { + modalId: action.modalScreenId, + title: action.modalTitle, + mode: action.modalMode, + items: action.modalItems, + rowData, + }); + return { success: true }; + } + + // ── 이벤트 발행 ── + case "event": { + if (!publish) { + return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; + } + if (!action.eventName) { + return { success: false, error: "이벤트 이름이 설정되지 않았습니다." }; + } + publish(action.eventName, { + ...(action.eventPayload || {}), + row: rowData, + }); + return { success: true }; + } + + default: + return { success: false, error: `알 수 없는 액션 타입: ${action.type}` }; + } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."; + return { success: false, error: message }; + } +} diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts index c0146c0b..c43d5c0b 100644 --- a/frontend/hooks/pop/index.ts +++ b/frontend/hooks/pop/index.ts @@ -11,5 +11,13 @@ export { usePopEvent, cleanupScreen } from "./usePopEvent"; 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"; + // SQL 빌더 유틸 (고급 사용 시) export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; 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/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 285c655d..749c394c 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"; @@ -1306,7 +1332,7 @@ export const SelectedItemsDetailInputComponent: React.FC; } diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index 6ddaa4e7..b5532c30 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useCallback } from "react"; import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -24,9 +24,28 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; -import { usePopEvent } from "@/hooks/pop/usePopEvent"; -import { useDataSource } from "@/hooks/pop/useDataSource"; -import * as LucideIcons from "lucide-react"; +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"; // ======================================== @@ -36,8 +55,8 @@ import { toast } from "sonner"; /** 메인 액션 타입 (5종) */ export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event"; -/** 후속 액션 타입 (3종) */ -export type FollowUpActionType = "event" | "refresh" | "navigate"; +/** 후속 액션 타입 (4종) */ +export type FollowUpActionType = "event" | "refresh" | "navigate" | "close-modal"; /** 버튼 variant (shadcn 기반 4종) */ export type ButtonVariant = "default" | "secondary" | "outline" | "destructive"; @@ -126,6 +145,7 @@ const FOLLOWUP_TYPE_LABELS: Record = { event: "이벤트 발행", refresh: "새로고침", navigate: "화면 이동", + "close-modal": "모달 닫기", }; /** variant 라벨 */ @@ -259,6 +279,12 @@ function SectionDivider({ label }: { label: string }) { ); } +/** 허용된 아이콘 맵 (개별 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, @@ -269,8 +295,7 @@ function DynamicLucideIcon({ size?: number; className?: string; }) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const IconComponent = (LucideIcons as any)[name]; + const IconComponent = LUCIDE_ICON_MAP[name]; if (!IconComponent) return null; return ; } @@ -283,120 +308,30 @@ interface PopButtonComponentProps { config?: PopButtonConfig; label?: string; isDesignMode?: boolean; + screenId?: string; } export function PopButtonComponent({ config, label, isDesignMode, + screenId, }: PopButtonComponentProps) { - const [showConfirm, setShowConfirm] = useState(false); - - // 이벤트 훅 (1차: screenId 빈 문자열 - 후속 통합에서 주입) - const { publish } = usePopEvent(""); - - // 데이터 훅 (save/delete용, tableName 없으면 자동 스킵) - const { save, remove, refetch } = useDataSource({ - tableName: config?.action?.targetTable || "", - }); + // 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"]; - }, [config?.confirm?.message, config?.action?.type]); - - // 메인 액션 실행 - const executeMainAction = useCallback(async (): Promise => { - const action = config?.action; - if (!action) return false; - - try { - switch (action.type) { - case "save": { - // sharedData에서 데이터 수집 (후속 통합에서 실제 구현) - const record: Record = {}; - const result = await save(record); - if (!result.success) { - toast.error(result.error || "저장 실패"); - return false; - } - toast.success("저장되었습니다"); - return true; - } - case "delete": { - // sharedData에서 ID 수집 (후속 통합에서 실제 구현) - const result = await remove(""); - if (!result.success) { - toast.error(result.error || "삭제 실패"); - return false; - } - toast.success("삭제되었습니다"); - return true; - } - case "api": { - // 1차: toast 알림만 (실제 API 호출은 후속) - toast.info( - `API 호출 예정: ${action.apiMethod || "POST"} ${action.apiEndpoint || "(미설정)"}` - ); - return true; - } - case "modal": { - // 1차: toast 알림만 (실제 모달은 후속) - toast.info( - `모달 열기 예정: ${MODAL_MODE_LABELS[action.modalMode || "fullscreen"]}` - ); - return true; - } - case "event": { - if (action.eventName) { - publish(action.eventName, action.eventPayload); - toast.success(`이벤트 발행: ${action.eventName}`); - } - return true; - } - default: - return false; - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "액션 실행 실패"; - toast.error(message); - return false; - } - }, [config?.action, save, remove, publish]); - - // 후속 액션 순차 실행 - const executeFollowUpActions = useCallback(async () => { - const actions = config?.followUpActions; - if (!actions || actions.length === 0) return; - - for (const fa of actions) { - try { - switch (fa.type) { - case "event": - if (fa.eventName) { - publish(fa.eventName, fa.eventPayload); - } - break; - case "refresh": - publish("__refresh__"); - await refetch(); - break; - case "navigate": - if (fa.targetScreenId) { - window.location.href = `/pop/screens/${fa.targetScreenId}`; - return; // navigate 후 중단 - } - break; - } - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "후속 액션 실패"; - toast.error(message); - // 개별 실패 시 다음 진행 - } - } - }, [config?.followUpActions, publish, refetch]); + }, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]); // 클릭 핸들러 const handleClick = useCallback(async () => { @@ -408,33 +343,14 @@ export function PopButtonComponent({ return; } - // 확인 다이얼로그 필요 시 - if (config?.confirm?.enabled) { - setShowConfirm(true); - return; - } + const action = config?.action; + if (!action) return; - // 바로 실행 - const success = await executeMainAction(); - if (success) { - await executeFollowUpActions(); - } - }, [ - isDesignMode, - config?.confirm?.enabled, - config?.action?.type, - executeMainAction, - executeFollowUpActions, - ]); - - // 확인 후 실행 - const handleConfirmExecute = useCallback(async () => { - setShowConfirm(false); - const success = await executeMainAction(); - if (success) { - await executeFollowUpActions(); - } - }, [executeMainAction, executeFollowUpActions]); + await execute(action, { + confirm: config?.confirm, + followUpActions: config?.followUpActions, + }); + }, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]); // 외형 const buttonLabel = config?.label || label || "버튼"; @@ -448,6 +364,7 @@ export function PopButtonComponent({ + ) : ( + + )} + + )} ); diff --git a/frontend/lib/registry/pop-components/pop-icon.tsx b/frontend/lib/registry/pop-components/pop-icon.tsx index 1d61afd9..3ecb9d49 100644 --- a/frontend/lib/registry/pop-components/pop-icon.tsx +++ b/frontend/lib/registry/pop-components/pop-icon.tsx @@ -24,9 +24,26 @@ import { } from "@/components/ui/alert-dialog"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { GridMode } from "@/components/pop/designer/types/pop-layout"; -import * as LucideIcons from "lucide-react"; +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, +}; + // ======================================== // 타입 정의 // ======================================== @@ -201,8 +218,7 @@ function getImageUrl(imageConfig?: ImageConfig): string | undefined { // Lucide 아이콘 동적 렌더링 function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const IconComponent = (LucideIcons as any)[name]; + const IconComponent = LUCIDE_ICON_MAP[name]; if (!IconComponent) return null; return ; } diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index e659b920..7f75359a 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -8,7 +8,7 @@ * 오버플로우: visibleRows 제한 + "전체보기" 확장 */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { ChevronDown, ChevronUp, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -18,6 +18,9 @@ import { } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { dataApi } from "@/lib/api/data"; +import { executePopAction } from "@/hooks/pop/executePopAction"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { toast } from "sonner"; import type { PopStringListConfig, CardGridConfig, @@ -43,6 +46,7 @@ function resolveColumnName(name: string): string { interface PopStringListComponentProps { config?: PopStringListConfig; className?: string; + screenId?: string; } // 테이블 행 데이터 타입 @@ -53,6 +57,7 @@ type RowData = Record; export function PopStringListComponent({ config, className, + screenId, }: PopStringListComponentProps) { const displayMode = config?.displayMode || "list"; const header = config?.header; @@ -67,6 +72,46 @@ export function PopStringListComponent({ const [error, setError] = useState(null); const [expanded, setExpanded] = useState(false); + // 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음) + const [loadingRowIdx, setLoadingRowIdx] = useState(-1); + + // 이벤트 발행 (카드 버튼 액션에서 사용) + const { publish } = usePopEvent(screenId || ""); + + // 카드 버튼 클릭 핸들러 + const handleCardButtonClick = useCallback( + async (cell: CardCellDefinition, row: RowData) => { + if (!cell.buttonAction) return; + + // 확인 다이얼로그 (간단 구현: window.confirm) + if (cell.buttonConfirm?.enabled) { + const msg = cell.buttonConfirm.message || "이 작업을 실행하시겠습니까?"; + if (!window.confirm(msg)) return; + } + + const rowIndex = rows.indexOf(row); + setLoadingRowIdx(rowIndex); + + try { + const result = await executePopAction(cell.buttonAction, row as Record, { + publish, + screenId, + }); + + if (result.success) { + toast.success("작업이 완료되었습니다."); + } else { + toast.error(result.error || "작업에 실패했습니다."); + } + } catch { + toast.error("알 수 없는 오류가 발생했습니다."); + } finally { + setLoadingRowIdx(-1); + } + }, + [rows, publish, screenId] + ); + // 오버플로우 계산 (JSON 복원 시 string 유입 방어) const visibleRows = Number(overflow?.visibleRows) || 5; const maxExpandRows = Number(overflow?.maxExpandRows) || 20; @@ -83,9 +128,20 @@ export function PopStringListComponent({ setExpanded((prev) => !prev); }, []); + // dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용) + const dsTableName = dataSource?.tableName; + const dsSortColumn = dataSource?.sort?.column; + const dsSortDirection = dataSource?.sort?.direction; + const dsLimitMode = dataSource?.limit?.mode; + const dsLimitCount = dataSource?.limit?.count; + const dsFiltersKey = useMemo( + () => JSON.stringify(dataSource?.filters || []), + [dataSource?.filters] + ); + // 데이터 조회 useEffect(() => { - if (!dataSource?.tableName) { + if (!dsTableName) { setLoading(false); setRows([]); return; @@ -98,8 +154,9 @@ export function PopStringListComponent({ try { // 필터 조건 구성 const filters: Record = {}; - if (dataSource.filters && dataSource.filters.length > 0) { - dataSource.filters.forEach((f) => { + const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>; + if (parsedFilters.length > 0) { + parsedFilters.forEach((f) => { if (f.column && f.value) { filters[f.column] = f.value; } @@ -107,16 +164,16 @@ export function PopStringListComponent({ } // 정렬 조건 - const sortBy = dataSource.sort?.column; - const sortOrder = dataSource.sort?.direction; + const sortBy = dsSortColumn; + const sortOrder = dsSortDirection; // 개수 제한 (string 유입 방어: Number 캐스팅) const size = - dataSource.limit?.mode === "limited" && dataSource.limit?.count - ? Number(dataSource.limit.count) + dsLimitMode === "limited" && dsLimitCount + ? Number(dsLimitCount) : maxExpandRows; - const result = await dataApi.getTableData(dataSource.tableName, { + const result = await dataApi.getTableData(dsTableName, { page: 1, size, sortBy: sortOrder ? sortBy : undefined, @@ -136,7 +193,7 @@ export function PopStringListComponent({ }; fetchData(); - }, [dataSource, maxExpandRows]); + }, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]); // 로딩 상태 if (loading) { @@ -199,6 +256,8 @@ export function PopStringListComponent({ )} @@ -376,9 +435,11 @@ function ListModeView({ columns, data }: ListModeViewProps) { interface CardModeViewProps { cardGrid?: CardGridConfig; data: RowData[]; + handleCardButtonClick?: (cell: CardCellDefinition, row: RowData) => void; + loadingRowId?: number; } -function CardModeView({ cardGrid, data }: CardModeViewProps) { +function CardModeView({ cardGrid, data, handleCardButtonClick, loadingRowId }: CardModeViewProps) { if (!cardGrid || (cardGrid.cells || []).length === 0) { return (
@@ -439,7 +500,7 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) { : "none", }} > - {renderCellContent(cell, row)} + {renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
); })} @@ -451,7 +512,12 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) { // ===== 셀 컨텐츠 렌더링 ===== -function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactNode { +function renderCellContent( + cell: CardCellDefinition, + row: RowData, + onButtonClick?: (cell: CardCellDefinition, row: RowData) => void, + isButtonLoading?: boolean, +): React.ReactNode { const value = row[cell.columnName]; const displayValue = value != null ? String(value) : ""; @@ -478,7 +544,16 @@ function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactN case "button": return ( - ); diff --git a/frontend/lib/registry/pop-components/pop-string-list/types.ts b/frontend/lib/registry/pop-components/pop-string-list/types.ts index a7eeb482..3fe0d748 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/types.ts +++ b/frontend/lib/registry/pop-components/pop-string-list/types.ts @@ -2,6 +2,7 @@ // pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import. import type { CardListDataSource } from "../types"; +import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "../pop-button"; /** 표시 모드 */ export type StringListDisplayMode = "list" | "card"; @@ -20,6 +21,10 @@ export interface CardCellDefinition { fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px) align?: "left" | "center" | "right"; // 가로 정렬 (기본 left) verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top) + // button 타입 전용 (pop-button 로직 재사용) + buttonAction?: ButtonMainAction; // 클릭 시 실행할 액션 + buttonVariant?: ButtonVariant; // 버튼 스타일 + buttonConfirm?: ConfirmConfig; // 확인 다이얼로그 설정 } /** 카드 그리드 레이아웃 설정 */ diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index d65a153b..8fb66c5d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -24,5 +24,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".next"] }