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";
|
} 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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -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 { 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";
|
||||||
|
|
|
||||||
|
|
@ -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 { 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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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; // 확인 다이얼로그 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드 그리드 레이아웃 설정 */
|
/** 카드 그리드 레이아웃 설정 */
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue