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:
parent
51e1392640
commit
df8cbb3e80
|
|
@ -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 (
|
||||
<PopRenderer
|
||||
<PopViewerWithModals
|
||||
layout={layout}
|
||||
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
||||
screenId={String(screenId)}
|
||||
currentMode={currentModeKey}
|
||||
isDesignMode={false}
|
||||
overrideGap={adjustedGap}
|
||||
overridePadding={adjustedPadding}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<PopModalDefinition>) => 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({
|
|||
</Button>
|
||||
</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
|
||||
ref={containerRef}
|
||||
|
|
@ -680,7 +750,7 @@ export default function PopCanvas({
|
|||
) : (
|
||||
// 그리드 렌더러
|
||||
<PopRenderer
|
||||
layout={layout}
|
||||
layout={activeLayout}
|
||||
viewportWidth={customWidth}
|
||||
currentMode={currentMode}
|
||||
isDesignMode={true}
|
||||
|
|
@ -973,3 +1043,278 @@ function HiddenItem({
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GridMode>("tablet_landscape");
|
||||
|
||||
// 선택된 컴포넌트
|
||||
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
|
||||
? layout.components[selectedComponentId] || null
|
||||
: null;
|
||||
// 모달 캔버스 활성 상태 ("main" 또는 모달 ID)
|
||||
const [activeCanvasId, setActiveCanvasId] = useState<string>("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<PopComponentDefinitionV5>) => {
|
||||
// 함수적 업데이트로 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<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 (
|
||||
<PopDesignerContext.Provider
|
||||
value={{
|
||||
createModalCanvas,
|
||||
navigateToCanvas,
|
||||
activeCanvasId,
|
||||
selectedComponentId,
|
||||
}}
|
||||
>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* 헤더 */}
|
||||
|
|
@ -645,6 +765,9 @@ export default function PopDesigner({
|
|||
onResetOverride={handleResetOverride}
|
||||
onChangeGapPreset={handleChangeGapPreset}
|
||||
previewPageIndex={previewPageIndex}
|
||||
activeCanvasId={activeCanvasId}
|
||||
onActiveCanvasChange={navigateToCanvas}
|
||||
onUpdateModal={handleUpdateModal}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
|
|
@ -670,5 +793,6 @@ export default function PopDesigner({
|
|||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</DndProvider>
|
||||
</PopDesignerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -277,6 +277,7 @@ export default function PopRenderer({
|
|||
effectivePosition={position}
|
||||
isDesignMode={false}
|
||||
isSelected={false}
|
||||
screenId={String(currentScreenId || "")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</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 ActualComp = registeredComp?.component;
|
||||
|
|
@ -576,7 +581,7 @@ function renderActualComponent(component: PopComponentDefinitionV5): React.React
|
|||
if (ActualComp) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<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 { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -1306,7 +1332,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
switch (displayItem.type) {
|
||||
case "icon": {
|
||||
if (!displayItem.icon) return null;
|
||||
const IconComponent = (LucideIcons as any)[displayItem.icon];
|
||||
const IconComponent = LUCIDE_ICON_MAP[displayItem.icon];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent key={displayItem.id} className="mr-1 inline-block h-3 w-3" style={inlineStyle} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FollowUpActionType, string> = {
|
|||
event: "이벤트 발행",
|
||||
refresh: "새로고침",
|
||||
navigate: "화면 이동",
|
||||
"close-modal": "모달 닫기",
|
||||
};
|
||||
|
||||
/** 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 아이콘 동적 렌더링 */
|
||||
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 <IconComponent size={size} className={className} />;
|
||||
}
|
||||
|
|
@ -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<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]);
|
||||
}, [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({
|
|||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
isIconOnly && "px-2"
|
||||
|
|
@ -464,8 +381,8 @@ export function PopButtonComponent({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
{/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
|
||||
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
|
|
@ -480,7 +397,7 @@ export function PopButtonComponent({
|
|||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmExecute}
|
||||
onClick={confirmExecute}
|
||||
className={cn(
|
||||
"h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm",
|
||||
config?.action?.type === "delete" &&
|
||||
|
|
@ -747,6 +664,8 @@ function ActionDetailFields({
|
|||
onUpdate: (updates: Partial<ButtonMainAction>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// 디자이너 컨텍스트 (뷰어에서는 null)
|
||||
const designerCtx = usePopDesignerContext();
|
||||
const actionType = action?.type || "save";
|
||||
|
||||
switch (actionType) {
|
||||
|
|
@ -852,6 +771,38 @@ function ActionDetailFields({
|
|||
disabled={disabled}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, LucideIcon> = {
|
||||
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 <IconComponent size={size} className={className} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
|||
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<string | null>(null);
|
||||
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 유입 방어)
|
||||
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<string, unknown> = {};
|
||||
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({
|
|||
<CardModeView
|
||||
cardGrid={cardGrid}
|
||||
data={visibleData}
|
||||
handleCardButtonClick={handleCardButtonClick}
|
||||
loadingRowId={loadingRowIdx}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
|
|
@ -439,7 +500,7 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) {
|
|||
: "none",
|
||||
}}
|
||||
>
|
||||
{renderCellContent(cell, row)}
|
||||
{renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
|
||||
</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 displayValue = value != null ? String(value) : "";
|
||||
|
||||
|
|
@ -478,7 +544,16 @@ function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactN
|
|||
|
||||
case "button":
|
||||
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}
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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; // 확인 다이얼로그 설정
|
||||
}
|
||||
|
||||
/** 카드 그리드 레이아웃 설정 */
|
||||
|
|
|
|||
|
|
@ -24,5 +24,5 @@
|
|||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue