feat(pop): 액션 아키텍처 + 모달 시스템 구현 (STEP 0~7)

- 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 <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-23 13:54:49 +09:00
parent 51e1392640
commit df8cbb3e80
16 changed files with 1492 additions and 220 deletions

View File

@ -26,7 +26,7 @@ import {
} from "@/components/pop/designer/types/pop-layout"; } from "@/components/pop/designer/types/pop-layout";
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
import "@/lib/registry/pop-components"; import "@/lib/registry/pop-components";
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
import { import {
useResponsiveModeWithOverride, useResponsiveModeWithOverride,
type DeviceType, type DeviceType,
@ -294,11 +294,11 @@ function PopScreenViewPage() {
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
return ( return (
<PopRenderer <PopViewerWithModals
layout={layout} layout={layout}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth} viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
screenId={String(screenId)}
currentMode={currentModeKey} currentMode={currentModeKey}
isDesignMode={false}
overrideGap={adjustedGap} overrideGap={adjustedGap}
overridePadding={adjustedPadding} overridePadding={adjustedPadding}
/> />

View File

@ -13,8 +13,12 @@ import {
GAP_PRESETS, GAP_PRESETS,
GRID_BREAKPOINTS, GRID_BREAKPOINTS,
DEFAULT_COMPONENT_GRID_SIZE, DEFAULT_COMPONENT_GRID_SIZE,
PopModalDefinition,
ModalSizePreset,
MODAL_SIZE_PRESETS,
resolveModalWidth,
} from "./types/pop-layout"; } 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 { useDrag } from "react-dnd";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -114,6 +118,12 @@ interface PopCanvasProps {
onChangeGapPreset?: (preset: GapPreset) => void; onChangeGapPreset?: (preset: GapPreset) => void;
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
previewPageIndex?: number; previewPageIndex?: number;
/** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
activeCanvasId?: string;
/** 캔버스 전환 콜백 */
onActiveCanvasChange?: (canvasId: string) => void;
/** 모달 정의 업데이트 콜백 */
onUpdateModal?: (modalId: string, updates: Partial<PopModalDefinition>) => void;
} }
// ======================================== // ========================================
@ -138,7 +148,41 @@ export default function PopCanvas({
onResetOverride, onResetOverride,
onChangeGapPreset, onChangeGapPreset,
previewPageIndex, previewPageIndex,
activeCanvasId = "main",
onActiveCanvasChange,
onUpdateModal,
}: PopCanvasProps) { }: 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); const [canvasScale, setCanvasScale] = useState(0.8);
@ -165,12 +209,12 @@ export default function PopCanvas({
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
// 숨김 컴포넌트 ID 목록 // 숨김 컴포넌트 ID 목록 (activeLayout 기반)
const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || []; const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반) // 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
const dynamicCanvasHeight = useMemo(() => { const dynamicCanvasHeight = useMemo(() => {
const visibleComps = Object.values(layout.components).filter( const visibleComps = Object.values(activeLayout.components).filter(
comp => !hiddenComponentIds.includes(comp.id) comp => !hiddenComponentIds.includes(comp.id)
); );
@ -189,7 +233,7 @@ export default function PopCanvas({
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2; const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
return Math.max(MIN_CANVAS_HEIGHT, height); 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(() => { 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 existingPositions = Array.from(effectivePositions.values());
const hasOverlap = existingPositions.some(pos => const hasOverlap = existingPositions.some(pos =>
@ -349,7 +393,7 @@ export default function PopCanvas({
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean }; const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
// 현재 모드에서의 유효 위치들 가져오기 // 현재 모드에서의 유효 위치들 가져오기
const effectivePositions = getAllEffectivePositions(layout, currentMode); const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
@ -401,42 +445,42 @@ export default function PopCanvas({
canDrop: monitor.canDrop(), 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); drop(canvasRef);
// 빈 상태 체크 // 빈 상태 체크 (activeLayout 기반)
const isEmpty = Object.keys(layout.components).length === 0; const isEmpty = Object.keys(activeLayout.components).length === 0;
// 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨) // 숨김 처리된 컴포넌트 객체 목록
const hiddenComponents = useMemo(() => { const hiddenComponents = useMemo(() => {
return hiddenComponentIds return hiddenComponentIds
.map(id => layout.components[id]) .map(id => activeLayout.components[id])
.filter(Boolean); .filter(Boolean);
}, [hiddenComponentIds, layout.components]); }, [hiddenComponentIds, activeLayout.components]);
// 표시되는 컴포넌트 목록 (숨김 제외) // 표시되는 컴포넌트 목록 (숨김 제외)
const visibleComponents = useMemo(() => { const visibleComponents = useMemo(() => {
return Object.values(layout.components).filter( return Object.values(activeLayout.components).filter(
comp => !hiddenComponentIds.includes(comp.id) comp => !hiddenComponentIds.includes(comp.id)
); );
}, [layout.components, hiddenComponentIds]); }, [activeLayout.components, hiddenComponentIds]);
// 검토 필요 컴포넌트 목록 // 검토 필요 컴포넌트 목록
const reviewComponents = useMemo(() => { const reviewComponents = useMemo(() => {
return visibleComponents.filter(comp => { return visibleComponents.filter(comp => {
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id]; const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id];
return needsReview(currentMode, hasOverride); return needsReview(currentMode, hasOverride);
}); });
}, [visibleComponents, layout.overrides, currentMode]); }, [visibleComponents, activeLayout.overrides, currentMode]);
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
// 12칸 모드가 아닐 때만 패널 표시 // 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 showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
const showRightPanel = showReviewPanel || showHiddenPanel; const showRightPanel = showReviewPanel || showHiddenPanel;
@ -576,6 +620,32 @@ export default function PopCanvas({
</Button> </Button>
</div> </div>
{/* 모달 탭 바 (모달이 1개 이상 있을 때만 표시) */}
{modalTabs.length > 1 && (
<div className="flex gap-1 border-b bg-muted/30 px-4 py-1">
{modalTabs.map(tab => (
<Button
key={tab.id}
variant={activeCanvasId === tab.id ? "default" : "ghost"}
size="sm"
onClick={() => onActiveCanvasChange?.(tab.id)}
className="h-7 text-xs"
>
{tab.label}
</Button>
))}
</div>
)}
{/* 모달 사이즈 설정 패널 (모달 캔버스 활성 시) */}
{activeModal && (
<ModalSizeSettingsPanel
modal={activeModal}
currentMode={currentMode}
onUpdate={(updates) => onUpdateModal?.(activeModal.id, updates)}
/>
)}
{/* 캔버스 영역 */} {/* 캔버스 영역 */}
<div <div
ref={containerRef} ref={containerRef}
@ -680,7 +750,7 @@ export default function PopCanvas({
) : ( ) : (
// 그리드 렌더러 // 그리드 렌더러
<PopRenderer <PopRenderer
layout={layout} layout={activeLayout}
viewportWidth={customWidth} viewportWidth={customWidth}
currentMode={currentMode} currentMode={currentMode}
isDesignMode={true} isDesignMode={true}
@ -973,3 +1043,278 @@ function HiddenItem({
</div> </div>
); );
} }
// ========================================
// 모달 사이즈 설정 패널
// ========================================
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<PopModalDefinition>) => 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<GridMode, ModalSizePreset> | 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 (
<div className="border-b bg-muted/20">
{/* 헤더 (항상 표시) */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center justify-between px-4 py-2 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
{isExpanded
? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
}
<span className="text-xs font-semibold">{modal.title}</span>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{sizeConfig.default.toUpperCase()}
</span>
<span className="text-[10px] text-muted-foreground">
{currentModalWidth}px / {currentModeWidth}px
</span>
</div>
<span className="text-[10px] text-muted-foreground">{modal.id}</span>
</button>
{/* 펼침 영역 */}
{isExpanded && (
<div className="px-4 pb-3 space-y-3">
{/* 기본 사이즈 선택 */}
<div className="space-y-1">
<span className="text-[11px] text-muted-foreground font-medium"> </span>
<div className="flex gap-1">
{SIZE_PRESET_ORDER.map(preset => {
const info = MODAL_SIZE_PRESETS[preset];
return (
<button
key={preset}
type="button"
onClick={() => handleDefaultChange(preset)}
className={cn(
"flex-1 h-8 rounded-md text-xs font-medium transition-colors flex flex-col items-center justify-center gap-0",
sizeConfig.default === preset
? "bg-primary text-primary-foreground"
: "bg-background border hover:bg-accent"
)}
>
<span className="leading-none">{preset.toUpperCase()}</span>
<span className={cn(
"text-[9px] leading-none",
sizeConfig.default === preset ? "text-primary-foreground/70" : "text-muted-foreground"
)}>
{preset === "full" ? "100%" : `${info.width}px`}
</span>
</button>
);
})}
</div>
</div>
{/* 모드별 개별 설정 토글 */}
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground"> </span>
<button
type="button"
onClick={handleTogglePerMode}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
usePerMode ? "bg-primary" : "bg-gray-300"
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
usePerMode ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
{/* 모드별 설정 */}
{usePerMode && (
<div className="space-y-1.5">
{MODE_LABELS.map(({ mode, label, icon: Icon }) => {
const modePreset = sizeConfig.modeOverrides?.[mode] ?? sizeConfig.default;
return (
<div key={mode} className={cn(
"flex items-center justify-between rounded-md px-2 py-1",
mode === currentMode ? "bg-primary/10 ring-1 ring-primary/30" : ""
)}>
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px]">{label}</span>
</div>
<div className="flex gap-0.5">
{SIZE_PRESET_ORDER.map(preset => (
<button
key={preset}
type="button"
onClick={() => handleModeChange(mode, preset)}
className={cn(
"h-6 px-1.5 rounded text-[10px] font-medium transition-colors",
modePreset === preset
? "bg-primary text-primary-foreground"
: "bg-background border hover:bg-accent"
)}
>
{preset.toUpperCase()}
</button>
))}
</div>
</div>
);
})}
</div>
)}
{/* 캔버스 축소판 미리보기 */}
<ModalThumbnailPreview sizeConfig={sizeConfig} currentMode={currentMode} />
</div>
)}
</div>
);
}
// ========================================
// 모달 사이즈 썸네일 미리보기 (캔버스 축소판 + 모달 오버레이)
// ========================================
function ModalThumbnailPreview({
sizeConfig,
currentMode,
}: {
sizeConfig: { default: ModalSizePreset; modeOverrides?: Partial<Record<GridMode, ModalSizePreset>> };
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<GridMode, ModalSizePreset> | 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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground font-medium"></span>
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Icon className="h-3 w-3" />
<span>{modeInfo.label}</span>
</div>
</div>
<div
className="relative mx-auto rounded-md border bg-gray-100 overflow-hidden"
style={{ width: `${PREVIEW_WIDTH}px`, height: `${previewHeight}px` }}
>
{/* 반투명 배경 오버레이 (모달이 열렸을 때의 딤 효과) */}
<div className="absolute inset-0 bg-black/10" />
{/* 모달 영역 (가운데 정렬, FULL이면 전체 채움) */}
<div
className={cn(
"absolute border-2 border-primary/60 bg-primary/15",
isFull ? "rounded-none" : "rounded-sm"
)}
style={{
width: `${scaledModalWidth}px`,
height: `${scaledModalHeight}px`,
left: `${(PREVIEW_WIDTH - scaledModalWidth) / 2}px`,
top: `${(previewHeight - scaledModalHeight) / 2}px`,
}}
>
<div className="absolute top-1 left-1.5 text-[8px] font-medium text-primary/80 leading-none">
</div>
</div>
{/* 하단 수치 표시 */}
<div className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1.5 py-0.5 text-[9px] text-white">
{isFull ? "FULL" : `${modalWidth}px`} / {modeWidth}px
</div>
</div>
</div>
);
}

View File

@ -28,11 +28,14 @@ import {
createEmptyPopLayoutV5, createEmptyPopLayoutV5,
isV5Layout, isV5Layout,
addComponentToV5Layout, addComponentToV5Layout,
createComponentDefinitionV5,
GRID_BREAKPOINTS, GRID_BREAKPOINTS,
PopModalDefinition,
} from "./types/pop-layout"; } from "./types/pop-layout";
import { getAllEffectivePositions } from "./utils/gridUtils"; import { getAllEffectivePositions } from "./utils/gridUtils";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { PopDesignerContext } from "./PopDesignerContext";
// ======================================== // ========================================
// Props // Props
@ -75,10 +78,18 @@ export default function PopDesigner({
// 그리드 모드 (4개 프리셋) // 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape"); const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
// 선택된 컴포넌트 // 모달 캔버스 활성 상태 ("main" 또는 모달 ID)
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId const [activeCanvasId, setActiveCanvasId] = useState<string>("main");
? layout.components[selectedComponentId] || null
: null; // 선택된 컴포넌트 (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) => { (type: PopComponentType, position: PopGridPosition) => {
const componentId = `comp_${idCounter}`; const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1); setIdCounter((prev) => prev + 1);
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
setLayout(newLayout); if (activeCanvasId === "main") {
saveToHistory(newLayout); // 메인 캔버스
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); setSelectedComponentId(componentId);
setHasChanges(true); setHasChanges(true);
}, },
[idCounter, layout, saveToHistory] [idCounter, layout, saveToHistory, activeCanvasId]
); );
const handleUpdateComponent = useCallback( const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => { (componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
// 함수적 업데이트로 stale closure 방지 // 함수적 업데이트로 stale closure 방지
setLayout((prev) => { setLayout((prev) => {
const existingComponent = prev.components[componentId]; if (activeCanvasId === "main") {
if (!existingComponent) return prev; // 메인 캔버스
const existingComponent = prev.components[componentId];
if (!existingComponent) return prev;
const newComponent = { const newLayout = {
...existingComponent, ...prev,
...updates, components: {
}; ...prev.components,
const newLayout = { [componentId]: { ...existingComponent, ...updates },
...prev, },
components: { };
...prev.components, saveToHistory(newLayout);
[componentId]: newComponent, return newLayout;
}, } else {
}; // 모달 캔버스
saveToHistory(newLayout); const newLayout = {
return 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); setHasChanges(true);
}, },
[saveToHistory] [saveToHistory, activeCanvasId]
); );
const handleDeleteComponent = useCallback( const handleDeleteComponent = useCallback(
(componentId: string) => { (componentId: string) => {
const newComponents = { ...layout.components }; setLayout(prev => {
delete newComponents[componentId]; if (activeCanvasId === "main") {
const newComponents = { ...prev.components };
const newLayout = { delete newComponents[componentId];
...layout, const newLayout = { ...prev, components: newComponents };
components: newComponents, saveToHistory(newLayout);
}; return newLayout;
setLayout(newLayout); } else {
saveToHistory(newLayout); 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); setSelectedComponentId(null);
setHasChanges(true); setHasChanges(true);
}, },
[layout, saveToHistory] [saveToHistory, activeCanvasId]
); );
const handleMoveComponent = useCallback( const handleMoveComponent = useCallback(
@ -478,6 +537,59 @@ export default function PopDesigner({
setHasChanges(true); setHasChanges(true);
}, [layout, currentMode, saveToHistory]); }, [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<PopModalDefinition>) => {
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 ( return (
<PopDesignerContext.Provider
value={{
createModalCanvas,
navigateToCanvas,
activeCanvasId,
selectedComponentId,
}}
>
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
{/* 헤더 */} {/* 헤더 */}
@ -645,6 +765,9 @@ export default function PopDesigner({
onResetOverride={handleResetOverride} onResetOverride={handleResetOverride}
onChangeGapPreset={handleChangeGapPreset} onChangeGapPreset={handleChangeGapPreset}
previewPageIndex={previewPageIndex} previewPageIndex={previewPageIndex}
activeCanvasId={activeCanvasId}
onActiveCanvasChange={navigateToCanvas}
onUpdateModal={handleUpdateModal}
/> />
</ResizablePanel> </ResizablePanel>
@ -670,5 +793,6 @@ export default function PopDesigner({
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
</DndProvider> </DndProvider>
</PopDesignerContext.Provider>
); );
} }

View File

@ -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<PopDesignerContextType | null>(null);
/**
*
* null (Provider )
*/
export function usePopDesignerContext(): PopDesignerContextType | null {
return useContext(PopDesignerContext);
}

View File

@ -277,6 +277,7 @@ export default function PopRenderer({
effectivePosition={position} effectivePosition={position}
isDesignMode={false} isDesignMode={false}
isSelected={false} isSelected={false}
screenId={String(currentScreenId || "")}
/> />
</div> </div>
); );
@ -362,6 +363,7 @@ function DraggableComponent({
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
isSelected={isSelected} isSelected={isSelected}
previewPageIndex={previewPageIndex} previewPageIndex={previewPageIndex}
screenId=""
/> />
{/* 리사이즈 핸들 (선택된 컴포넌트만) */} {/* 리사이즈 핸들 (선택된 컴포넌트만) */}
@ -513,9 +515,11 @@ interface ComponentContentProps {
isDesignMode: boolean; isDesignMode: boolean;
isSelected: boolean; isSelected: boolean;
previewPageIndex?: number; 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; const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// PopComponentRegistry에서 등록된 컴포넌트 가져오기 // PopComponentRegistry에서 등록된 컴포넌트 가져오기
@ -541,6 +545,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
label={component.label} label={component.label}
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
previewPageIndex={previewPageIndex} previewPageIndex={previewPageIndex}
screenId={screenId}
/> />
</div> </div>
); );
@ -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 registeredComp = PopComponentRegistry.getComponent(component.type);
const ActualComp = registeredComp?.component; const ActualComp = registeredComp?.component;
@ -576,7 +581,7 @@ function renderActualComponent(component: PopComponentDefinitionV5): React.React
if (ActualComp) { if (ActualComp) {
return ( return (
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<ActualComp config={component.config} label={component.label} /> <ActualComp config={component.config} label={component.label} screenId={screenId} />
</div> </div>
); );
} }

View File

@ -208,6 +208,9 @@ export interface PopLayoutDataV5 {
mobile_landscape?: PopModeOverrideV5; mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5; tablet_portrait?: PopModeOverrideV5;
}; };
// 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
modals?: PopModalDefinition[];
} }
/** /**
@ -385,6 +388,94 @@ export const addComponentToV5Layout = (
return newLayout; return newLayout;
}; };
// ========================================
// 모달 캔버스 정의
// ========================================
// ========================================
// 모달 사이즈 시스템
// ========================================
/** 모달 사이즈 프리셋 */
export type ModalSizePreset = "sm" | "md" | "lg" | "xl" | "full";
/** 모달 사이즈 프리셋별 픽셀 값 */
export const MODAL_SIZE_PRESETS: Record<ModalSizePreset, { width: number; label: string }> = {
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<string, PopComponentDefinitionV5>;
/** 모드별 오버라이드 */
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;
}
// ======================================== // ========================================
// 레거시 타입 별칭 (하위 호환 - 추후 제거) // 레거시 타입 별칭 (하위 호환 - 추후 제거)
// ======================================== // ========================================

View File

@ -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<OpenModal[]>([]);
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 (
<>
{/* 메인 화면 렌더링 */}
<PopRenderer
layout={layout}
viewportWidth={viewportWidth}
currentScreenId={Number(screenId) || undefined}
currentMode={currentMode}
isDesignMode={false}
overrideGap={overrideGap}
overridePadding={overridePadding}
/>
{/* 모달 스택 렌더링 */}
{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 (
<Dialog
key={`${definition.id}-${index}`}
open={true}
onOpenChange={(open) => {
if (!open) handleCloseModal(index);
}}
>
<DialogContent
className={isFull
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
: "max-h-[90vh] overflow-auto p-0"
}
style={isFull ? undefined : {
maxWidth: `${modalWidth}px`,
width: `${modalWidth}px`,
}}
onInteractOutside={(e) => {
if (!closeOnOverlay) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (!closeOnEsc) e.preventDefault();
}}
>
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
<DialogTitle className="text-base">
{definition.title}
</DialogTitle>
</DialogHeader>
<div className={isFull ? "flex-1 overflow-auto" : "px-4 pb-4"}>
<PopRenderer
layout={modalLayout}
viewportWidth={rendererWidth}
currentScreenId={Number(screenId) || undefined}
/>
</div>
</DialogContent>
</Dialog>
);
})}
</>
);
}

View File

@ -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<string, string>;
/** 화면 ID (이벤트 발행 시 사용) */
screenId?: string;
/** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */
publish?: PublishFn;
}
// ========================================
// 내부 헬퍼
// ========================================
/**
*
*
*/
function applyFieldMapping(
rowData: Record<string, unknown>,
mapping?: Record<string, string>
): Record<string, unknown> {
if (!mapping || Object.keys(mapping).length === 0) {
return { ...rowData };
}
const result: Record<string, unknown> = {};
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, unknown>
): string | number | Record<string, unknown> {
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<string, unknown>;
}
// ========================================
// 메인 함수
// ========================================
/**
* POP ( )
*
* @param action -
* @param rowData - ( )
* @param options - , screenId, publish
* @returns
*/
export async function executePopAction(
action: ButtonMainAction,
rowData?: Record<string, unknown>,
options?: ExecuteOptions
): Promise<ActionResult> {
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 };
}
}

View File

@ -11,5 +11,13 @@ export { usePopEvent, cleanupScreen } from "./usePopEvent";
export { useDataSource } from "./useDataSource"; export { useDataSource } from "./useDataSource";
export type { MutationResult, DataSourceResult } 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 빌더 유틸 (고급 사용 시) // SQL 빌더 유틸 (고급 사용 시)
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";

View File

@ -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<string, unknown>;
fieldMapping?: Record<string, string>;
confirm: ConfirmConfig;
followUpActions?: FollowUpAction[];
}
/** execute 호출 시 옵션 */
interface ExecuteActionOptions {
/** 대상 행 데이터 */
rowData?: Record<string, unknown>;
/** 필드 매핑 */
fieldMapping?: Record<string, string>;
/** 확인 다이얼로그 설정 */
confirm?: ConfirmConfig;
/** 후속 액션 */
followUpActions?: FollowUpAction[];
}
// ========================================
// 상수
// ========================================
/** 액션 성공 시 토스트 메시지 */
const ACTION_SUCCESS_MESSAGES: Record<string, string> = {
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<PendingConfirmState | null>(null);
const { publish } = usePopEvent(screenId);
// publish 안정성 보장 (콜백 내에서 최신 참조 사용)
const publishRef = useRef(publish);
publishRef.current = publish;
/**
* ( or )
*/
const runAction = useCallback(
async (
action: ButtonMainAction,
rowData?: Record<string, unknown>,
fieldMapping?: Record<string, string>,
followUpActions?: FollowUpAction[]
): Promise<ActionResult> => {
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<ActionResult> => {
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;
}

View File

@ -13,8 +13,34 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { X } from "lucide-react"; import {
import * as LucideIcons from "lucide-react"; 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<string, LucideIcon> = {
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 { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -1306,7 +1332,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
switch (displayItem.type) { switch (displayItem.type) {
case "icon": { case "icon": {
if (!displayItem.icon) return null; if (!displayItem.icon) return null;
const IconComponent = (LucideIcons as any)[displayItem.icon]; const IconComponent = LUCIDE_ICON_MAP[displayItem.icon];
if (!IconComponent) return null; if (!IconComponent) return null;
return <IconComponent key={displayItem.id} className="mr-1 inline-block h-3 w-3" style={inlineStyle} />; return <IconComponent key={displayItem.id} className="mr-1 inline-block h-3 w-3" style={inlineStyle} />;
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useCallback } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -24,9 +24,28 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { usePopAction } from "@/hooks/pop/usePopAction";
import { useDataSource } from "@/hooks/pop/useDataSource"; import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
import * as LucideIcons from "lucide-react"; 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"; import { toast } from "sonner";
// ======================================== // ========================================
@ -36,8 +55,8 @@ import { toast } from "sonner";
/** 메인 액션 타입 (5종) */ /** 메인 액션 타입 (5종) */
export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event"; export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event";
/** 후속 액션 타입 (3종) */ /** 후속 액션 타입 (4종) */
export type FollowUpActionType = "event" | "refresh" | "navigate"; export type FollowUpActionType = "event" | "refresh" | "navigate" | "close-modal";
/** 버튼 variant (shadcn 기반 4종) */ /** 버튼 variant (shadcn 기반 4종) */
export type ButtonVariant = "default" | "secondary" | "outline" | "destructive"; export type ButtonVariant = "default" | "secondary" | "outline" | "destructive";
@ -126,6 +145,7 @@ const FOLLOWUP_TYPE_LABELS: Record<FollowUpActionType, string> = {
event: "이벤트 발행", event: "이벤트 발행",
refresh: "새로고침", refresh: "새로고침",
navigate: "화면 이동", navigate: "화면 이동",
"close-modal": "모달 닫기",
}; };
/** variant 라벨 */ /** variant 라벨 */
@ -259,6 +279,12 @@ function SectionDivider({ label }: { label: string }) {
); );
} }
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
};
/** Lucide 아이콘 동적 렌더링 */ /** Lucide 아이콘 동적 렌더링 */
function DynamicLucideIcon({ function DynamicLucideIcon({
name, name,
@ -269,8 +295,7 @@ function DynamicLucideIcon({
size?: number; size?: number;
className?: string; className?: string;
}) { }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const IconComponent = LUCIDE_ICON_MAP[name];
const IconComponent = (LucideIcons as any)[name];
if (!IconComponent) return null; if (!IconComponent) return null;
return <IconComponent size={size} className={className} />; return <IconComponent size={size} className={className} />;
} }
@ -283,120 +308,30 @@ interface PopButtonComponentProps {
config?: PopButtonConfig; config?: PopButtonConfig;
label?: string; label?: string;
isDesignMode?: boolean; isDesignMode?: boolean;
screenId?: string;
} }
export function PopButtonComponent({ export function PopButtonComponent({
config, config,
label, label,
isDesignMode, isDesignMode,
screenId,
}: PopButtonComponentProps) { }: PopButtonComponentProps) {
const [showConfirm, setShowConfirm] = useState(false); // usePopAction 훅으로 액션 실행 통합
const {
// 이벤트 훅 (1차: screenId 빈 문자열 - 후속 통합에서 주입) execute,
const { publish } = usePopEvent(""); isLoading,
pendingConfirm,
// 데이터 훅 (save/delete용, tableName 없으면 자동 스킵) confirmExecute,
const { save, remove, refetch } = useDataSource({ cancelConfirm,
tableName: config?.action?.targetTable || "", } = usePopAction(screenId || "");
});
// 확인 메시지 결정 // 확인 메시지 결정
const getConfirmMessage = useCallback((): string => { const getConfirmMessage = useCallback((): string => {
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
if (config?.confirm?.message) return config.confirm.message; if (config?.confirm?.message) return config.confirm.message;
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]; return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
}, [config?.confirm?.message, config?.action?.type]); }, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
// 메인 액션 실행
const executeMainAction = useCallback(async (): Promise<boolean> => {
const action = config?.action;
if (!action) return false;
try {
switch (action.type) {
case "save": {
// sharedData에서 데이터 수집 (후속 통합에서 실제 구현)
const record: Record<string, unknown> = {};
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]);
// 클릭 핸들러 // 클릭 핸들러
const handleClick = useCallback(async () => { const handleClick = useCallback(async () => {
@ -408,33 +343,14 @@ export function PopButtonComponent({
return; return;
} }
// 확인 다이얼로그 필요 시 const action = config?.action;
if (config?.confirm?.enabled) { if (!action) return;
setShowConfirm(true);
return;
}
// 바로 실행 await execute(action, {
const success = await executeMainAction(); confirm: config?.confirm,
if (success) { followUpActions: config?.followUpActions,
await executeFollowUpActions(); });
} }, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]);
}, [
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]);
// 외형 // 외형
const buttonLabel = config?.label || label || "버튼"; const buttonLabel = config?.label || label || "버튼";
@ -448,6 +364,7 @@ export function PopButtonComponent({
<Button <Button
variant={variant} variant={variant}
onClick={handleClick} onClick={handleClick}
disabled={isLoading}
className={cn( className={cn(
"transition-transform active:scale-95", "transition-transform active:scale-95",
isIconOnly && "px-2" isIconOnly && "px-2"
@ -464,8 +381,8 @@ export function PopButtonComponent({
</Button> </Button>
</div> </div>
{/* 확인 다이얼로그 */} {/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}> <AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]"> <AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> <AlertDialogTitle className="text-base sm:text-lg">
@ -480,7 +397,7 @@ export function PopButtonComponent({
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirmExecute} onClick={confirmExecute}
className={cn( className={cn(
"h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm", "h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm",
config?.action?.type === "delete" && config?.action?.type === "delete" &&
@ -747,6 +664,8 @@ function ActionDetailFields({
onUpdate: (updates: Partial<ButtonMainAction>) => void; onUpdate: (updates: Partial<ButtonMainAction>) => void;
disabled?: boolean; disabled?: boolean;
}) { }) {
// 디자이너 컨텍스트 (뷰어에서는 null)
const designerCtx = usePopDesignerContext();
const actionType = action?.type || "save"; const actionType = action?.type || "save";
switch (actionType) { switch (actionType) {
@ -852,6 +771,38 @@ function ActionDetailFields({
disabled={disabled} disabled={disabled}
/> />
</div> </div>
{/* 모달 캔버스 생성/열기 (fullscreen 모드 + 디자이너 내부) */}
{action?.modalMode === "fullscreen" && designerCtx && (
<div>
{action?.modalScreenId ? (
<Button
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => designerCtx.navigateToCanvas(action.modalScreenId!)}
>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => {
const selectedId = designerCtx.selectedComponentId;
if (!selectedId) return;
const modalId = designerCtx.createModalCanvas(
selectedId,
action?.modalTitle || "새 모달"
);
onUpdate({ modalScreenId: modalId });
}}
>
</Button>
)}
</div>
)}
</div> </div>
); );

View File

@ -24,9 +24,26 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { GridMode } from "@/components/pop/designer/types/pop-layout"; 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"; import { toast } from "sonner";
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
Home, ArrowLeft, Settings, Search, Plus, Check, X: XIcon,
Edit, Trash2, RefreshCw,
};
// ======================================== // ========================================
// 타입 정의 // 타입 정의
// ======================================== // ========================================
@ -201,8 +218,7 @@ function getImageUrl(imageConfig?: ImageConfig): string | undefined {
// Lucide 아이콘 동적 렌더링 // Lucide 아이콘 동적 렌더링
function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) { function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const IconComponent = LUCIDE_ICON_MAP[name];
const IconComponent = (LucideIcons as any)[name];
if (!IconComponent) return null; if (!IconComponent) return null;
return <IconComponent size={size} className={className} />; return <IconComponent size={size} className={className} />;
} }

View File

@ -8,7 +8,7 @@
* 오버플로우: visibleRows + "전체보기" * 오버플로우: visibleRows + "전체보기"
*/ */
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { ChevronDown, ChevronUp, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react"; import { ChevronDown, ChevronUp, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -18,6 +18,9 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { executePopAction } from "@/hooks/pop/executePopAction";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { toast } from "sonner";
import type { import type {
PopStringListConfig, PopStringListConfig,
CardGridConfig, CardGridConfig,
@ -43,6 +46,7 @@ function resolveColumnName(name: string): string {
interface PopStringListComponentProps { interface PopStringListComponentProps {
config?: PopStringListConfig; config?: PopStringListConfig;
className?: string; className?: string;
screenId?: string;
} }
// 테이블 행 데이터 타입 // 테이블 행 데이터 타입
@ -53,6 +57,7 @@ type RowData = Record<string, unknown>;
export function PopStringListComponent({ export function PopStringListComponent({
config, config,
className, className,
screenId,
}: PopStringListComponentProps) { }: PopStringListComponentProps) {
const displayMode = config?.displayMode || "list"; const displayMode = config?.displayMode || "list";
const header = config?.header; const header = config?.header;
@ -67,6 +72,46 @@ export function PopStringListComponent({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
// 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-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<string, unknown>, {
publish,
screenId,
});
if (result.success) {
toast.success("작업이 완료되었습니다.");
} else {
toast.error(result.error || "작업에 실패했습니다.");
}
} catch {
toast.error("알 수 없는 오류가 발생했습니다.");
} finally {
setLoadingRowIdx(-1);
}
},
[rows, publish, screenId]
);
// 오버플로우 계산 (JSON 복원 시 string 유입 방어) // 오버플로우 계산 (JSON 복원 시 string 유입 방어)
const visibleRows = Number(overflow?.visibleRows) || 5; const visibleRows = Number(overflow?.visibleRows) || 5;
const maxExpandRows = Number(overflow?.maxExpandRows) || 20; const maxExpandRows = Number(overflow?.maxExpandRows) || 20;
@ -83,9 +128,20 @@ export function PopStringListComponent({
setExpanded((prev) => !prev); 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(() => { useEffect(() => {
if (!dataSource?.tableName) { if (!dsTableName) {
setLoading(false); setLoading(false);
setRows([]); setRows([]);
return; return;
@ -98,8 +154,9 @@ export function PopStringListComponent({
try { try {
// 필터 조건 구성 // 필터 조건 구성
const filters: Record<string, unknown> = {}; const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) { const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>;
dataSource.filters.forEach((f) => { if (parsedFilters.length > 0) {
parsedFilters.forEach((f) => {
if (f.column && f.value) { if (f.column && f.value) {
filters[f.column] = f.value; filters[f.column] = f.value;
} }
@ -107,16 +164,16 @@ export function PopStringListComponent({
} }
// 정렬 조건 // 정렬 조건
const sortBy = dataSource.sort?.column; const sortBy = dsSortColumn;
const sortOrder = dataSource.sort?.direction; const sortOrder = dsSortDirection;
// 개수 제한 (string 유입 방어: Number 캐스팅) // 개수 제한 (string 유입 방어: Number 캐스팅)
const size = const size =
dataSource.limit?.mode === "limited" && dataSource.limit?.count dsLimitMode === "limited" && dsLimitCount
? Number(dataSource.limit.count) ? Number(dsLimitCount)
: maxExpandRows; : maxExpandRows;
const result = await dataApi.getTableData(dataSource.tableName, { const result = await dataApi.getTableData(dsTableName, {
page: 1, page: 1,
size, size,
sortBy: sortOrder ? sortBy : undefined, sortBy: sortOrder ? sortBy : undefined,
@ -136,7 +193,7 @@ export function PopStringListComponent({
}; };
fetchData(); fetchData();
}, [dataSource, maxExpandRows]); }, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]);
// 로딩 상태 // 로딩 상태
if (loading) { if (loading) {
@ -199,6 +256,8 @@ export function PopStringListComponent({
<CardModeView <CardModeView
cardGrid={cardGrid} cardGrid={cardGrid}
data={visibleData} data={visibleData}
handleCardButtonClick={handleCardButtonClick}
loadingRowId={loadingRowIdx}
/> />
)} )}
</div> </div>
@ -376,9 +435,11 @@ function ListModeView({ columns, data }: ListModeViewProps) {
interface CardModeViewProps { interface CardModeViewProps {
cardGrid?: CardGridConfig; cardGrid?: CardGridConfig;
data: RowData[]; 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) { if (!cardGrid || (cardGrid.cells || []).length === 0) {
return ( return (
<div className="flex h-full items-center justify-center p-2"> <div className="flex h-full items-center justify-center p-2">
@ -439,7 +500,7 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) {
: "none", : "none",
}} }}
> >
{renderCellContent(cell, row)} {renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
</div> </div>
); );
})} })}
@ -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 value = row[cell.columnName];
const displayValue = value != null ? String(value) : ""; const displayValue = value != null ? String(value) : "";
@ -478,7 +544,16 @@ function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactN
case "button": case "button":
return ( return (
<Button variant="outline" size="sm" className="h-6 text-[10px]"> <Button
variant={cell.buttonVariant || "outline"}
size="sm"
className="h-6 text-[10px]"
disabled={isButtonLoading}
onClick={(e) => {
e.stopPropagation();
onButtonClick?.(cell, row);
}}
>
{cell.label || displayValue} {cell.label || displayValue}
</Button> </Button>
); );

View File

@ -2,6 +2,7 @@
// pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import. // pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import.
import type { CardListDataSource } from "../types"; import type { CardListDataSource } from "../types";
import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "../pop-button";
/** 표시 모드 */ /** 표시 모드 */
export type StringListDisplayMode = "list" | "card"; export type StringListDisplayMode = "list" | "card";
@ -20,6 +21,10 @@ export interface CardCellDefinition {
fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px) fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px)
align?: "left" | "center" | "right"; // 가로 정렬 (기본 left) align?: "left" | "center" | "right"; // 가로 정렬 (기본 left)
verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top) verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top)
// button 타입 전용 (pop-button 로직 재사용)
buttonAction?: ButtonMainAction; // 클릭 시 실행할 액션
buttonVariant?: ButtonVariant; // 버튼 스타일
buttonConfirm?: ConfirmConfig; // 확인 다이얼로그 설정
} }
/** 카드 그리드 레이아웃 설정 */ /** 카드 그리드 레이아웃 설정 */

View File

@ -24,5 +24,5 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules", ".next"]
} }