Compare commits
2 Commits
368d641ae8
...
223f5c0251
| Author | SHA1 | Date |
|---|---|---|
|
|
223f5c0251 | |
|
|
de2163bcef |
|
|
@ -4894,7 +4894,8 @@ export class ScreenManagementService {
|
||||||
/**
|
/**
|
||||||
* POP 레이아웃 저장
|
* POP 레이아웃 저장
|
||||||
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
|
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
|
||||||
* - v2 형식으로 저장 (version: "pop-2.0")
|
* - v3 형식 지원 (version: "pop-3.0", 섹션 제거)
|
||||||
|
* - v2/v1 하위 호환
|
||||||
*/
|
*/
|
||||||
async saveLayoutPop(
|
async saveLayoutPop(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
@ -4905,11 +4906,16 @@ export class ScreenManagementService {
|
||||||
console.log(`=== POP 레이아웃 저장 시작 ===`);
|
console.log(`=== POP 레이아웃 저장 시작 ===`);
|
||||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||||
|
|
||||||
// v2 구조 확인
|
// 버전 감지
|
||||||
|
const isV3 = layoutData.version === "pop-3.0" ||
|
||||||
|
(layoutData.layouts && layoutData.components && !layoutData.sections);
|
||||||
const isV2 = layoutData.version === "pop-2.0" ||
|
const isV2 = layoutData.version === "pop-2.0" ||
|
||||||
(layoutData.layouts && layoutData.sections && layoutData.components);
|
(layoutData.layouts && layoutData.sections && layoutData.components);
|
||||||
|
|
||||||
if (isV2) {
|
if (isV3) {
|
||||||
|
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||||
|
console.log(`v3 레이아웃: ${componentCount}개 컴포넌트 (섹션 없음)`);
|
||||||
|
} else if (isV2) {
|
||||||
const sectionCount = Object.keys(layoutData.sections || {}).length;
|
const sectionCount = Object.keys(layoutData.sections || {}).length;
|
||||||
const componentCount = Object.keys(layoutData.components || {}).length;
|
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||||
console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
|
@ -4933,13 +4939,50 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버전 정보 보장 (v2 우선, v1은 프론트엔드에서 마이그레이션 후 저장 권장)
|
// SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게)
|
||||||
|
const targetCompanyCode = companyCode === "*"
|
||||||
|
? (existingScreen.company_code || "*")
|
||||||
|
: companyCode;
|
||||||
|
|
||||||
|
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
|
||||||
|
|
||||||
|
// 버전 정보 보장
|
||||||
let dataToSave: any;
|
let dataToSave: any;
|
||||||
if (isV2) {
|
if (isV3) {
|
||||||
|
dataToSave = {
|
||||||
|
...layoutData,
|
||||||
|
version: "pop-3.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
// canvasGrid.rows 검증 및 보정
|
||||||
|
if (dataToSave.settings?.canvasGrid) {
|
||||||
|
if (!dataToSave.settings.canvasGrid.rows) {
|
||||||
|
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
||||||
|
dataToSave.settings.canvasGrid.rows = 24;
|
||||||
|
}
|
||||||
|
// 구버전 rowHeight 필드 제거
|
||||||
|
if (dataToSave.settings.canvasGrid.rowHeight) {
|
||||||
|
console.warn("구버전 rowHeight 필드 제거");
|
||||||
|
delete dataToSave.settings.canvasGrid.rowHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isV2) {
|
||||||
dataToSave = {
|
dataToSave = {
|
||||||
...layoutData,
|
...layoutData,
|
||||||
version: "pop-2.0",
|
version: "pop-2.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// canvasGrid.rows 검증 및 보정
|
||||||
|
if (dataToSave.settings?.canvasGrid) {
|
||||||
|
if (!dataToSave.settings.canvasGrid.rows) {
|
||||||
|
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
||||||
|
dataToSave.settings.canvasGrid.rows = 24;
|
||||||
|
}
|
||||||
|
if (dataToSave.settings.canvasGrid.rowHeight) {
|
||||||
|
console.warn("구버전 rowHeight 필드 제거");
|
||||||
|
delete dataToSave.settings.canvasGrid.rowHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// v1 형식으로 저장 (하위 호환)
|
// v1 형식으로 저장 (하위 호환)
|
||||||
dataToSave = {
|
dataToSave = {
|
||||||
|
|
@ -4954,10 +4997,10 @@ export class ScreenManagementService {
|
||||||
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
|
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
|
||||||
ON CONFLICT (screen_id, company_code)
|
ON CONFLICT (screen_id, company_code)
|
||||||
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
|
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
|
||||||
[screenId, companyCode, JSON.stringify(dataToSave), userId || null],
|
[screenId, targetCompanyCode, JSON.stringify(dataToSave), userId || null],
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version})`);
|
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState, useMemo } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, ArrowLeft, Smartphone, Tablet, Monitor, RotateCcw } from "lucide-react";
|
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
|
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
|
|
@ -18,35 +18,89 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
|
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
import {
|
||||||
|
PopLayoutDataV3,
|
||||||
|
PopLayoutModeKey,
|
||||||
|
ensureV3Layout,
|
||||||
|
isV3Layout,
|
||||||
|
} from "@/components/pop/designer/types/pop-layout";
|
||||||
|
import {
|
||||||
|
PopLayoutRenderer,
|
||||||
|
hasBaseLayout,
|
||||||
|
getEffectiveModeLayout,
|
||||||
|
} from "@/components/pop/designer/renderers";
|
||||||
|
import {
|
||||||
|
useResponsiveMode,
|
||||||
|
useResponsiveModeWithOverride,
|
||||||
|
type DeviceType,
|
||||||
|
} from "@/hooks/useDeviceOrientation";
|
||||||
|
|
||||||
// POP 디바이스 타입
|
// 디바이스별 크기 (프리뷰 모드용)
|
||||||
type DeviceType = "mobile" | "tablet";
|
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; height: number; label: string }>> = {
|
||||||
|
mobile: {
|
||||||
// 디바이스별 크기
|
landscape: { width: 667, height: 375, label: "모바일 가로" },
|
||||||
const DEVICE_SIZES = {
|
portrait: { width: 375, height: 667, label: "모바일 세로" },
|
||||||
mobile: { width: 375, height: 812, label: "모바일" },
|
},
|
||||||
tablet: { width: 768, height: 1024, label: "태블릿" },
|
tablet: {
|
||||||
|
landscape: { width: 1024, height: 768, label: "태블릿 가로" },
|
||||||
|
portrait: { width: 768, height: 1024, label: "태블릿 세로" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
|
||||||
|
if (device === "tablet") {
|
||||||
|
return isLandscape ? "tablet_landscape" : "tablet_portrait";
|
||||||
|
}
|
||||||
|
return isLandscape ? "mobile_landscape" : "mobile_portrait";
|
||||||
|
};
|
||||||
|
|
||||||
|
// v3.0 레이아웃인지 확인
|
||||||
|
const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => {
|
||||||
|
return layout && layout.version === "pop-3.0" && layout.layouts && layout.components;
|
||||||
|
};
|
||||||
|
|
||||||
|
// v1/v2 레이아웃인지 확인 (마이그레이션 대상)
|
||||||
|
const isPopLayout = (layout: any): boolean => {
|
||||||
|
return layout && (
|
||||||
|
layout.version === "pop-1.0" ||
|
||||||
|
layout.version === "pop-2.0" ||
|
||||||
|
layout.version === "pop-3.0"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
|
||||||
function PopScreenViewPage() {
|
function PopScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const screenId = parseInt(params.screenId as string);
|
const screenId = parseInt(params.screenId as string);
|
||||||
|
|
||||||
// URL 쿼리에서 디바이스 타입 가져오기 (기본: tablet)
|
|
||||||
const deviceParam = searchParams.get("device") as DeviceType | null;
|
|
||||||
const [deviceType, setDeviceType] = useState<DeviceType>(deviceParam || "tablet");
|
|
||||||
|
|
||||||
// 프리뷰 모드 (디자이너에서 열렸을 때)
|
|
||||||
const isPreviewMode = searchParams.get("preview") === "true";
|
const isPreviewMode = searchParams.get("preview") === "true";
|
||||||
|
|
||||||
// 사용자 정보
|
// 반응형 모드 감지 (화면 크기에 따라 tablet/mobile, landscape/portrait 자동 전환)
|
||||||
|
// 프리뷰 모드에서는 수동 전환 가능
|
||||||
|
const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride(
|
||||||
|
isPreviewMode ? "tablet" : undefined,
|
||||||
|
isPreviewMode ? true : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// 현재 모드 정보
|
||||||
|
const deviceType = mode.device;
|
||||||
|
const isLandscape = mode.isLandscape;
|
||||||
|
const currentModeKey = mode.modeKey;
|
||||||
|
|
||||||
const { user, userName, companyCode } = useAuth();
|
const { user, userName, companyCode } = useAuth();
|
||||||
|
|
||||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
|
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -73,62 +127,39 @@ function PopScreenViewPage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 화면 정보 로드
|
|
||||||
const screenData = await screenApi.getScreen(screenId);
|
const screenData = await screenApi.getScreen(screenId);
|
||||||
setScreen(screenData);
|
setScreen(screenData);
|
||||||
|
|
||||||
// POP 레이아웃 로드 (screen_layouts_pop 테이블에서)
|
|
||||||
// POP 레이아웃은 sections[] 구조 사용 (데스크톱의 components[]와 다름)
|
|
||||||
try {
|
try {
|
||||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||||
|
|
||||||
if (popLayout && popLayout.sections && popLayout.sections.length > 0) {
|
if (popLayout && isPopLayout(popLayout)) {
|
||||||
// POP 레이아웃 (sections 구조) - 그대로 저장
|
// v1/v2/v3 → v3로 변환
|
||||||
console.log("POP 레이아웃 로드:", popLayout.sections?.length || 0, "개 섹션");
|
const v3Layout = ensureV3Layout(popLayout);
|
||||||
setLayout(popLayout as any); // sections 구조 그대로 사용
|
setPopLayoutV3(v3Layout);
|
||||||
} else if (popLayout && popLayout.components && popLayout.components.length > 0) {
|
|
||||||
// 이전 형식 (components 구조) - 호환성 유지
|
const componentCount = Object.keys(v3Layout.components).length;
|
||||||
console.log("POP 레이아웃 로드 (이전 형식):", popLayout.components?.length || 0, "개 컴포넌트");
|
console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||||
|
|
||||||
|
if (!isV3Layout(popLayout)) {
|
||||||
|
console.log("[POP] v1/v2 → v3 자동 마이그레이션 완료");
|
||||||
|
}
|
||||||
|
} else if (popLayout && popLayout.components && Array.isArray(popLayout.components) && popLayout.components.length > 0) {
|
||||||
|
// 이전 형식 (레거시 components 구조)
|
||||||
|
console.log("[POP] 레거시 레이아웃 로드:", popLayout.components.length, "개 컴포넌트");
|
||||||
setLayout(popLayout as LayoutData);
|
setLayout(popLayout as LayoutData);
|
||||||
} else {
|
} else {
|
||||||
// POP 레이아웃이 비어있으면 빈 레이아웃
|
console.log("[POP] 레이아웃 없음");
|
||||||
console.log("POP 레이아웃 없음, 빈 화면 표시");
|
setPopLayoutV3(null);
|
||||||
setLayout({
|
setLayout(null);
|
||||||
screenId,
|
|
||||||
sections: [],
|
|
||||||
components: [],
|
|
||||||
gridSettings: {
|
|
||||||
columns: 12,
|
|
||||||
gap: 8,
|
|
||||||
padding: 16,
|
|
||||||
enabled: true,
|
|
||||||
size: 8,
|
|
||||||
color: "#e0e0e0",
|
|
||||||
opacity: 0.5,
|
|
||||||
snapToGrid: true,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
}
|
}
|
||||||
} catch (layoutError) {
|
} catch (layoutError) {
|
||||||
console.warn("POP 레이아웃 로드 실패:", layoutError);
|
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
||||||
setLayout({
|
setPopLayoutV3(null);
|
||||||
screenId,
|
setLayout(null);
|
||||||
sections: [],
|
|
||||||
components: [],
|
|
||||||
gridSettings: {
|
|
||||||
columns: 12,
|
|
||||||
gap: 8,
|
|
||||||
padding: 16,
|
|
||||||
enabled: true,
|
|
||||||
size: 8,
|
|
||||||
color: "#e0e0e0",
|
|
||||||
opacity: 0.5,
|
|
||||||
snapToGrid: true,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("POP 화면 로드 실패:", error);
|
console.error("[POP] 화면 로드 실패:", error);
|
||||||
setError("화면을 불러오는데 실패했습니다.");
|
setError("화면을 불러오는데 실패했습니다.");
|
||||||
toast.error("화면을 불러오는데 실패했습니다.");
|
toast.error("화면을 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -141,8 +172,7 @@ function PopScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 현재 디바이스 크기
|
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
||||||
const currentDevice = DEVICE_SIZES[deviceType];
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -177,7 +207,7 @@ function PopScreenViewPage() {
|
||||||
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div className="min-h-screen bg-gray-100">
|
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
|
||||||
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
||||||
{isPreviewMode && (
|
{isPreviewMode && (
|
||||||
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||||
|
|
@ -188,27 +218,65 @@ function PopScreenViewPage() {
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium">{screen.screenName}</span>
|
<span className="text-sm font-medium">{screen.screenName}</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
({currentModeKey.replace("_", " ")})
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 디바이스 전환 버튼 */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={deviceType === "mobile" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDevice("mobile")}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
모바일
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={deviceType === "tablet" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDevice("tablet")}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Tablet className="h-4 w-4" />
|
||||||
|
태블릿
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={isLandscape ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOrientation(true)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
가로
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!isLandscape ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOrientation(false)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
세로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자동 감지 모드 버튼 */}
|
||||||
<Button
|
<Button
|
||||||
variant={deviceType === "mobile" ? "default" : "ghost"}
|
variant={isAutoDetect ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setDeviceType("mobile")}
|
onClick={() => {
|
||||||
|
setDevice(undefined);
|
||||||
|
setOrientation(undefined);
|
||||||
|
}}
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
>
|
>
|
||||||
<Smartphone className="h-4 w-4" />
|
자동
|
||||||
모바일
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={deviceType === "tablet" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDeviceType("tablet")}
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
<Tablet className="h-4 w-4" />
|
|
||||||
태블릿
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -220,67 +288,30 @@ function PopScreenViewPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* POP 화면 컨텐츠 */}
|
{/* POP 화면 컨텐츠 */}
|
||||||
<div className={`flex justify-center ${isPreviewMode ? "py-4" : "py-0"}`}>
|
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
|
||||||
|
{/* 현재 모드 표시 (일반 모드) */}
|
||||||
|
{!isPreviewMode && (
|
||||||
|
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{currentModeKey.replace("_", " ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`bg-white ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-hidden border-8 border-gray-800" : ""}`}
|
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-hidden border-8 border-gray-800" : "w-full h-full"}`}
|
||||||
style={{
|
style={isPreviewMode ? {
|
||||||
width: isPreviewMode ? currentDevice.width : "100%",
|
width: currentDevice.width,
|
||||||
minHeight: isPreviewMode ? currentDevice.height : "100vh",
|
height: currentDevice.height,
|
||||||
maxWidth: isPreviewMode ? currentDevice.width : "100%",
|
flexShrink: 0,
|
||||||
}}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* POP 레이아웃: sections 구조 렌더링 */}
|
{/* POP 레이아웃 v3.0 렌더링 */}
|
||||||
{layout && (layout as any).sections && (layout as any).sections.length > 0 ? (
|
{popLayoutV3 ? (
|
||||||
<div className="w-full min-h-full p-2">
|
<PopLayoutV3Renderer
|
||||||
{/* 그리드 레이아웃으로 섹션 배치 */}
|
layout={popLayoutV3}
|
||||||
<div
|
modeKey={currentModeKey}
|
||||||
className="grid gap-1"
|
/>
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `repeat(${(layout as any).canvasGrid?.columns || 24}, 1fr)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(layout as any).sections.map((section: any) => (
|
|
||||||
<div
|
|
||||||
key={section.id}
|
|
||||||
className="bg-gray-50 border border-gray-200 rounded-lg p-2"
|
|
||||||
style={{
|
|
||||||
gridColumn: `${section.grid?.col || 1} / span ${section.grid?.colSpan || 6}`,
|
|
||||||
gridRow: `${section.grid?.row || 1} / span ${section.grid?.rowSpan || 4}`,
|
|
||||||
minHeight: `${(section.grid?.rowSpan || 4) * 20}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 섹션 라벨 */}
|
|
||||||
{section.label && (
|
|
||||||
<div className="text-xs font-medium text-gray-500 mb-1">
|
|
||||||
{section.label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 섹션 내 컴포넌트들 */}
|
|
||||||
{section.components && section.components.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{section.components.map((comp: any) => (
|
|
||||||
<div
|
|
||||||
key={comp.id}
|
|
||||||
className="bg-white border border-gray-100 rounded p-2 text-sm"
|
|
||||||
>
|
|
||||||
{/* TODO: POP 전용 컴포넌트 렌더러 구현 필요 */}
|
|
||||||
<span className="text-gray-600">
|
|
||||||
{comp.label || comp.type || comp.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-400 text-center py-2">
|
|
||||||
빈 섹션
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : layout && layout.components && layout.components.length > 0 ? (
|
) : layout && layout.components && layout.components.length > 0 ? (
|
||||||
// 이전 형식 (components 구조) - 호환성 유지
|
// 레거시 형식 (components 구조) - 호환성 유지
|
||||||
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||||||
<div className="relative w-full min-h-full p-4">
|
<div className="relative w-full min-h-full p-4">
|
||||||
{layout.components
|
{layout.components
|
||||||
|
|
@ -348,6 +379,55 @@ function PopScreenViewPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// POP 레이아웃 v3.0 렌더러
|
||||||
|
// ========================================
|
||||||
|
interface PopLayoutV3RendererProps {
|
||||||
|
layout: PopLayoutDataV3;
|
||||||
|
modeKey: PopLayoutModeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopLayoutV3Renderer({ layout, modeKey }: PopLayoutV3RendererProps) {
|
||||||
|
// 태블릿 가로 모드가 기준으로 설정되어 있는지 확인
|
||||||
|
if (!hasBaseLayout(layout)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
|
||||||
|
<span className="text-2xl">!</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
||||||
|
화면이 설정되지 않았습니다
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 max-w-xs">
|
||||||
|
POP 화면 디자이너에서 태블릿 가로 모드 레이아웃을 먼저 설정해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 모드에 맞는 레이아웃 가져오기
|
||||||
|
const { modeLayout, isConverted, sourceModeKey } = getEffectiveModeLayout(layout, modeKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col">
|
||||||
|
{isConverted && (
|
||||||
|
<div className="mx-2 mt-2 px-2 py-1 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-700 shrink-0">
|
||||||
|
{sourceModeKey} 기준 자동 변환됨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PopLayoutRenderer
|
||||||
|
layout={layout}
|
||||||
|
modeKey={modeKey}
|
||||||
|
customModeLayout={isConverted ? modeLayout : undefined}
|
||||||
|
isDesignMode={false}
|
||||||
|
className="flex-1"
|
||||||
|
style={{ height: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Provider 래퍼
|
// Provider 래퍼
|
||||||
export default function PopScreenViewPageWrapper() {
|
export default function PopScreenViewPageWrapper() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
import { useCallback, useRef, useState, useEffect } from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import GridLayout, { Layout } from "react-grid-layout";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV2,
|
PopLayoutDataV3,
|
||||||
PopLayoutModeKey,
|
PopLayoutModeKey,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
GridPosition,
|
GridPosition,
|
||||||
MODE_RESOLUTIONS,
|
MODE_RESOLUTIONS,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { DND_ITEM_TYPES, DragItemSection } from "./panels/PopPanel";
|
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
||||||
import { GripVertical, Trash2, ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
|
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SectionGridV2 } from "./SectionGridV2";
|
|
||||||
|
|
||||||
import "react-grid-layout/css/styles.css";
|
|
||||||
import "react-resizable/css/styles.css";
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 타입 정의
|
// 타입 정의
|
||||||
|
|
@ -32,24 +27,29 @@ const MODE_LABELS: Record<PopLayoutModeKey, string> = {
|
||||||
mobile_portrait: "모바일 세로",
|
mobile_portrait: "모바일 세로",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 타입별 라벨
|
||||||
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
|
"pop-field": "필드",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-list": "리스트",
|
||||||
|
"pop-indicator": "인디케이터",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-numpad": "숫자패드",
|
||||||
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
// ========================================
|
// ========================================
|
||||||
interface PopCanvasProps {
|
interface PopCanvasProps {
|
||||||
layout: PopLayoutDataV2;
|
layout: PopLayoutDataV3;
|
||||||
activeDevice: DeviceType;
|
activeDevice: DeviceType;
|
||||||
activeModeKey: PopLayoutModeKey;
|
activeModeKey: PopLayoutModeKey;
|
||||||
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
|
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
|
||||||
selectedSectionId: string | null;
|
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
onSelectSection: (id: string | null) => void;
|
|
||||||
onSelectComponent: (id: string | null) => void;
|
onSelectComponent: (id: string | null) => void;
|
||||||
onUpdateSectionPosition: (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
|
||||||
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
||||||
onDeleteSection: (id: string) => void;
|
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void;
|
||||||
onDropSection: (gridPosition: GridPosition) => void;
|
onDeleteComponent: (componentId: string) => void;
|
||||||
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
|
||||||
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -60,51 +60,33 @@ export function PopCanvas({
|
||||||
activeDevice,
|
activeDevice,
|
||||||
activeModeKey,
|
activeModeKey,
|
||||||
onModeKeyChange,
|
onModeKeyChange,
|
||||||
selectedSectionId,
|
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
onSelectSection,
|
|
||||||
onSelectComponent,
|
onSelectComponent,
|
||||||
onUpdateSectionPosition,
|
|
||||||
onUpdateComponentPosition,
|
onUpdateComponentPosition,
|
||||||
onDeleteSection,
|
|
||||||
onDropSection,
|
|
||||||
onDropComponent,
|
onDropComponent,
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
}: PopCanvasProps) {
|
}: PopCanvasProps) {
|
||||||
const { settings, sections, components, layouts } = layout;
|
const { settings, components, layouts } = layout;
|
||||||
const canvasGrid = settings.canvasGrid;
|
const canvasGrid = settings.canvasGrid;
|
||||||
|
|
||||||
// 줌 상태 (0.3 ~ 1.0 범위)
|
// 줌 상태 (0.3 ~ 1.5 범위)
|
||||||
const [canvasScale, setCanvasScale] = useState(0.6);
|
const [canvasScale, setCanvasScale] = useState(0.6);
|
||||||
|
|
||||||
// 패닝 상태
|
// 패닝 상태
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||||
const [isSpacePressed, setIsSpacePressed] = useState(false); // Space 키 눌림 상태
|
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 줌 인 (최대 1.5로 증가)
|
// 줌 컨트롤
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
||||||
setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
||||||
};
|
const handleZoomFit = () => setCanvasScale(1.0);
|
||||||
|
|
||||||
// 줌 아웃 (최소 0.3)
|
// 패닝
|
||||||
const handleZoomOut = () => {
|
|
||||||
setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 맞춤 (1.0)
|
|
||||||
const handleZoomFit = () => {
|
|
||||||
setCanvasScale(1.0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 패닝 시작 (중앙 마우스 버튼 또는 배경 영역 드래그)
|
|
||||||
const handlePanStart = (e: React.MouseEvent) => {
|
const handlePanStart = (e: React.MouseEvent) => {
|
||||||
// 중앙 마우스 버튼(휠 버튼, button === 1) 또는 Space 키 누른 상태
|
|
||||||
// 또는 내부 컨테이너(스크롤 영역) 직접 클릭 시
|
|
||||||
const isMiddleButton = e.button === 1;
|
const isMiddleButton = e.button === 1;
|
||||||
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area");
|
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area");
|
||||||
|
|
||||||
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
|
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
setPanStart({ x: e.clientX, y: e.clientY });
|
setPanStart({ x: e.clientX, y: e.clientY });
|
||||||
|
|
@ -112,60 +94,44 @@ export function PopCanvas({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 패닝 중
|
|
||||||
const handlePanMove = (e: React.MouseEvent) => {
|
const handlePanMove = (e: React.MouseEvent) => {
|
||||||
if (!isPanning || !containerRef.current) return;
|
if (!isPanning || !containerRef.current) return;
|
||||||
|
|
||||||
const deltaX = e.clientX - panStart.x;
|
const deltaX = e.clientX - panStart.x;
|
||||||
const deltaY = e.clientY - panStart.y;
|
const deltaY = e.clientY - panStart.y;
|
||||||
|
|
||||||
containerRef.current.scrollLeft -= deltaX;
|
containerRef.current.scrollLeft -= deltaX;
|
||||||
containerRef.current.scrollTop -= deltaY;
|
containerRef.current.scrollTop -= deltaY;
|
||||||
|
|
||||||
setPanStart({ x: e.clientX, y: e.clientY });
|
setPanStart({ x: e.clientX, y: e.clientY });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 패닝 종료
|
const handlePanEnd = () => setIsPanning(false);
|
||||||
const handlePanEnd = () => {
|
|
||||||
setIsPanning(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 마우스 휠 줌 (0.3 ~ 1.5 범위)
|
// 마우스 휠 줌
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
e.preventDefault(); // 브라우저 스크롤 방지
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1; // 위로 스크롤: 줌인, 아래로 스크롤: 줌아웃
|
|
||||||
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Space 키 감지
|
// Space 키 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.code === "Space" && !isSpacePressed) {
|
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
|
||||||
setIsSpacePressed(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.code === "Space") {
|
if (e.code === "Space") setIsSpacePressed(false);
|
||||||
setIsSpacePressed(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
};
|
};
|
||||||
}, [isSpacePressed]);
|
}, [isSpacePressed]);
|
||||||
|
|
||||||
// 초기 로드 시 캔버스를 중앙으로 스크롤
|
// 초기 로드 시 캔버스 중앙 스크롤
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
// 약간의 딜레이 후 중앙으로 스크롤 (DOM이 완전히 렌더링된 후)
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const scrollX = (container.scrollWidth - container.clientWidth) / 2;
|
const scrollX = (container.scrollWidth - container.clientWidth) / 2;
|
||||||
const scrollY = (container.scrollHeight - container.clientHeight) / 2;
|
const scrollY = (container.scrollHeight - container.clientHeight) / 2;
|
||||||
|
|
@ -173,7 +139,7 @@ export function PopCanvas({
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [activeDevice]); // 디바이스 변경 시 재중앙화
|
}, [activeDevice]);
|
||||||
|
|
||||||
// 현재 디바이스의 가로/세로 모드 키
|
// 현재 디바이스의 가로/세로 모드 키
|
||||||
const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
||||||
|
|
@ -183,117 +149,6 @@ export function PopCanvas({
|
||||||
? "tablet_portrait"
|
? "tablet_portrait"
|
||||||
: "mobile_portrait";
|
: "mobile_portrait";
|
||||||
|
|
||||||
// 단일 캔버스 프레임 렌더링
|
|
||||||
const renderDeviceFrame = (modeKey: PopLayoutModeKey) => {
|
|
||||||
const resolution = MODE_RESOLUTIONS[modeKey];
|
|
||||||
const isActive = modeKey === activeModeKey;
|
|
||||||
const modeLayout = layouts[modeKey];
|
|
||||||
|
|
||||||
// 이 모드의 섹션 위치 목록
|
|
||||||
const sectionPositions = modeLayout.sectionPositions;
|
|
||||||
const sectionIds = Object.keys(sectionPositions);
|
|
||||||
|
|
||||||
// GridLayout용 레이아웃 아이템 생성
|
|
||||||
const gridLayoutItems: Layout[] = sectionIds.map((sectionId) => {
|
|
||||||
const pos = sectionPositions[sectionId];
|
|
||||||
return {
|
|
||||||
i: sectionId,
|
|
||||||
x: pos.col - 1,
|
|
||||||
y: pos.row - 1,
|
|
||||||
w: pos.colSpan,
|
|
||||||
h: pos.rowSpan,
|
|
||||||
minW: 2,
|
|
||||||
minH: 1,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const cols = canvasGrid.columns;
|
|
||||||
const rowHeight = canvasGrid.rowHeight;
|
|
||||||
const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap];
|
|
||||||
|
|
||||||
const sizeLabel = `${resolution.width}x${resolution.height}`;
|
|
||||||
const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`;
|
|
||||||
|
|
||||||
// 드래그/리사이즈 완료 핸들러
|
|
||||||
const handleDragResizeStop = (
|
|
||||||
layoutItems: Layout[],
|
|
||||||
oldItem: Layout,
|
|
||||||
newItem: Layout
|
|
||||||
) => {
|
|
||||||
const newPos: GridPosition = {
|
|
||||||
col: newItem.x + 1,
|
|
||||||
row: newItem.y + 1,
|
|
||||||
colSpan: newItem.w,
|
|
||||||
rowSpan: newItem.h,
|
|
||||||
};
|
|
||||||
onUpdateSectionPosition(newItem.i, newPos, modeKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={modeKey}
|
|
||||||
className={cn(
|
|
||||||
"relative shrink-0 cursor-pointer rounded-lg border-4 bg-white shadow-xl transition-all",
|
|
||||||
isActive
|
|
||||||
? "border-primary ring-2 ring-primary/30"
|
|
||||||
: "border-gray-300 hover:border-gray-400"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: resolution.width * canvasScale,
|
|
||||||
height: resolution.height * canvasScale,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isActive) {
|
|
||||||
onModeKeyChange(modeKey);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 모드 라벨 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
|
|
||||||
isActive ? "text-primary" : "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{modeLabel}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 활성 표시 배지 */}
|
|
||||||
{isActive && (
|
|
||||||
<div className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-white shadow-lg">
|
|
||||||
편집 중
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 드롭 영역 */}
|
|
||||||
<CanvasDropZone
|
|
||||||
modeKey={modeKey}
|
|
||||||
isActive={isActive}
|
|
||||||
resolution={resolution}
|
|
||||||
scale={canvasScale}
|
|
||||||
cols={cols}
|
|
||||||
rowHeight={rowHeight}
|
|
||||||
margin={margin}
|
|
||||||
sections={sections}
|
|
||||||
components={components}
|
|
||||||
sectionPositions={sectionPositions}
|
|
||||||
componentPositions={modeLayout.componentPositions}
|
|
||||||
gridLayoutItems={gridLayoutItems}
|
|
||||||
selectedSectionId={selectedSectionId}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onSelectSection={onSelectSection}
|
|
||||||
onSelectComponent={onSelectComponent}
|
|
||||||
onDragResizeStop={handleDragResizeStop}
|
|
||||||
onDropSection={onDropSection}
|
|
||||||
onDropComponent={onDropComponent}
|
|
||||||
onUpdateComponentPosition={(compId, pos) => onUpdateComponentPosition(compId, pos, modeKey)}
|
|
||||||
onDeleteSection={onDeleteSection}
|
|
||||||
onDeleteComponent={onDeleteComponent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col bg-gray-50">
|
<div className="relative flex h-full flex-col bg-gray-50">
|
||||||
{/* 줌 컨트롤 바 */}
|
{/* 줌 컨트롤 바 */}
|
||||||
|
|
@ -301,36 +156,18 @@ export function PopCanvas({
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
줌: {Math.round(canvasScale * 100)}%
|
줌: {Math.round(canvasScale * 100)}%
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut} title="줌 아웃">
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
title="줌 아웃"
|
|
||||||
>
|
|
||||||
<ZoomOut className="h-4 w-4" />
|
<ZoomOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn} title="줌 인">
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
title="줌 인"
|
|
||||||
>
|
|
||||||
<ZoomIn className="h-4 w-4" />
|
<ZoomIn className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit} title="맞춤 (100%)">
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleZoomFit}
|
|
||||||
title="맞춤 (100%)"
|
|
||||||
>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
<Maximize2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 캔버스 영역 (패닝 가능) */}
|
{/* 캔버스 영역 */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -344,21 +181,39 @@ export function PopCanvas({
|
||||||
onMouseLeave={handlePanEnd}
|
onMouseLeave={handlePanEnd}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
{/* 스크롤 가능한 큰 영역 - 빈 공간 클릭 시 패닝 가능 */}
|
|
||||||
<div
|
<div
|
||||||
className="canvas-scroll-area flex items-center justify-center gap-16"
|
className="canvas-scroll-area flex items-center justify-center gap-16"
|
||||||
style={{
|
style={{ padding: "500px", minWidth: "fit-content", minHeight: "fit-content" }}
|
||||||
// 캔버스 주변에 충분한 여백 확보 (상하좌우 500px씩)
|
|
||||||
padding: "500px",
|
|
||||||
minWidth: "fit-content",
|
|
||||||
minHeight: "fit-content",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* 가로 모드 캔버스 */}
|
{/* 가로 모드 */}
|
||||||
{renderDeviceFrame(landscapeModeKey)}
|
<DeviceFrame
|
||||||
|
modeKey={landscapeModeKey}
|
||||||
|
isActive={landscapeModeKey === activeModeKey}
|
||||||
|
scale={canvasScale}
|
||||||
|
canvasGrid={canvasGrid}
|
||||||
|
layout={layout}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onModeKeyChange={onModeKeyChange}
|
||||||
|
onSelectComponent={onSelectComponent}
|
||||||
|
onUpdateComponentPosition={onUpdateComponentPosition}
|
||||||
|
onDropComponent={onDropComponent}
|
||||||
|
onDeleteComponent={onDeleteComponent}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 세로 모드 캔버스 */}
|
{/* 세로 모드 */}
|
||||||
{renderDeviceFrame(portraitModeKey)}
|
<DeviceFrame
|
||||||
|
modeKey={portraitModeKey}
|
||||||
|
isActive={portraitModeKey === activeModeKey}
|
||||||
|
scale={canvasScale}
|
||||||
|
canvasGrid={canvasGrid}
|
||||||
|
layout={layout}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onModeKeyChange={onModeKeyChange}
|
||||||
|
onSelectComponent={onSelectComponent}
|
||||||
|
onUpdateComponentPosition={onUpdateComponentPosition}
|
||||||
|
onDropComponent={onDropComponent}
|
||||||
|
onDeleteComponent={onDeleteComponent}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -366,89 +221,202 @@ export function PopCanvas({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 캔버스 드롭 영역 컴포넌트
|
// CSS Grid 기반 디바이스 프레임 (v3: 컴포넌트 직접 배치)
|
||||||
// ========================================
|
// ========================================
|
||||||
interface CanvasDropZoneProps {
|
interface DeviceFrameProps {
|
||||||
modeKey: PopLayoutModeKey;
|
modeKey: PopLayoutModeKey;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
resolution: { width: number; height: number };
|
|
||||||
scale: number;
|
scale: number;
|
||||||
cols: number;
|
canvasGrid: { columns: number; rows: number; gap: number };
|
||||||
rowHeight: number;
|
layout: PopLayoutDataV3;
|
||||||
margin: [number, number];
|
|
||||||
sections: PopLayoutDataV2["sections"];
|
|
||||||
components: PopLayoutDataV2["components"];
|
|
||||||
sectionPositions: Record<string, GridPosition>;
|
|
||||||
componentPositions: Record<string, GridPosition>;
|
|
||||||
gridLayoutItems: Layout[];
|
|
||||||
selectedSectionId: string | null;
|
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
onSelectSection: (id: string | null) => void;
|
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
|
||||||
onSelectComponent: (id: string | null) => void;
|
onSelectComponent: (id: string | null) => void;
|
||||||
onDragResizeStop: (layout: Layout[], oldItem: Layout, newItem: Layout) => void;
|
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
||||||
onDropSection: (gridPosition: GridPosition) => void;
|
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void;
|
||||||
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
onDeleteComponent: (componentId: string) => void;
|
||||||
onUpdateComponentPosition: (componentId: string, position: GridPosition) => void;
|
|
||||||
onDeleteSection: (id: string) => void;
|
|
||||||
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CanvasDropZone({
|
function DeviceFrame({
|
||||||
modeKey,
|
modeKey,
|
||||||
isActive,
|
isActive,
|
||||||
resolution,
|
|
||||||
scale,
|
scale,
|
||||||
cols,
|
canvasGrid,
|
||||||
rowHeight,
|
layout,
|
||||||
margin,
|
|
||||||
sections,
|
|
||||||
components,
|
|
||||||
sectionPositions,
|
|
||||||
componentPositions,
|
|
||||||
gridLayoutItems,
|
|
||||||
selectedSectionId,
|
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
onSelectSection,
|
onModeKeyChange,
|
||||||
onSelectComponent,
|
onSelectComponent,
|
||||||
onDragResizeStop,
|
|
||||||
onDropSection,
|
|
||||||
onDropComponent,
|
|
||||||
onUpdateComponentPosition,
|
onUpdateComponentPosition,
|
||||||
onDeleteSection,
|
onDropComponent,
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
}: CanvasDropZoneProps) {
|
}: DeviceFrameProps) {
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
const dropRef = useRef<HTMLDivElement>(null);
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { components, layouts } = layout;
|
||||||
|
const resolution = MODE_RESOLUTIONS[modeKey];
|
||||||
|
const modeLayout = layouts[modeKey];
|
||||||
|
const componentPositions = modeLayout.componentPositions;
|
||||||
|
const componentIds = Object.keys(componentPositions);
|
||||||
|
|
||||||
// 스케일 적용된 크기
|
const cols = canvasGrid.columns;
|
||||||
const scaledWidth = resolution.width * scale;
|
const rows = canvasGrid.rows || 24;
|
||||||
const scaledHeight = resolution.height * scale;
|
const gap = canvasGrid.gap;
|
||||||
|
|
||||||
// 섹션 드롭 핸들러
|
// 드래그 상태
|
||||||
|
const [dragState, setDragState] = useState<{
|
||||||
|
componentId: string;
|
||||||
|
startPos: GridPosition;
|
||||||
|
currentPos: GridPosition;
|
||||||
|
isDragging: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 리사이즈 상태
|
||||||
|
const [resizeState, setResizeState] = useState<{
|
||||||
|
componentId: string;
|
||||||
|
startPos: GridPosition;
|
||||||
|
currentPos: GridPosition;
|
||||||
|
handle: "se" | "sw" | "ne" | "nw" | "e" | "w" | "n" | "s";
|
||||||
|
isResizing: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 라벨
|
||||||
|
const sizeLabel = `${resolution.width}x${resolution.height}`;
|
||||||
|
const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`;
|
||||||
|
|
||||||
|
// 마우스 → 그리드 좌표 변환
|
||||||
|
const getGridPosition = useCallback((clientX: number, clientY: number): { col: number; row: number } => {
|
||||||
|
if (!gridRef.current) return { col: 1, row: 1 };
|
||||||
|
const rect = gridRef.current.getBoundingClientRect();
|
||||||
|
const x = (clientX - rect.left) / scale;
|
||||||
|
const y = (clientY - rect.top) / scale;
|
||||||
|
const cellWidth = (resolution.width - gap * (cols + 1)) / cols;
|
||||||
|
const cellHeight = (resolution.height - gap * (rows + 1)) / rows;
|
||||||
|
const col = Math.max(1, Math.min(cols, Math.floor((x - gap) / (cellWidth + gap)) + 1));
|
||||||
|
const row = Math.max(1, Math.min(rows, Math.floor((y - gap) / (cellHeight + gap)) + 1));
|
||||||
|
return { col, row };
|
||||||
|
}, [scale, resolution, cols, rows, gap]);
|
||||||
|
|
||||||
|
// 드래그 시작
|
||||||
|
const handleDragStart = useCallback((e: React.MouseEvent, componentId: string) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const pos = componentPositions[componentId];
|
||||||
|
setDragState({
|
||||||
|
componentId,
|
||||||
|
startPos: { ...pos },
|
||||||
|
currentPos: { ...pos },
|
||||||
|
isDragging: true,
|
||||||
|
});
|
||||||
|
}, [isActive, componentPositions]);
|
||||||
|
|
||||||
|
// 마우스 이동
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (dragState?.isDragging && gridRef.current) {
|
||||||
|
const { col, row } = getGridPosition(e.clientX, e.clientY);
|
||||||
|
const newCol = Math.max(1, Math.min(cols - dragState.startPos.colSpan + 1, col));
|
||||||
|
const newRow = Math.max(1, Math.min(rows - dragState.startPos.rowSpan + 1, row));
|
||||||
|
setDragState(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
currentPos: { ...prev.startPos, col: newCol, row: newRow }
|
||||||
|
} : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizeState?.isResizing && gridRef.current) {
|
||||||
|
const { col, row } = getGridPosition(e.clientX, e.clientY);
|
||||||
|
const startPos = resizeState.startPos;
|
||||||
|
let newPos = { ...startPos };
|
||||||
|
|
||||||
|
switch (resizeState.handle) {
|
||||||
|
case "se":
|
||||||
|
newPos.colSpan = Math.max(2, col - startPos.col + 1);
|
||||||
|
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
|
||||||
|
break;
|
||||||
|
case "e":
|
||||||
|
newPos.colSpan = Math.max(2, col - startPos.col + 1);
|
||||||
|
break;
|
||||||
|
case "s":
|
||||||
|
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
|
||||||
|
break;
|
||||||
|
case "sw":
|
||||||
|
const newColSW = Math.min(col, startPos.col + startPos.colSpan - 2);
|
||||||
|
newPos.col = newColSW;
|
||||||
|
newPos.colSpan = startPos.col + startPos.colSpan - newColSW;
|
||||||
|
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
|
||||||
|
break;
|
||||||
|
case "w":
|
||||||
|
const newColW = Math.min(col, startPos.col + startPos.colSpan - 2);
|
||||||
|
newPos.col = newColW;
|
||||||
|
newPos.colSpan = startPos.col + startPos.colSpan - newColW;
|
||||||
|
break;
|
||||||
|
case "ne":
|
||||||
|
const newRowNE = Math.min(row, startPos.row + startPos.rowSpan - 2);
|
||||||
|
newPos.row = newRowNE;
|
||||||
|
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNE;
|
||||||
|
newPos.colSpan = Math.max(2, col - startPos.col + 1);
|
||||||
|
break;
|
||||||
|
case "n":
|
||||||
|
const newRowN = Math.min(row, startPos.row + startPos.rowSpan - 2);
|
||||||
|
newPos.row = newRowN;
|
||||||
|
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowN;
|
||||||
|
break;
|
||||||
|
case "nw":
|
||||||
|
const newColNW = Math.min(col, startPos.col + startPos.colSpan - 2);
|
||||||
|
const newRowNW = Math.min(row, startPos.row + startPos.rowSpan - 2);
|
||||||
|
newPos.col = newColNW;
|
||||||
|
newPos.row = newRowNW;
|
||||||
|
newPos.colSpan = startPos.col + startPos.colSpan - newColNW;
|
||||||
|
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNW;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
newPos.col = Math.max(1, newPos.col);
|
||||||
|
newPos.row = Math.max(1, newPos.row);
|
||||||
|
newPos.colSpan = Math.min(cols - newPos.col + 1, newPos.colSpan);
|
||||||
|
newPos.rowSpan = Math.min(rows - newPos.row + 1, newPos.rowSpan);
|
||||||
|
|
||||||
|
setResizeState(prev => prev ? { ...prev, currentPos: newPos } : null);
|
||||||
|
}
|
||||||
|
}, [dragState, resizeState, getGridPosition, cols, rows]);
|
||||||
|
|
||||||
|
// 드래그/리사이즈 종료
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (dragState?.isDragging) {
|
||||||
|
onUpdateComponentPosition(dragState.componentId, dragState.currentPos, modeKey);
|
||||||
|
setDragState(null);
|
||||||
|
}
|
||||||
|
if (resizeState?.isResizing) {
|
||||||
|
onUpdateComponentPosition(resizeState.componentId, resizeState.currentPos, modeKey);
|
||||||
|
setResizeState(null);
|
||||||
|
}
|
||||||
|
}, [dragState, resizeState, onUpdateComponentPosition, modeKey]);
|
||||||
|
|
||||||
|
// 리사이즈 시작
|
||||||
|
const handleResizeStart = useCallback((e: React.MouseEvent, componentId: string, handle: string) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const pos = componentPositions[componentId];
|
||||||
|
setResizeState({
|
||||||
|
componentId,
|
||||||
|
startPos: { ...pos },
|
||||||
|
currentPos: { ...pos },
|
||||||
|
handle: handle as any,
|
||||||
|
isResizing: true,
|
||||||
|
});
|
||||||
|
}, [isActive, componentPositions]);
|
||||||
|
|
||||||
|
// 컴포넌트 드롭
|
||||||
const [{ isOver, canDrop }, drop] = useDrop(
|
const [{ isOver, canDrop }, drop] = useDrop(
|
||||||
() => ({
|
() => ({
|
||||||
accept: DND_ITEM_TYPES.SECTION,
|
accept: DND_ITEM_TYPES.COMPONENT,
|
||||||
drop: (item: DragItemSection, monitor) => {
|
drop: (item: DragItemComponent, monitor) => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
|
|
||||||
const clientOffset = monitor.getClientOffset();
|
const clientOffset = monitor.getClientOffset();
|
||||||
if (!clientOffset || !dropRef.current) return;
|
if (!clientOffset || !gridRef.current) return;
|
||||||
|
const { col, row } = getGridPosition(clientOffset.x, clientOffset.y);
|
||||||
const dropRect = dropRef.current.getBoundingClientRect();
|
onDropComponent(item.componentType, { col, row, colSpan: 4, rowSpan: 3 });
|
||||||
// 스케일 보정
|
|
||||||
const x = (clientOffset.x - dropRect.left) / scale;
|
|
||||||
const y = (clientOffset.y - dropRect.top) / scale;
|
|
||||||
|
|
||||||
// 그리드 위치 계산
|
|
||||||
const colWidth = (resolution.width - 16) / cols;
|
|
||||||
const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1));
|
|
||||||
const row = Math.max(1, Math.floor(y / (rowHeight * scale)) + 1);
|
|
||||||
|
|
||||||
onDropSection({
|
|
||||||
col,
|
|
||||||
row,
|
|
||||||
colSpan: 3,
|
|
||||||
rowSpan: 4,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
canDrop: () => isActive,
|
canDrop: () => isActive,
|
||||||
collect: (monitor) => ({
|
collect: (monitor) => ({
|
||||||
|
|
@ -456,133 +424,139 @@ function CanvasDropZone({
|
||||||
canDrop: monitor.canDrop(),
|
canDrop: monitor.canDrop(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[isActive, resolution, scale, cols, rowHeight, onDropSection]
|
[isActive, getGridPosition, onDropComponent]
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(dropRef);
|
drop(dropRef);
|
||||||
|
|
||||||
const sectionIds = Object.keys(sectionPositions);
|
// 현재 표시할 위치
|
||||||
|
const getDisplayPosition = (componentId: string): GridPosition => {
|
||||||
|
if (dragState?.componentId === componentId && dragState.isDragging) {
|
||||||
|
return dragState.currentPos;
|
||||||
|
}
|
||||||
|
if (resizeState?.componentId === componentId && resizeState.isResizing) {
|
||||||
|
return resizeState.currentPos;
|
||||||
|
}
|
||||||
|
return componentPositions[componentId];
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative shrink-0">
|
||||||
ref={dropRef}
|
{/* 모드 라벨 */}
|
||||||
className={cn(
|
<div
|
||||||
"h-full w-full overflow-hidden rounded-md bg-gray-100 p-1 transition-colors",
|
className={cn(
|
||||||
isOver && canDrop && "bg-primary/10 ring-2 ring-primary ring-inset"
|
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
|
||||||
)}
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
style={{
|
)}
|
||||||
// 내부 컨텐츠를 스케일 조정
|
>
|
||||||
transform: `scale(${scale})`,
|
{modeLabel}
|
||||||
transformOrigin: "top left",
|
</div>
|
||||||
width: resolution.width,
|
|
||||||
height: resolution.height,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onSelectSection(null);
|
|
||||||
onSelectComponent(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sectionIds.length > 0 ? (
|
|
||||||
<GridLayout
|
|
||||||
className="layout"
|
|
||||||
layout={gridLayoutItems}
|
|
||||||
cols={cols}
|
|
||||||
rowHeight={rowHeight}
|
|
||||||
width={resolution.width - 8}
|
|
||||||
margin={margin}
|
|
||||||
containerPadding={[0, 0]}
|
|
||||||
onDragStop={onDragResizeStop}
|
|
||||||
onResizeStop={onDragResizeStop}
|
|
||||||
isDraggable={isActive}
|
|
||||||
isResizable={isActive}
|
|
||||||
compactType={null}
|
|
||||||
preventCollision={false}
|
|
||||||
useCSSTransforms={true}
|
|
||||||
draggableHandle=".section-drag-handle"
|
|
||||||
>
|
|
||||||
{sectionIds.map((sectionId) => {
|
|
||||||
const sectionDef = sections[sectionId];
|
|
||||||
if (!sectionDef) return null;
|
|
||||||
|
|
||||||
return (
|
{/* 디바이스 프레임 */}
|
||||||
<div
|
<div
|
||||||
key={sectionId}
|
ref={dropRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
|
"relative cursor-pointer overflow-hidden rounded-xl bg-white shadow-lg transition-all",
|
||||||
selectedSectionId === sectionId
|
isActive ? "ring-2 ring-primary ring-offset-2" : "ring-1 ring-gray-200 hover:ring-gray-300",
|
||||||
? "border-primary ring-2 ring-primary/30"
|
isOver && canDrop && "ring-2 ring-primary bg-primary/5"
|
||||||
: "border-gray-200 hover:border-gray-400"
|
)}
|
||||||
)}
|
style={{
|
||||||
onClick={(e) => {
|
width: resolution.width * scale,
|
||||||
e.stopPropagation();
|
height: resolution.height * scale,
|
||||||
onSelectSection(sectionId);
|
}}
|
||||||
}}
|
onClick={(e) => {
|
||||||
>
|
if (e.target === e.currentTarget) {
|
||||||
{/* 섹션 헤더 */}
|
if (!isActive) onModeKeyChange(modeKey);
|
||||||
<div
|
else onSelectComponent(null);
|
||||||
className={cn(
|
}
|
||||||
"section-drag-handle flex h-7 shrink-0 cursor-move items-center justify-between border-b px-2",
|
}}
|
||||||
selectedSectionId === sectionId ? "bg-primary/10" : "bg-gray-50"
|
onMouseMove={handleMouseMove}
|
||||||
)}
|
onMouseUp={handleMouseUp}
|
||||||
>
|
onMouseLeave={handleMouseUp}
|
||||||
<div className="flex items-center gap-1">
|
>
|
||||||
<GripVertical className="h-3 w-3 text-gray-400" />
|
{/* CSS Grid (뷰어와 동일) */}
|
||||||
<span className="text-xs font-medium text-gray-600">
|
|
||||||
{sectionDef.label || "섹션"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{selectedSectionId === sectionId && isActive && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5 text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteSection(sectionId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 섹션 내부 - 컴포넌트들 */}
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<SectionGridV2
|
|
||||||
sectionId={sectionId}
|
|
||||||
sectionDef={sectionDef}
|
|
||||||
components={components}
|
|
||||||
componentPositions={componentPositions}
|
|
||||||
isActive={isActive}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onSelectComponent={onSelectComponent}
|
|
||||||
onDropComponent={onDropComponent}
|
|
||||||
onUpdateComponentPosition={onUpdateComponentPosition}
|
|
||||||
onDeleteComponent={onDeleteComponent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</GridLayout>
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
ref={gridRef}
|
||||||
"flex h-full items-center justify-center rounded-lg border-2 border-dashed text-sm",
|
className="origin-top-left"
|
||||||
isOver && canDrop
|
style={{
|
||||||
? "border-primary bg-primary/5 text-primary"
|
transform: `scale(${scale})`,
|
||||||
: "border-gray-300 text-gray-400"
|
width: resolution.width,
|
||||||
)}
|
height: resolution.height,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||||
|
gap: `${gap}px`,
|
||||||
|
padding: `${gap}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isOver && canDrop
|
{componentIds.length > 0 ? (
|
||||||
? "여기에 섹션을 놓으세요"
|
componentIds.map((componentId) => {
|
||||||
: isActive
|
const compDef = components[componentId];
|
||||||
? "왼쪽 패널에서 섹션을 드래그하세요"
|
if (!compDef) return null;
|
||||||
: "클릭하여 편집 모드로 전환"}
|
|
||||||
|
const pos = getDisplayPosition(componentId);
|
||||||
|
const isSelected = selectedComponentId === componentId;
|
||||||
|
const isDragging = dragState?.componentId === componentId && dragState.isDragging;
|
||||||
|
const isResizing = resizeState?.componentId === componentId && resizeState.isResizing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={componentId}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex cursor-move items-center justify-center overflow-hidden rounded-lg border-2 bg-white transition-all",
|
||||||
|
isSelected
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
(isDragging || isResizing) && "opacity-80 shadow-xl z-50"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
gridColumn: `${pos.col} / span ${pos.colSpan}`,
|
||||||
|
gridRow: `${pos.row} / span ${pos.rowSpan}`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isActive) onModeKeyChange(modeKey);
|
||||||
|
onSelectComponent(componentId);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => handleDragStart(e, componentId)}
|
||||||
|
>
|
||||||
|
{/* 컴포넌트 라벨 */}
|
||||||
|
<span className="text-xs text-gray-500 select-none">
|
||||||
|
{compDef.label || COMPONENT_TYPE_LABELS[compDef.type]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 리사이즈 핸들 */}
|
||||||
|
{isActive && isSelected && (
|
||||||
|
<>
|
||||||
|
<div className="absolute -right-1 -bottom-1 h-3 w-3 cursor-se-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "se")} />
|
||||||
|
<div className="absolute -left-1 -bottom-1 h-3 w-3 cursor-sw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "sw")} />
|
||||||
|
<div className="absolute -right-1 -top-1 h-3 w-3 cursor-ne-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "ne")} />
|
||||||
|
<div className="absolute -left-1 -top-1 h-3 w-3 cursor-nw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "nw")} />
|
||||||
|
<div className="absolute right-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-e-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "e")} />
|
||||||
|
<div className="absolute left-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-w-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "w")} />
|
||||||
|
<div className="absolute bottom-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-s-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "s")} />
|
||||||
|
<div className="absolute top-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-n-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "n")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"col-span-full row-span-full flex items-center justify-center text-sm",
|
||||||
|
isOver && canDrop ? "text-primary" : "text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOver && canDrop
|
||||||
|
? "여기에 컴포넌트를 놓으세요"
|
||||||
|
: isActive
|
||||||
|
? "왼쪽 패널에서 컴포넌트를 드래그하세요"
|
||||||
|
: "클릭하여 편집"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { DndProvider } from "react-dnd";
|
import { DndProvider } from "react-dnd";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
import { ArrowLeft, Save, Smartphone, Tablet } from "lucide-react";
|
import { ArrowLeft, Save, Smartphone, Tablet } from "lucide-react";
|
||||||
|
|
@ -16,22 +16,17 @@ import { toast } from "sonner";
|
||||||
import { PopCanvas } from "./PopCanvas";
|
import { PopCanvas } from "./PopCanvas";
|
||||||
import { PopPanel } from "./panels/PopPanel";
|
import { PopPanel } from "./panels/PopPanel";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV2,
|
PopLayoutDataV3,
|
||||||
PopLayoutModeKey,
|
PopLayoutModeKey,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
GridPosition,
|
GridPosition,
|
||||||
PopSectionDefinition,
|
PopComponentDefinition,
|
||||||
createEmptyPopLayoutV2,
|
createEmptyPopLayoutV3,
|
||||||
createSectionDefinition,
|
ensureV3Layout,
|
||||||
createComponentDefinition,
|
addComponentToV3Layout,
|
||||||
ensureV2Layout,
|
removeComponentFromV3Layout,
|
||||||
addSectionToV2Layout,
|
updateComponentPositionInModeV3,
|
||||||
addComponentToV2Layout,
|
isV3Layout,
|
||||||
removeSectionFromV2Layout,
|
|
||||||
removeComponentFromV2Layout,
|
|
||||||
updateSectionPositionInMode,
|
|
||||||
updateComponentPositionInMode,
|
|
||||||
isV2Layout,
|
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
@ -41,16 +36,6 @@ import { ScreenDefinition } from "@/types/screen";
|
||||||
// ========================================
|
// ========================================
|
||||||
type DeviceType = "mobile" | "tablet";
|
type DeviceType = "mobile" | "tablet";
|
||||||
|
|
||||||
/**
|
|
||||||
* 디바이스 + 방향 → 모드 키 변환
|
|
||||||
*/
|
|
||||||
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
|
|
||||||
if (device === "tablet") {
|
|
||||||
return isLandscape ? "tablet_landscape" : "tablet_portrait";
|
|
||||||
}
|
|
||||||
return isLandscape ? "mobile_landscape" : "mobile_portrait";
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -61,7 +46,7 @@ interface PopDesignerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 메인 컴포넌트
|
// 메인 컴포넌트 (v3: 섹션 없이 컴포넌트 직접 배치)
|
||||||
// ========================================
|
// ========================================
|
||||||
export default function PopDesigner({
|
export default function PopDesigner({
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
|
|
@ -69,9 +54,9 @@ export default function PopDesigner({
|
||||||
onScreenUpdate,
|
onScreenUpdate,
|
||||||
}: PopDesignerProps) {
|
}: PopDesignerProps) {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 상태 (v2)
|
// 레이아웃 상태 (v3)
|
||||||
// ========================================
|
// ========================================
|
||||||
const [layout, setLayout] = useState<PopLayoutDataV2>(createEmptyPopLayoutV2());
|
const [layout, setLayout] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
@ -80,31 +65,17 @@ export default function PopDesigner({
|
||||||
// 디바이스/모드 상태
|
// 디바이스/모드 상태
|
||||||
// ========================================
|
// ========================================
|
||||||
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
||||||
|
|
||||||
// 활성 모드 키 (가로/세로 중 현재 포커스된 캔버스)
|
|
||||||
// 기본값: 태블릿 가로
|
|
||||||
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 선택 상태
|
// 선택 상태 (v3: 섹션 없음, 컴포넌트만)
|
||||||
// ========================================
|
// ========================================
|
||||||
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
|
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ========================================
|
// 선택된 컴포넌트 정의
|
||||||
// 파생 상태
|
const selectedComponent: PopComponentDefinition | null = selectedComponentId
|
||||||
// ========================================
|
? layout.components[selectedComponentId] || null
|
||||||
|
: null;
|
||||||
// 선택된 섹션 정의
|
|
||||||
const selectedSection: PopSectionDefinition | null = useMemo(() => {
|
|
||||||
if (!selectedSectionId) return null;
|
|
||||||
return layout.sections[selectedSectionId] || null;
|
|
||||||
}, [layout.sections, selectedSectionId]);
|
|
||||||
|
|
||||||
// 현재 활성 모드의 섹션 ID 목록
|
|
||||||
const activeSectionIds = useMemo(() => {
|
|
||||||
return Object.keys(layout.layouts[activeModeKey].sectionPositions);
|
|
||||||
}, [layout.layouts, activeModeKey]);
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 로드
|
// 레이아웃 로드
|
||||||
|
|
@ -115,31 +86,27 @@ export default function PopDesigner({
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// API가 layout_data 내용을 직접 반환 (언래핑 상태)
|
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (loadedLayout) {
|
if (loadedLayout) {
|
||||||
// v1 또는 v2 → v2로 변환
|
// v1, v2, v3 → v3로 변환
|
||||||
const v2Layout = ensureV2Layout(loadedLayout);
|
const v3Layout = ensureV3Layout(loadedLayout);
|
||||||
setLayout(v2Layout);
|
setLayout(v3Layout);
|
||||||
|
|
||||||
const sectionCount = Object.keys(v2Layout.sections).length;
|
const componentCount = Object.keys(v3Layout.components).length;
|
||||||
const componentCount = Object.keys(v2Layout.components).length;
|
console.log(`POP v3 레이아웃 로드 성공: ${componentCount}개 컴포넌트`);
|
||||||
console.log(`POP v2 레이아웃 로드 성공: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
|
||||||
|
|
||||||
// v1에서 마이그레이션된 경우 알림
|
if (!isV3Layout(loadedLayout)) {
|
||||||
if (!isV2Layout(loadedLayout)) {
|
console.log("v1/v2 → v3 자동 마이그레이션 완료");
|
||||||
console.log("v1 → v2 자동 마이그레이션 완료");
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 레이아웃 없음 - 빈 v2 레이아웃 생성
|
console.log("POP 레이아웃 없음, 빈 v3 레이아웃 생성");
|
||||||
console.log("POP 레이아웃 없음, 빈 v2 레이아웃 생성");
|
setLayout(createEmptyPopLayoutV3());
|
||||||
setLayout(createEmptyPopLayoutV2());
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
console.error("레이아웃 로드 실패:", error);
|
||||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||||
setLayout(createEmptyPopLayoutV2());
|
setLayout(createEmptyPopLayoutV3());
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -167,25 +134,13 @@ export default function PopDesigner({
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId, layout]);
|
}, [selectedScreen?.screenId, layout]);
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 섹션 추가 (4모드 동기화)
|
|
||||||
// ========================================
|
|
||||||
const handleDropSection = useCallback((gridPosition: GridPosition) => {
|
|
||||||
const newId = `section-${Date.now()}`;
|
|
||||||
|
|
||||||
setLayout((prev) => addSectionToV2Layout(prev, newId, gridPosition));
|
|
||||||
setSelectedSectionId(newId);
|
|
||||||
setHasChanges(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 컴포넌트 추가 (4모드 동기화)
|
// 컴포넌트 추가 (4모드 동기화)
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleDropComponent = useCallback(
|
const handleDropComponent = useCallback(
|
||||||
(sectionId: string, type: PopComponentType, gridPosition: GridPosition) => {
|
(type: PopComponentType, gridPosition: GridPosition) => {
|
||||||
const newId = `${type}-${Date.now()}`;
|
const newId = `${type}-${Date.now()}`;
|
||||||
|
setLayout((prev) => addComponentToV3Layout(prev, newId, type, gridPosition));
|
||||||
setLayout((prev) => addComponentToV2Layout(prev, sectionId, newId, type, gridPosition));
|
|
||||||
setSelectedComponentId(newId);
|
setSelectedComponentId(newId);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
|
|
@ -193,16 +148,16 @@ export default function PopDesigner({
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 섹션 정의 업데이트 (공유)
|
// 컴포넌트 정의 업데이트
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleUpdateSectionDefinition = useCallback(
|
const handleUpdateComponentDefinition = useCallback(
|
||||||
(sectionId: string, updates: Partial<PopSectionDefinition>) => {
|
(componentId: string, updates: Partial<PopComponentDefinition>) => {
|
||||||
setLayout((prev) => ({
|
setLayout((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
sections: {
|
components: {
|
||||||
...prev.sections,
|
...prev.components,
|
||||||
[sectionId]: {
|
[componentId]: {
|
||||||
...prev.sections[sectionId],
|
...prev.components[componentId],
|
||||||
...updates,
|
...updates,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -212,63 +167,37 @@ export default function PopDesigner({
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 섹션 위치 업데이트 (현재 모드만)
|
|
||||||
// ========================================
|
|
||||||
const handleUpdateSectionPosition = useCallback(
|
|
||||||
(sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
|
||||||
const targetMode = modeKey || activeModeKey;
|
|
||||||
setLayout((prev) => updateSectionPositionInMode(prev, targetMode, sectionId, position));
|
|
||||||
setHasChanges(true);
|
|
||||||
},
|
|
||||||
[activeModeKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 컴포넌트 위치 업데이트 (현재 모드만)
|
// 컴포넌트 위치 업데이트 (현재 모드만)
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleUpdateComponentPosition = useCallback(
|
const handleUpdateComponentPosition = useCallback(
|
||||||
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
||||||
const targetMode = modeKey || activeModeKey;
|
const targetMode = modeKey || activeModeKey;
|
||||||
setLayout((prev) => updateComponentPositionInMode(prev, targetMode, componentId, position));
|
setLayout((prev) => updateComponentPositionInModeV3(prev, targetMode, componentId, position));
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[activeModeKey]
|
[activeModeKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 섹션 삭제 (4모드 동기화)
|
// 컴포넌트 삭제 (4모드 동기화)
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleDeleteSection = useCallback((sectionId: string) => {
|
const handleDeleteComponent = useCallback((componentId: string) => {
|
||||||
setLayout((prev) => removeSectionFromV2Layout(prev, sectionId));
|
setLayout((prev) => removeComponentFromV3Layout(prev, componentId));
|
||||||
setSelectedSectionId(null);
|
|
||||||
setSelectedComponentId(null);
|
setSelectedComponentId(null);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 삭제 (4모드 동기화)
|
|
||||||
// ========================================
|
|
||||||
const handleDeleteComponent = useCallback(
|
|
||||||
(sectionId: string, componentId: string) => {
|
|
||||||
setLayout((prev) => removeComponentFromV2Layout(prev, sectionId, componentId));
|
|
||||||
setSelectedComponentId(null);
|
|
||||||
setHasChanges(true);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 디바이스 전환
|
// 디바이스 전환
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleDeviceChange = useCallback((device: DeviceType) => {
|
const handleDeviceChange = useCallback((device: DeviceType) => {
|
||||||
setActiveDevice(device);
|
setActiveDevice(device);
|
||||||
// 기본 모드 키 설정 (가로)
|
|
||||||
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
|
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 모드 키 전환 (캔버스 포커스)
|
// 모드 키 전환
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
|
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
|
||||||
setActiveModeKey(modeKey);
|
setActiveModeKey(modeKey);
|
||||||
|
|
@ -292,7 +221,6 @@ export default function PopDesigner({
|
||||||
// ========================================
|
// ========================================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// input/textarea 포커스 시 제외
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (
|
||||||
target.tagName === "INPUT" ||
|
target.tagName === "INPUT" ||
|
||||||
|
|
@ -302,44 +230,17 @@ export default function PopDesigner({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 또는 Backspace 키
|
|
||||||
if (e.key === "Delete" || e.key === "Backspace") {
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
e.preventDefault(); // 브라우저 뒤로가기 방지
|
e.preventDefault();
|
||||||
|
|
||||||
// 컴포넌트가 선택되어 있으면 컴포넌트 삭제
|
|
||||||
if (selectedComponentId) {
|
if (selectedComponentId) {
|
||||||
// v2 구조: 컴포넌트가 속한 섹션을 sections의 componentIds에서 찾기
|
handleDeleteComponent(selectedComponentId);
|
||||||
// (PopComponentDefinition에는 sectionId가 없으므로 섹션을 순회하여 찾음)
|
|
||||||
let foundSectionId: string | null = null;
|
|
||||||
for (const [sectionId, sectionDef] of Object.entries(layout.sections)) {
|
|
||||||
if (sectionDef.componentIds.includes(selectedComponentId)) {
|
|
||||||
foundSectionId = sectionId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundSectionId) {
|
|
||||||
handleDeleteComponent(foundSectionId, selectedComponentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 컴포넌트가 선택되지 않았고 섹션이 선택되어 있으면 섹션 삭제
|
|
||||||
else if (selectedSectionId) {
|
|
||||||
handleDeleteSection(selectedSectionId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => {
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
}, [selectedComponentId, handleDeleteComponent]);
|
||||||
};
|
|
||||||
}, [
|
|
||||||
selectedComponentId,
|
|
||||||
selectedSectionId,
|
|
||||||
layout.sections,
|
|
||||||
handleDeleteComponent,
|
|
||||||
handleDeleteSection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
|
|
@ -374,7 +275,7 @@ export default function PopDesigner({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 디바이스 전환 (가로/세로 전환 버튼 제거 - 캔버스 2개 동시 표시) */}
|
{/* 중앙: 디바이스 전환 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeDevice}
|
value={activeDevice}
|
||||||
|
|
@ -406,9 +307,9 @@ export default function PopDesigner({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 영역: 리사이즈 가능한 패널 */}
|
{/* 메인 영역 */}
|
||||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||||
{/* 왼쪽: 패널 (컴포넌트/편집 탭) */}
|
{/* 왼쪽: 패널 */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
defaultSize={20}
|
defaultSize={20}
|
||||||
minSize={15}
|
minSize={15}
|
||||||
|
|
@ -418,31 +319,26 @@ export default function PopDesigner({
|
||||||
<PopPanel
|
<PopPanel
|
||||||
layout={layout}
|
layout={layout}
|
||||||
activeModeKey={activeModeKey}
|
activeModeKey={activeModeKey}
|
||||||
selectedSectionId={selectedSectionId}
|
selectedComponentId={selectedComponentId}
|
||||||
selectedSection={selectedSection}
|
selectedComponent={selectedComponent}
|
||||||
onUpdateSectionDefinition={handleUpdateSectionDefinition}
|
onUpdateComponentDefinition={handleUpdateComponentDefinition}
|
||||||
onDeleteSection={handleDeleteSection}
|
onDeleteComponent={handleDeleteComponent}
|
||||||
activeDevice={activeDevice}
|
activeDevice={activeDevice}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
{/* 오른쪽: 캔버스 (가로+세로 2개 동시 표시) */}
|
{/* 오른쪽: 캔버스 */}
|
||||||
<ResizablePanel defaultSize={80}>
|
<ResizablePanel defaultSize={80}>
|
||||||
<PopCanvas
|
<PopCanvas
|
||||||
layout={layout}
|
layout={layout}
|
||||||
activeDevice={activeDevice}
|
activeDevice={activeDevice}
|
||||||
activeModeKey={activeModeKey}
|
activeModeKey={activeModeKey}
|
||||||
onModeKeyChange={handleModeKeyChange}
|
onModeKeyChange={handleModeKeyChange}
|
||||||
selectedSectionId={selectedSectionId}
|
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
onSelectSection={setSelectedSectionId}
|
|
||||||
onSelectComponent={setSelectedComponentId}
|
onSelectComponent={setSelectedComponentId}
|
||||||
onUpdateSectionPosition={handleUpdateSectionPosition}
|
|
||||||
onUpdateComponentPosition={handleUpdateComponentPosition}
|
onUpdateComponentPosition={handleUpdateComponentPosition}
|
||||||
onDeleteSection={handleDeleteSection}
|
|
||||||
onDropSection={handleDropSection}
|
|
||||||
onDropComponent={handleDropComponent}
|
onDropComponent={handleDropComponent}
|
||||||
onDeleteComponent={handleDeleteComponent}
|
onDeleteComponent={handleDeleteComponent}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,352 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
|
||||||
import { useDrop } from "react-dnd";
|
|
||||||
import GridLayout, { Layout } from "react-grid-layout";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopSectionData,
|
|
||||||
PopComponentData,
|
|
||||||
PopComponentType,
|
|
||||||
GridPosition,
|
|
||||||
} from "./types/pop-layout";
|
|
||||||
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
|
||||||
import { Trash2, Move } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import "react-grid-layout/css/styles.css";
|
|
||||||
import "react-resizable/css/styles.css";
|
|
||||||
|
|
||||||
interface SectionGridProps {
|
|
||||||
section: PopSectionData;
|
|
||||||
isActive: boolean;
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
onSelectComponent: (id: string | null) => void;
|
|
||||||
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
|
||||||
onUpdateComponent: (sectionId: string, componentId: string, updates: Partial<PopComponentData>) => void;
|
|
||||||
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SectionGrid({
|
|
||||||
section,
|
|
||||||
isActive,
|
|
||||||
selectedComponentId,
|
|
||||||
onSelectComponent,
|
|
||||||
onDropComponent,
|
|
||||||
onUpdateComponent,
|
|
||||||
onDeleteComponent,
|
|
||||||
}: SectionGridProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { components } = section;
|
|
||||||
|
|
||||||
// 컨테이너 크기 측정
|
|
||||||
const [containerSize, setContainerSize] = useState({ width: 300, height: 200 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateSize = () => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
setContainerSize({
|
|
||||||
width: containerRef.current.offsetWidth,
|
|
||||||
height: containerRef.current.offsetHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateSize();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(updateSize);
|
|
||||||
if (containerRef.current) {
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => resizeObserver.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산
|
|
||||||
const padding = 8; // p-2 = 8px
|
|
||||||
const gap = 4; // 고정 간격
|
|
||||||
const availableWidth = containerSize.width - padding * 2;
|
|
||||||
const availableHeight = containerSize.height - padding * 2;
|
|
||||||
|
|
||||||
// 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산
|
|
||||||
const CELL_SIZE = 40;
|
|
||||||
const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap)));
|
|
||||||
const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)));
|
|
||||||
const cellHeight = CELL_SIZE;
|
|
||||||
|
|
||||||
// GridLayout용 레이아웃 변환 (자동 계산된 cols/rows 사용)
|
|
||||||
const gridLayoutItems: Layout[] = useMemo(() => {
|
|
||||||
return components.map((comp) => {
|
|
||||||
// 컴포넌트 위치가 그리드 범위를 벗어나지 않도록 조정
|
|
||||||
const x = Math.min(Math.max(0, comp.grid.col - 1), Math.max(0, cols - 1));
|
|
||||||
const y = Math.min(Math.max(0, comp.grid.row - 1), Math.max(0, rows - 1));
|
|
||||||
// colSpan/rowSpan도 범위 제한
|
|
||||||
const w = Math.min(Math.max(1, comp.grid.colSpan), Math.max(1, cols - x));
|
|
||||||
const h = Math.min(Math.max(1, comp.grid.rowSpan), Math.max(1, rows - y));
|
|
||||||
|
|
||||||
return {
|
|
||||||
i: comp.id,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
minW: 1,
|
|
||||||
minH: 1,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [components, cols, rows]);
|
|
||||||
|
|
||||||
// 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용)
|
|
||||||
const handleDragStop = useCallback(
|
|
||||||
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
|
||||||
const comp = components.find((c) => c.id === newItem.i);
|
|
||||||
if (!comp) return;
|
|
||||||
|
|
||||||
const newGrid: GridPosition = {
|
|
||||||
col: newItem.x + 1,
|
|
||||||
row: newItem.y + 1,
|
|
||||||
colSpan: newItem.w,
|
|
||||||
rowSpan: newItem.h,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
comp.grid.col !== newGrid.col ||
|
|
||||||
comp.grid.row !== newGrid.row
|
|
||||||
) {
|
|
||||||
onUpdateComponent(section.id, comp.id, { grid: newGrid });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[components, section.id, onUpdateComponent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleResizeStop = useCallback(
|
|
||||||
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
|
||||||
const comp = components.find((c) => c.id === newItem.i);
|
|
||||||
if (!comp) return;
|
|
||||||
|
|
||||||
const newGrid: GridPosition = {
|
|
||||||
col: newItem.x + 1,
|
|
||||||
row: newItem.y + 1,
|
|
||||||
colSpan: newItem.w,
|
|
||||||
rowSpan: newItem.h,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
comp.grid.colSpan !== newGrid.colSpan ||
|
|
||||||
comp.grid.rowSpan !== newGrid.rowSpan
|
|
||||||
) {
|
|
||||||
onUpdateComponent(section.id, comp.id, { grid: newGrid });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[components, section.id, onUpdateComponent]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 빈 셀 찾기 (드롭 위치용) - 자동 계산된 cols/rows 사용
|
|
||||||
const findEmptyCell = useCallback((): GridPosition => {
|
|
||||||
const occupied = new Set<string>();
|
|
||||||
|
|
||||||
components.forEach((comp) => {
|
|
||||||
for (let c = comp.grid.col; c < comp.grid.col + comp.grid.colSpan; c++) {
|
|
||||||
for (let r = comp.grid.row; r < comp.grid.row + comp.grid.rowSpan; r++) {
|
|
||||||
occupied.add(`${c}-${r}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 빈 셀 찾기
|
|
||||||
for (let r = 1; r <= rows; r++) {
|
|
||||||
for (let c = 1; c <= cols; c++) {
|
|
||||||
if (!occupied.has(`${c}-${r}`)) {
|
|
||||||
return { col: c, row: r, colSpan: 1, rowSpan: 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 빈 셀 없으면 첫 번째 위치에
|
|
||||||
return { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
|
||||||
}, [components, cols, rows]);
|
|
||||||
|
|
||||||
// 컴포넌트 드롭 핸들러
|
|
||||||
const [{ isOver, canDrop }, drop] = useDrop(() => ({
|
|
||||||
accept: DND_ITEM_TYPES.COMPONENT,
|
|
||||||
drop: (item: DragItemComponent) => {
|
|
||||||
if (!isActive) return;
|
|
||||||
const emptyCell = findEmptyCell();
|
|
||||||
onDropComponent(section.id, item.componentType, emptyCell);
|
|
||||||
return { dropped: true };
|
|
||||||
},
|
|
||||||
canDrop: () => isActive,
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOver: monitor.isOver(),
|
|
||||||
canDrop: monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
}), [isActive, section.id, findEmptyCell, onDropComponent]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(node) => {
|
|
||||||
containerRef.current = node;
|
|
||||||
drop(node);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"relative h-full w-full p-2 transition-colors",
|
|
||||||
isOver && canDrop && "bg-blue-50"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelectComponent(null);
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
{/* 빈 상태 안내 텍스트 */}
|
|
||||||
{components.length === 0 && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10">
|
|
||||||
<span className={cn(
|
|
||||||
"rounded bg-white/80 px-2 py-1 text-xs",
|
|
||||||
isOver && canDrop ? "text-primary font-medium" : "text-gray-400"
|
|
||||||
)}>
|
|
||||||
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 컴포넌트 GridLayout */}
|
|
||||||
{components.length > 0 && availableWidth > 0 && cols > 0 && (
|
|
||||||
<GridLayout
|
|
||||||
className="layout relative z-10"
|
|
||||||
layout={gridLayoutItems}
|
|
||||||
cols={cols}
|
|
||||||
rowHeight={cellHeight}
|
|
||||||
width={availableWidth}
|
|
||||||
margin={[gap, gap]}
|
|
||||||
containerPadding={[0, 0]}
|
|
||||||
onDragStop={handleDragStop}
|
|
||||||
onResizeStop={handleResizeStop}
|
|
||||||
isDraggable={isActive}
|
|
||||||
isResizable={isActive}
|
|
||||||
compactType={null}
|
|
||||||
preventCollision={false}
|
|
||||||
useCSSTransforms={true}
|
|
||||||
draggableHandle=".component-drag-handle"
|
|
||||||
resizeHandles={["se", "e", "s"]}
|
|
||||||
>
|
|
||||||
{components.map((comp) => (
|
|
||||||
<div
|
|
||||||
key={comp.id}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex flex-col rounded border bg-white text-xs transition-all overflow-hidden",
|
|
||||||
selectedComponentId === comp.id
|
|
||||||
? "border-primary ring-2 ring-primary/30"
|
|
||||||
: "border-gray-200 hover:border-gray-400"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelectComponent(comp.id);
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* 드래그 핸들 바 */}
|
|
||||||
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 hover:opacity-100 transition-opacity">
|
|
||||||
<Move className="h-3 w-3 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컴포넌트 내용 */}
|
|
||||||
<div className="flex flex-1 items-center justify-center p-1">
|
|
||||||
<ComponentPreview component={comp} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
{selectedComponentId === comp.id && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute -right-2 -top-2 h-5 w-5 rounded-full bg-white shadow text-destructive hover:bg-destructive/10 z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteComponent(section.id, comp.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</GridLayout>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 미리보기
|
|
||||||
interface ComponentPreviewProps {
|
|
||||||
component: PopComponentData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentPreview({ component }: ComponentPreviewProps) {
|
|
||||||
const { type, label } = component;
|
|
||||||
|
|
||||||
// 타입별 미리보기 렌더링
|
|
||||||
const renderPreview = () => {
|
|
||||||
switch (type) {
|
|
||||||
case "pop-field":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
|
|
||||||
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-button":
|
|
||||||
return (
|
|
||||||
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 text-primary font-medium">
|
|
||||||
{label || "버튼"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-list":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
|
|
||||||
<div className="h-3 w-full rounded bg-gray-100" />
|
|
||||||
<div className="h-3 w-3/4 rounded bg-gray-100" />
|
|
||||||
<div className="h-3 w-full rounded bg-gray-100" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-indicator":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col items-center gap-1">
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
|
|
||||||
<span className="text-lg font-bold text-primary">0</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-scanner":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col items-center gap-1">
|
|
||||||
<div className="h-8 w-8 rounded border-2 border-dashed border-gray-300 flex items-center justify-center">
|
|
||||||
<span className="text-[8px] text-gray-400">QR</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-numpad":
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-0.5 w-full">
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <span className="text-gray-500">{label || type}</span>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,373 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
|
||||||
import { useDrop } from "react-dnd";
|
|
||||||
import GridLayout, { Layout } from "react-grid-layout";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopSectionDefinition,
|
|
||||||
PopComponentDefinition,
|
|
||||||
PopComponentType,
|
|
||||||
GridPosition,
|
|
||||||
} from "./types/pop-layout";
|
|
||||||
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
|
||||||
import { Trash2, Move } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import "react-grid-layout/css/styles.css";
|
|
||||||
import "react-resizable/css/styles.css";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Props
|
|
||||||
// ========================================
|
|
||||||
interface SectionGridV2Props {
|
|
||||||
sectionId: string;
|
|
||||||
sectionDef: PopSectionDefinition;
|
|
||||||
components: Record<string, PopComponentDefinition>;
|
|
||||||
componentPositions: Record<string, GridPosition>;
|
|
||||||
isActive: boolean;
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
onSelectComponent: (id: string | null) => void;
|
|
||||||
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
|
||||||
onUpdateComponentPosition: (componentId: string, position: GridPosition) => void;
|
|
||||||
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 메인 컴포넌트
|
|
||||||
// ========================================
|
|
||||||
export function SectionGridV2({
|
|
||||||
sectionId,
|
|
||||||
sectionDef,
|
|
||||||
components,
|
|
||||||
componentPositions,
|
|
||||||
isActive,
|
|
||||||
selectedComponentId,
|
|
||||||
onSelectComponent,
|
|
||||||
onDropComponent,
|
|
||||||
onUpdateComponentPosition,
|
|
||||||
onDeleteComponent,
|
|
||||||
}: SectionGridV2Props) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 이 섹션에 포함된 컴포넌트 ID 목록
|
|
||||||
const componentIds = sectionDef.componentIds || [];
|
|
||||||
|
|
||||||
// 컨테이너 크기 측정
|
|
||||||
const [containerSize, setContainerSize] = useState({ width: 300, height: 200 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateSize = () => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
setContainerSize({
|
|
||||||
width: containerRef.current.offsetWidth,
|
|
||||||
height: containerRef.current.offsetHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateSize();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(updateSize);
|
|
||||||
if (containerRef.current) {
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => resizeObserver.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산
|
|
||||||
const padding = 8; // p-2 = 8px
|
|
||||||
const gap = 4; // 고정 간격
|
|
||||||
const availableWidth = containerSize.width - padding * 2;
|
|
||||||
const availableHeight = containerSize.height - padding * 2;
|
|
||||||
|
|
||||||
// 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산
|
|
||||||
const CELL_SIZE = 40;
|
|
||||||
const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap)));
|
|
||||||
const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)));
|
|
||||||
const cellHeight = CELL_SIZE;
|
|
||||||
|
|
||||||
// GridLayout용 레이아웃 변환
|
|
||||||
const gridLayoutItems: Layout[] = useMemo(() => {
|
|
||||||
return componentIds
|
|
||||||
.map((compId) => {
|
|
||||||
const pos = componentPositions[compId];
|
|
||||||
if (!pos) return null;
|
|
||||||
|
|
||||||
// 위치가 그리드 범위를 벗어나지 않도록 조정
|
|
||||||
const x = Math.min(Math.max(0, pos.col - 1), Math.max(0, cols - 1));
|
|
||||||
const y = Math.min(Math.max(0, pos.row - 1), Math.max(0, rows - 1));
|
|
||||||
const w = Math.min(Math.max(1, pos.colSpan), Math.max(1, cols - x));
|
|
||||||
const h = Math.min(Math.max(1, pos.rowSpan), Math.max(1, rows - y));
|
|
||||||
|
|
||||||
return {
|
|
||||||
i: compId,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
minW: 1,
|
|
||||||
minH: 1,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is Layout => item !== null);
|
|
||||||
}, [componentIds, componentPositions, cols, rows]);
|
|
||||||
|
|
||||||
// 드래그 완료 핸들러
|
|
||||||
const handleDragStop = useCallback(
|
|
||||||
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
|
||||||
const newPos: GridPosition = {
|
|
||||||
col: newItem.x + 1,
|
|
||||||
row: newItem.y + 1,
|
|
||||||
colSpan: newItem.w,
|
|
||||||
rowSpan: newItem.h,
|
|
||||||
};
|
|
||||||
|
|
||||||
const oldPos = componentPositions[newItem.i];
|
|
||||||
if (!oldPos || oldPos.col !== newPos.col || oldPos.row !== newPos.row) {
|
|
||||||
onUpdateComponentPosition(newItem.i, newPos);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[componentPositions, onUpdateComponentPosition]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 리사이즈 완료 핸들러
|
|
||||||
const handleResizeStop = useCallback(
|
|
||||||
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
|
||||||
const newPos: GridPosition = {
|
|
||||||
col: newItem.x + 1,
|
|
||||||
row: newItem.y + 1,
|
|
||||||
colSpan: newItem.w,
|
|
||||||
rowSpan: newItem.h,
|
|
||||||
};
|
|
||||||
|
|
||||||
const oldPos = componentPositions[newItem.i];
|
|
||||||
if (!oldPos || oldPos.colSpan !== newPos.colSpan || oldPos.rowSpan !== newPos.rowSpan) {
|
|
||||||
onUpdateComponentPosition(newItem.i, newPos);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[componentPositions, onUpdateComponentPosition]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 빈 셀 찾기 (드롭 위치용)
|
|
||||||
const findEmptyCell = useCallback((): GridPosition => {
|
|
||||||
const occupied = new Set<string>();
|
|
||||||
|
|
||||||
componentIds.forEach((compId) => {
|
|
||||||
const pos = componentPositions[compId];
|
|
||||||
if (!pos) return;
|
|
||||||
|
|
||||||
for (let c = pos.col; c < pos.col + pos.colSpan; c++) {
|
|
||||||
for (let r = pos.row; r < pos.row + pos.rowSpan; r++) {
|
|
||||||
occupied.add(`${c}-${r}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 빈 셀 찾기
|
|
||||||
for (let r = 1; r <= rows; r++) {
|
|
||||||
for (let c = 1; c <= cols; c++) {
|
|
||||||
if (!occupied.has(`${c}-${r}`)) {
|
|
||||||
return { col: c, row: r, colSpan: 1, rowSpan: 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 빈 셀 없으면 첫 번째 위치
|
|
||||||
return { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
|
||||||
}, [componentIds, componentPositions, cols, rows]);
|
|
||||||
|
|
||||||
// 컴포넌트 드롭 핸들러
|
|
||||||
const [{ isOver, canDrop }, drop] = useDrop(
|
|
||||||
() => ({
|
|
||||||
accept: DND_ITEM_TYPES.COMPONENT,
|
|
||||||
drop: (item: DragItemComponent) => {
|
|
||||||
if (!isActive) return;
|
|
||||||
const emptyCell = findEmptyCell();
|
|
||||||
onDropComponent(sectionId, item.componentType, emptyCell);
|
|
||||||
return { dropped: true };
|
|
||||||
},
|
|
||||||
canDrop: () => isActive,
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOver: monitor.isOver(),
|
|
||||||
canDrop: monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[isActive, sectionId, findEmptyCell, onDropComponent]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(node) => {
|
|
||||||
containerRef.current = node;
|
|
||||||
drop(node);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"relative h-full w-full p-2 transition-colors",
|
|
||||||
isOver && canDrop && "bg-blue-50"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelectComponent(null);
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 빈 상태 안내 텍스트 */}
|
|
||||||
{componentIds.length === 0 && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded bg-white/80 px-2 py-1 text-xs",
|
|
||||||
isOver && canDrop ? "font-medium text-primary" : "text-gray-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 컴포넌트 GridLayout */}
|
|
||||||
{componentIds.length > 0 && availableWidth > 0 && cols > 0 && (
|
|
||||||
<GridLayout
|
|
||||||
className="layout relative z-10"
|
|
||||||
layout={gridLayoutItems}
|
|
||||||
cols={cols}
|
|
||||||
rowHeight={cellHeight}
|
|
||||||
width={availableWidth}
|
|
||||||
margin={[gap, gap]}
|
|
||||||
containerPadding={[0, 0]}
|
|
||||||
onDragStop={handleDragStop}
|
|
||||||
onResizeStop={handleResizeStop}
|
|
||||||
isDraggable={isActive}
|
|
||||||
isResizable={isActive}
|
|
||||||
compactType={null}
|
|
||||||
preventCollision={false}
|
|
||||||
useCSSTransforms={true}
|
|
||||||
draggableHandle=".component-drag-handle"
|
|
||||||
resizeHandles={["se", "e", "s"]}
|
|
||||||
>
|
|
||||||
{componentIds.map((compId) => {
|
|
||||||
const compDef = components[compId];
|
|
||||||
if (!compDef) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={compId}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex flex-col overflow-hidden rounded border bg-white text-xs transition-all",
|
|
||||||
selectedComponentId === compId
|
|
||||||
? "border-primary ring-2 ring-primary/30"
|
|
||||||
: "border-gray-200 hover:border-gray-400"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelectComponent(compId);
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* 드래그 핸들 바 */}
|
|
||||||
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 transition-opacity hover:opacity-100">
|
|
||||||
<Move className="h-3 w-3 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컴포넌트 내용 */}
|
|
||||||
<div className="flex flex-1 items-center justify-center p-1">
|
|
||||||
<ComponentPreviewV2 component={compDef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
{selectedComponentId === compId && isActive && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white text-destructive shadow hover:bg-destructive/10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteComponent(sectionId, compId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</GridLayout>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 미리보기
|
|
||||||
// ========================================
|
|
||||||
interface ComponentPreviewV2Props {
|
|
||||||
component: PopComponentDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentPreviewV2({ component }: ComponentPreviewV2Props) {
|
|
||||||
const { type, label } = component;
|
|
||||||
|
|
||||||
const renderPreview = () => {
|
|
||||||
switch (type) {
|
|
||||||
case "pop-field":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
|
|
||||||
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-button":
|
|
||||||
return (
|
|
||||||
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 font-medium text-primary">
|
|
||||||
{label || "버튼"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-list":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
|
|
||||||
<div className="h-3 w-full rounded bg-gray-100" />
|
|
||||||
<div className="h-3 w-3/4 rounded bg-gray-100" />
|
|
||||||
<div className="h-3 w-full rounded bg-gray-100" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-indicator":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col items-center gap-1">
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
|
|
||||||
<span className="text-lg font-bold text-primary">0</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-scanner":
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col items-center gap-1">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded border-2 border-dashed border-gray-300">
|
|
||||||
<span className="text-[8px] text-gray-400">QR</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "pop-numpad":
|
|
||||||
return (
|
|
||||||
<div className="grid w-full grid-cols-3 gap-0.5">
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <span className="text-gray-500">{label || type}</span>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PopComponentDefinition, PopComponentConfig } from "../types/pop-layout";
|
||||||
|
import { Settings, Database, Link2 } from "lucide-react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentEditorPanelProps {
|
||||||
|
/** 선택된 컴포넌트 (없으면 null) */
|
||||||
|
component: PopComponentDefinition | null;
|
||||||
|
/** 컴포넌트 설정 변경 시 호출 */
|
||||||
|
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
|
||||||
|
/** 컴포넌트 라벨 변경 시 호출 */
|
||||||
|
onLabelChange?: (label: string) => void;
|
||||||
|
/** 추가 className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 편집 패널
|
||||||
|
//
|
||||||
|
// 역할:
|
||||||
|
// - 선택된 컴포넌트의 설정을 편집
|
||||||
|
// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결
|
||||||
|
//
|
||||||
|
// TODO:
|
||||||
|
// - 타입별 상세 설정 UI 구현
|
||||||
|
// - 데이터 바인딩 UI 구현
|
||||||
|
// - 데이터 플로우 UI 구현
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export function ComponentEditorPanel({
|
||||||
|
component,
|
||||||
|
onConfigChange,
|
||||||
|
onLabelChange,
|
||||||
|
className,
|
||||||
|
}: ComponentEditorPanelProps) {
|
||||||
|
// 컴포넌트가 선택되지 않은 경우
|
||||||
|
if (!component) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium">컴포넌트 편집</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
||||||
|
컴포넌트를 선택하세요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{component.label || getComponentTypeLabel(component.type)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{component.type}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<Tabs defaultValue="settings" className="flex-1">
|
||||||
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||||
|
<TabsTrigger value="settings" className="gap-1 text-xs">
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
설정
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="data" className="gap-1 text-xs">
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
데이터
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="flow" className="gap-1 text-xs">
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
연결
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 기본 설정 탭 */}
|
||||||
|
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||||
|
<ComponentSettingsForm
|
||||||
|
component={component}
|
||||||
|
onConfigChange={onConfigChange}
|
||||||
|
onLabelChange={onLabelChange}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 데이터 바인딩 탭 (뼈대) */}
|
||||||
|
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||||
|
<DataBindingPlaceholder />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 데이터 연결 탭 (뼈대) */}
|
||||||
|
<TabsContent value="flow" className="flex-1 overflow-auto p-4">
|
||||||
|
<DataFlowPlaceholder />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 설정 폼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentSettingsFormProps {
|
||||||
|
component: PopComponentDefinition;
|
||||||
|
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
|
||||||
|
onLabelChange?: (label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentSettingsForm({
|
||||||
|
component,
|
||||||
|
onConfigChange,
|
||||||
|
onLabelChange,
|
||||||
|
}: ComponentSettingsFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 라벨 입력 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">라벨</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-full rounded border border-input bg-background px-2 text-sm"
|
||||||
|
value={component.label || ""}
|
||||||
|
onChange={(e) => onLabelChange?.(e.target.value)}
|
||||||
|
placeholder="컴포넌트 라벨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타입별 설정 (TODO: 상세 구현) */}
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
{getComponentTypeLabel(component.type)} 상세 설정
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||||
|
(추후 구현 예정)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 데이터 바인딩 플레이스홀더 (뼈대)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function DataBindingPlaceholder() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Database className="h-8 w-8 text-gray-400" />
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
데이터 바인딩 설정
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
테이블 선택 → 칼럼 선택 → 조인 설정
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-center text-xs text-gray-400">
|
||||||
|
(추후 구현 예정)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 데이터 플로우 플레이스홀더 (뼈대)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function DataFlowPlaceholder() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Link2 className="h-8 w-8 text-gray-400" />
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
데이터 연결 설정
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
컴포넌트 간 / 섹션 간 / 화면 간 연결
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-center text-xs text-gray-400">
|
||||||
|
(추후 구현 예정)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function getComponentTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
"pop-field": "필드",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-list": "리스트",
|
||||||
|
"pop-indicator": "인디케이터",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-numpad": "넘패드",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComponentEditorPanel;
|
||||||
|
|
@ -6,13 +6,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
|
|
@ -21,7 +14,6 @@ import {
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
LayoutGrid,
|
|
||||||
Type,
|
Type,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
List,
|
List,
|
||||||
|
|
@ -34,9 +26,9 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV2,
|
PopLayoutDataV3,
|
||||||
PopLayoutModeKey,
|
PopLayoutModeKey,
|
||||||
PopSectionDefinition,
|
PopComponentDefinition,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
MODE_RESOLUTIONS,
|
MODE_RESOLUTIONS,
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
|
|
@ -45,14 +37,9 @@ import {
|
||||||
// 드래그 아이템 타입
|
// 드래그 아이템 타입
|
||||||
// ========================================
|
// ========================================
|
||||||
export const DND_ITEM_TYPES = {
|
export const DND_ITEM_TYPES = {
|
||||||
SECTION: "section",
|
|
||||||
COMPONENT: "component",
|
COMPONENT: "component",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface DragItemSection {
|
|
||||||
type: typeof DND_ITEM_TYPES.SECTION;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DragItemComponent {
|
export interface DragItemComponent {
|
||||||
type: typeof DND_ITEM_TYPES.COMPONENT;
|
type: typeof DND_ITEM_TYPES.COMPONENT;
|
||||||
componentType: PopComponentType;
|
componentType: PopComponentType;
|
||||||
|
|
@ -83,7 +70,7 @@ const COMPONENT_PALETTE: {
|
||||||
type: "pop-list",
|
type: "pop-list",
|
||||||
label: "리스트",
|
label: "리스트",
|
||||||
icon: List,
|
icon: List,
|
||||||
description: "데이터 목록 표시",
|
description: "데이터 목록 (카드 템플릿 지원)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "pop-indicator",
|
type: "pop-indicator",
|
||||||
|
|
@ -106,15 +93,15 @@ const COMPONENT_PALETTE: {
|
||||||
];
|
];
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props (v3: 섹션 없음)
|
||||||
// ========================================
|
// ========================================
|
||||||
interface PopPanelProps {
|
interface PopPanelProps {
|
||||||
layout: PopLayoutDataV2;
|
layout: PopLayoutDataV3;
|
||||||
activeModeKey: PopLayoutModeKey;
|
activeModeKey: PopLayoutModeKey;
|
||||||
selectedSectionId: string | null;
|
selectedComponentId: string | null;
|
||||||
selectedSection: PopSectionDefinition | null;
|
selectedComponent: PopComponentDefinition | null;
|
||||||
onUpdateSectionDefinition: (id: string, updates: Partial<PopSectionDefinition>) => void;
|
onUpdateComponentDefinition: (id: string, updates: Partial<PopComponentDefinition>) => void;
|
||||||
onDeleteSection: (id: string) => void;
|
onDeleteComponent: (id: string) => void;
|
||||||
activeDevice: "mobile" | "tablet";
|
activeDevice: "mobile" | "tablet";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,18 +111,18 @@ interface PopPanelProps {
|
||||||
export function PopPanel({
|
export function PopPanel({
|
||||||
layout,
|
layout,
|
||||||
activeModeKey,
|
activeModeKey,
|
||||||
selectedSectionId,
|
selectedComponentId,
|
||||||
selectedSection,
|
selectedComponent,
|
||||||
onUpdateSectionDefinition,
|
onUpdateComponentDefinition,
|
||||||
onDeleteSection,
|
onDeleteComponent,
|
||||||
activeDevice,
|
activeDevice,
|
||||||
}: PopPanelProps) {
|
}: PopPanelProps) {
|
||||||
const [activeTab, setActiveTab] = useState<string>("components");
|
const [activeTab, setActiveTab] = useState<string>("components");
|
||||||
|
|
||||||
// 현재 모드의 섹션 위치
|
// 현재 모드의 컴포넌트 위치
|
||||||
const currentModeLayout = layout.layouts[activeModeKey];
|
const currentModeLayout = layout.layouts[activeModeKey];
|
||||||
const selectedSectionPosition = selectedSectionId
|
const selectedComponentPosition = selectedComponentId
|
||||||
? currentModeLayout.sectionPositions[selectedSectionId]
|
? currentModeLayout.componentPositions[selectedComponentId]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -169,17 +156,6 @@ export function PopPanel({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 섹션 드래그 아이템 */}
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
|
||||||
레이아웃
|
|
||||||
</h4>
|
|
||||||
<DraggableSectionItem />
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
캔버스에 드래그하여 섹션 추가
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컴포넌트 팔레트 */}
|
{/* 컴포넌트 팔레트 */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
|
@ -197,7 +173,7 @@ export function PopPanel({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
섹션 안으로 드래그하여 배치
|
캔버스로 드래그하여 배치
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,19 +181,19 @@ export function PopPanel({
|
||||||
|
|
||||||
{/* 편집 탭 */}
|
{/* 편집 탭 */}
|
||||||
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
|
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
|
||||||
{selectedSection && selectedSectionPosition ? (
|
{selectedComponent && selectedComponentPosition ? (
|
||||||
<SectionEditorV2
|
<ComponentEditorV3
|
||||||
section={selectedSection}
|
component={selectedComponent}
|
||||||
position={selectedSectionPosition}
|
position={selectedComponentPosition}
|
||||||
activeModeKey={activeModeKey}
|
activeModeKey={activeModeKey}
|
||||||
onUpdateDefinition={(updates) =>
|
onUpdateDefinition={(updates) =>
|
||||||
onUpdateSectionDefinition(selectedSection.id, updates)
|
onUpdateComponentDefinition(selectedComponent.id, updates)
|
||||||
}
|
}
|
||||||
onDelete={() => onDeleteSection(selectedSection.id)}
|
onDelete={() => onDeleteComponent(selectedComponent.id)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||||
섹션을 선택하세요
|
컴포넌트를 선택하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -239,37 +215,6 @@ function getModeLabel(modeKey: PopLayoutModeKey): string {
|
||||||
return labels[modeKey];
|
return labels[modeKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 드래그 가능한 섹션 아이템
|
|
||||||
// ========================================
|
|
||||||
function DraggableSectionItem() {
|
|
||||||
const [{ isDragging }, drag] = useDrag(() => ({
|
|
||||||
type: DND_ITEM_TYPES.SECTION,
|
|
||||||
item: { type: DND_ITEM_TYPES.SECTION } as DragItemSection,
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={drag}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-grab items-center gap-3 rounded-lg border p-3 transition-all",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
|
||||||
isDragging && "opacity-50 ring-2 ring-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium">섹션</p>
|
|
||||||
<p className="text-xs text-muted-foreground">컴포넌트를 그룹화하는 컨테이너</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -314,30 +259,43 @@ function DraggableComponentItem({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// v2 섹션 편집기
|
// v3 컴포넌트 편집기
|
||||||
// ========================================
|
// ========================================
|
||||||
interface SectionEditorV2Props {
|
interface ComponentEditorV3Props {
|
||||||
section: PopSectionDefinition;
|
component: PopComponentDefinition;
|
||||||
position: { col: number; row: number; colSpan: number; rowSpan: number };
|
position: { col: number; row: number; colSpan: number; rowSpan: number };
|
||||||
activeModeKey: PopLayoutModeKey;
|
activeModeKey: PopLayoutModeKey;
|
||||||
onUpdateDefinition: (updates: Partial<PopSectionDefinition>) => void;
|
onUpdateDefinition: (updates: Partial<PopComponentDefinition>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionEditorV2({
|
function ComponentEditorV3({
|
||||||
section,
|
component,
|
||||||
position,
|
position,
|
||||||
activeModeKey,
|
activeModeKey,
|
||||||
onUpdateDefinition,
|
onUpdateDefinition,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: SectionEditorV2Props) {
|
}: ComponentEditorV3Props) {
|
||||||
const [isGridOpen, setIsGridOpen] = useState(true);
|
const [isPositionOpen, setIsPositionOpen] = useState(true);
|
||||||
|
|
||||||
|
// 컴포넌트 타입 라벨
|
||||||
|
const typeLabels: Record<PopComponentType, string> = {
|
||||||
|
"pop-field": "필드",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-list": "리스트",
|
||||||
|
"pop-indicator": "인디케이터",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-numpad": "숫자패드",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 섹션 기본 정보 */}
|
{/* 컴포넌트 기본 정보 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">섹션 설정</span>
|
<div>
|
||||||
|
<span className="text-sm font-medium">{typeLabels[component.type]}</span>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{component.id}</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -350,26 +308,23 @@ function SectionEditorV2({
|
||||||
|
|
||||||
{/* 라벨 */}
|
{/* 라벨 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">라벨 (공유)</Label>
|
<Label className="text-xs">라벨</Label>
|
||||||
<Input
|
<Input
|
||||||
value={section.label || ""}
|
value={component.label || ""}
|
||||||
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
|
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
|
||||||
placeholder="섹션 이름"
|
placeholder="컴포넌트 이름"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
라벨은 4개 모드에서 공유됩니다
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 현재 모드 위치 (읽기 전용 - 드래그로 조정) */}
|
{/* 현재 모드 위치 (읽기 전용) */}
|
||||||
<Collapsible open={isGridOpen} onOpenChange={setIsGridOpen}>
|
<Collapsible open={isPositionOpen} onOpenChange={setIsPositionOpen}>
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
|
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
|
||||||
현재 모드 위치
|
현재 모드 위치
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-transform",
|
"h-4 w-4 transition-transform",
|
||||||
isGridOpen && "rotate-180"
|
isPositionOpen && "rotate-180"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
@ -402,83 +357,12 @@ function SectionEditorV2({
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* 내부 그리드 설정 */}
|
{/* TODO: 컴포넌트별 설정 (config) */}
|
||||||
<div className="space-y-3">
|
<div className="rounded-lg border border-dashed p-3">
|
||||||
<h4 className="text-sm font-medium">내부 그리드 (공유)</h4>
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
컴포넌트별 상세 설정은 추후 추가 예정
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">내부 열 수</Label>
|
|
||||||
<Select
|
|
||||||
value={String(section.innerGrid.columns)}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
onUpdateDefinition({
|
|
||||||
innerGrid: { ...section.innerGrid, columns: parseInt(v) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">1열</SelectItem>
|
|
||||||
<SelectItem value="2">2열</SelectItem>
|
|
||||||
<SelectItem value="3">3열</SelectItem>
|
|
||||||
<SelectItem value="4">4열</SelectItem>
|
|
||||||
<SelectItem value="6">6열</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">내부 행 수</Label>
|
|
||||||
<Select
|
|
||||||
value={String(section.innerGrid.rows)}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
onUpdateDefinition({
|
|
||||||
innerGrid: { ...section.innerGrid, rows: parseInt(v) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">1행</SelectItem>
|
|
||||||
<SelectItem value="2">2행</SelectItem>
|
|
||||||
<SelectItem value="3">3행</SelectItem>
|
|
||||||
<SelectItem value="4">4행</SelectItem>
|
|
||||||
<SelectItem value="5">5행</SelectItem>
|
|
||||||
<SelectItem value="6">6행</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
내부 그리드 설정은 4개 모드에서 공유됩니다
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컴포넌트 목록 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium">
|
|
||||||
포함된 컴포넌트 ({section.componentIds.length}개)
|
|
||||||
</h4>
|
|
||||||
{section.componentIds.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{section.componentIds.map((compId) => (
|
|
||||||
<div
|
|
||||||
key={compId}
|
|
||||||
className="rounded border bg-muted/50 px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
{compId}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
아직 컴포넌트가 없습니다
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
// POP 디자이너 패널 export
|
// POP 디자이너 패널 export
|
||||||
export { PopPanel } from "./PopPanel";
|
export { PopPanel } from "./PopPanel";
|
||||||
|
export { ComponentEditorPanel } from "./ComponentEditorPanel";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopComponentDefinition,
|
||||||
|
PopComponentType,
|
||||||
|
GridPosition,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentRendererProps {
|
||||||
|
/** 컴포넌트 정의 (타입, 라벨, 설정 등) */
|
||||||
|
component: PopComponentDefinition;
|
||||||
|
/** 컴포넌트의 그리드 위치 (섹션 내부 그리드 기준) */
|
||||||
|
position: GridPosition;
|
||||||
|
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
/** 선택된 상태인지 */
|
||||||
|
isSelected?: boolean;
|
||||||
|
/** 컴포넌트 클릭 시 호출 */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** 추가 className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 렌더러
|
||||||
|
//
|
||||||
|
// 역할:
|
||||||
|
// - 관리자가 설정한 GridPosition(col, row, colSpan, rowSpan)을
|
||||||
|
// 그대로 CSS Grid에 반영
|
||||||
|
// - 디자이너/뷰어 모두에서 동일한 렌더링 보장
|
||||||
|
// - 디자인 모드에서는 선택 상태 표시
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export const ComponentRenderer = forwardRef<HTMLDivElement, ComponentRendererProps>(
|
||||||
|
function ComponentRenderer(
|
||||||
|
{
|
||||||
|
component,
|
||||||
|
position,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const { type, label, config } = component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
// 기본 스타일
|
||||||
|
"relative flex flex-col overflow-hidden rounded border bg-white transition-all",
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
isDesignMode && "cursor-pointer",
|
||||||
|
// 선택 상태 스타일
|
||||||
|
isSelected
|
||||||
|
? "border-primary ring-2 ring-primary/30 z-10"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
// 관리자가 설정한 GridPosition을 그대로 반영
|
||||||
|
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||||
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 컴포넌트 타입별 미리보기 렌더링 */}
|
||||||
|
<ComponentPreview type={type} label={label} config={config} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 타입별 미리보기
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentPreviewProps {
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
config?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentPreview({ type, label, config }: ComponentPreviewProps) {
|
||||||
|
switch (type) {
|
||||||
|
case "pop-field":
|
||||||
|
return <FieldPreview label={label} config={config} />;
|
||||||
|
case "pop-button":
|
||||||
|
return <ButtonPreview label={label} config={config} />;
|
||||||
|
case "pop-list":
|
||||||
|
return <ListPreview label={label} config={config} />;
|
||||||
|
case "pop-indicator":
|
||||||
|
return <IndicatorPreview label={label} config={config} />;
|
||||||
|
case "pop-scanner":
|
||||||
|
return <ScannerPreview label={label} config={config} />;
|
||||||
|
case "pop-numpad":
|
||||||
|
return <NumpadPreview label={label} config={config} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-2 text-xs text-gray-400">
|
||||||
|
{label || type}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 개별 컴포넌트 미리보기
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function FieldPreview({ label, config }: { label?: string; config?: any }) {
|
||||||
|
const fieldType = config?.fieldType || "text";
|
||||||
|
const placeholder = config?.placeholder || "입력하세요";
|
||||||
|
const required = config?.required || false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col gap-1 p-2">
|
||||||
|
{/* 라벨 */}
|
||||||
|
<span className="text-xs font-medium text-gray-600">
|
||||||
|
{label || "필드"}
|
||||||
|
{required && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
</span>
|
||||||
|
{/* 입력 필드 미리보기 */}
|
||||||
|
<div className="flex h-8 w-full items-center rounded border border-gray-200 bg-gray-50 px-2 text-xs text-gray-400">
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonPreview({ label, config }: { label?: string; config?: any }) {
|
||||||
|
const buttonType = config?.buttonType || "action";
|
||||||
|
const variant = buttonType === "submit" ? "bg-primary text-white" : "bg-gray-100 text-gray-700";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-center rounded font-medium",
|
||||||
|
variant
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label || "버튼"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListPreview({ label, config }: { label?: string; config?: any }) {
|
||||||
|
const itemCount = config?.itemsPerPage || 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col gap-1 p-2">
|
||||||
|
{/* 라벨 */}
|
||||||
|
<span className="text-xs font-medium text-gray-600">{label || "리스트"}</span>
|
||||||
|
{/* 리스트 아이템 미리보기 */}
|
||||||
|
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
|
||||||
|
{Array.from({ length: Math.min(3, itemCount) }).map((_, i) => (
|
||||||
|
<div key={i} className="h-4 rounded bg-gray-100" />
|
||||||
|
))}
|
||||||
|
{itemCount > 3 && (
|
||||||
|
<div className="text-center text-[10px] text-gray-400">
|
||||||
|
+{itemCount - 3} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndicatorPreview({ label, config }: { label?: string; config?: any }) {
|
||||||
|
const indicatorType = config?.indicatorType || "kpi";
|
||||||
|
const unit = config?.unit || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||||
|
{/* 라벨 */}
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
|
||||||
|
{/* 값 미리보기 */}
|
||||||
|
<span className="text-xl font-bold text-primary">
|
||||||
|
0{unit && <span className="text-sm font-normal text-gray-500">{unit}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScannerPreview({ label, config }: { label?: string; config?: any }) {
|
||||||
|
const scannerType = config?.scannerType || "camera";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||||
|
{/* QR 아이콘 */}
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded border-2 border-dashed border-gray-300">
|
||||||
|
<span className="text-xs text-gray-400">QR</span>
|
||||||
|
</div>
|
||||||
|
{/* 라벨 */}
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumpadPreview({ label, config }: { label?: string; config?: any }) {
|
||||||
|
const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col gap-1 p-2">
|
||||||
|
{/* 라벨 */}
|
||||||
|
{label && (
|
||||||
|
<span className="text-[10px] text-gray-500">{label}</span>
|
||||||
|
)}
|
||||||
|
{/* 넘패드 미리보기 */}
|
||||||
|
<div className="grid flex-1 grid-cols-3 gap-0.5">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-center rounded bg-gray-100 text-[8px]"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComponentRenderer;
|
||||||
|
|
@ -0,0 +1,401 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopLayoutDataV3,
|
||||||
|
PopLayoutModeKey,
|
||||||
|
PopModeLayoutV3,
|
||||||
|
GridPosition,
|
||||||
|
MODE_RESOLUTIONS,
|
||||||
|
PopComponentDefinition,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface PopLayoutRendererProps {
|
||||||
|
/** 레이아웃 데이터 (v3.0) */
|
||||||
|
layout: PopLayoutDataV3;
|
||||||
|
/** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */
|
||||||
|
modeKey: PopLayoutModeKey;
|
||||||
|
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
/** 선택된 컴포넌트 ID */
|
||||||
|
selectedComponentId?: string | null;
|
||||||
|
/** 컴포넌트 클릭 시 호출 */
|
||||||
|
onComponentClick?: (componentId: string) => void;
|
||||||
|
/** 배경 클릭 시 호출 (선택 해제용) */
|
||||||
|
onBackgroundClick?: () => void;
|
||||||
|
/** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */
|
||||||
|
customModeLayout?: PopModeLayoutV3;
|
||||||
|
/** 추가 className */
|
||||||
|
className?: string;
|
||||||
|
/** 추가 style */
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 타입별 라벨
|
||||||
|
// ========================================
|
||||||
|
const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
"pop-field": "필드",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-list": "리스트",
|
||||||
|
"pop-indicator": "인디케이터",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-numpad": "숫자패드",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// POP 레이아웃 렌더러 (v3)
|
||||||
|
//
|
||||||
|
// 핵심 역할:
|
||||||
|
// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장
|
||||||
|
// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음)
|
||||||
|
// - CSS Grid + 1fr 비율 기반
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export function PopLayoutRenderer({
|
||||||
|
layout,
|
||||||
|
modeKey,
|
||||||
|
isDesignMode = false,
|
||||||
|
selectedComponentId,
|
||||||
|
onComponentClick,
|
||||||
|
onBackgroundClick,
|
||||||
|
customModeLayout,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: PopLayoutRendererProps) {
|
||||||
|
const { components, layouts, settings } = layout;
|
||||||
|
const canvasGrid = settings.canvasGrid;
|
||||||
|
|
||||||
|
// 현재 모드의 레이아웃
|
||||||
|
const modeLayout = customModeLayout || layouts[modeKey];
|
||||||
|
|
||||||
|
// 컴포넌트가 없으면 빈 상태 표시
|
||||||
|
if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center bg-gray-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
onClick={onBackgroundClick}
|
||||||
|
>
|
||||||
|
<div className="text-center text-sm text-gray-400">
|
||||||
|
<p>레이아웃이 설정되지 않았습니다</p>
|
||||||
|
{isDesignMode && <p className="mt-1">컴포넌트를 추가해주세요</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 ID 목록
|
||||||
|
const componentIds = Object.keys(modeLayout.componentPositions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative w-full h-full bg-white", className)}
|
||||||
|
style={{
|
||||||
|
// CSS Grid: 디자이너와 동일
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
|
||||||
|
gap: `${canvasGrid.gap}px`,
|
||||||
|
padding: `${canvasGrid.gap}px`,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onBackgroundClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 컴포넌트들 직접 렌더링 */}
|
||||||
|
{componentIds.map((componentId) => {
|
||||||
|
const compDef = components[componentId];
|
||||||
|
const compPos = modeLayout.componentPositions[componentId];
|
||||||
|
|
||||||
|
if (!compDef || !compPos) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComponentRenderer
|
||||||
|
key={componentId}
|
||||||
|
componentId={componentId}
|
||||||
|
component={compDef}
|
||||||
|
position={compPos}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isSelected={selectedComponentId === componentId}
|
||||||
|
onComponentClick={() => onComponentClick?.(componentId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 렌더러
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentRendererProps {
|
||||||
|
componentId: string;
|
||||||
|
component: PopComponentDefinition;
|
||||||
|
position: GridPosition;
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onComponentClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentRenderer({
|
||||||
|
componentId,
|
||||||
|
component,
|
||||||
|
position,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onComponentClick,
|
||||||
|
}: ComponentRendererProps) {
|
||||||
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
|
||||||
|
isSelected
|
||||||
|
? "border-primary ring-2 ring-primary/30 z-10"
|
||||||
|
: "border-gray-200",
|
||||||
|
isDesignMode && "cursor-pointer hover:border-gray-300"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||||
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onComponentClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
|
||||||
|
{isDesignMode && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-5 shrink-0 items-center border-b px-2",
|
||||||
|
isSelected ? "bg-primary/10" : "bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-medium text-gray-600">
|
||||||
|
{component.label || typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컴포넌트 내용 */}
|
||||||
|
<div className="flex flex-1 items-center justify-center p-2">
|
||||||
|
{renderComponentContent(component, isDesignMode)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트별 렌더링
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function renderComponentContent(
|
||||||
|
component: PopComponentDefinition,
|
||||||
|
isDesignMode: boolean
|
||||||
|
): React.ReactNode {
|
||||||
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
|
// 디자인 모드에서는 플레이스홀더 표시
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-400 text-center">
|
||||||
|
{typeLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 뷰어 모드: 실제 컴포넌트 렌더링
|
||||||
|
switch (component.type) {
|
||||||
|
case "pop-field":
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={component.label || "입력하세요"}
|
||||||
|
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-button":
|
||||||
|
return (
|
||||||
|
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
|
||||||
|
{component.label || "버튼"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-list":
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-auto p-2">
|
||||||
|
<div className="text-xs text-gray-500 text-center">
|
||||||
|
리스트 (데이터 연결 필요)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-indicator":
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary">0</div>
|
||||||
|
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-scanner":
|
||||||
|
return (
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<div className="text-xs">스캐너</div>
|
||||||
|
<div className="text-[10px]">탭하여 스캔</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-numpad":
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-1 p-1 w-full">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{typeLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 함수들 (export)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 모드에 레이아웃이 설정되어 있는지 확인
|
||||||
|
*/
|
||||||
|
export function hasModeLayout(
|
||||||
|
layout: PopLayoutDataV3,
|
||||||
|
modeKey: PopLayoutModeKey
|
||||||
|
): boolean {
|
||||||
|
const modeLayout = layout.layouts[modeKey];
|
||||||
|
return modeLayout && Object.keys(modeLayout.componentPositions).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태블릿 가로 모드(기준 모드)가 설정되어 있는지 확인
|
||||||
|
*/
|
||||||
|
export function hasBaseLayout(layout: PopLayoutDataV3): boolean {
|
||||||
|
return hasModeLayout(layout, "tablet_landscape");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태블릿 가로 모드를 기준으로 다른 모드에 맞게 자동 변환
|
||||||
|
*/
|
||||||
|
export function autoConvertLayout(
|
||||||
|
layout: PopLayoutDataV3,
|
||||||
|
targetModeKey: PopLayoutModeKey
|
||||||
|
): PopModeLayoutV3 {
|
||||||
|
const sourceKey: PopLayoutModeKey = "tablet_landscape";
|
||||||
|
const sourceLayout = layout.layouts[sourceKey];
|
||||||
|
const sourceRes = MODE_RESOLUTIONS[sourceKey];
|
||||||
|
const targetRes = MODE_RESOLUTIONS[targetModeKey];
|
||||||
|
|
||||||
|
// 비율 계산
|
||||||
|
const widthRatio = targetRes.width / sourceRes.width;
|
||||||
|
const heightRatio = targetRes.height / sourceRes.height;
|
||||||
|
|
||||||
|
// 가로 → 세로 변환인지 확인
|
||||||
|
const isOrientationChange =
|
||||||
|
sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height;
|
||||||
|
|
||||||
|
// 컴포넌트 위치 변환
|
||||||
|
const convertedPositions: Record<string, GridPosition> = {};
|
||||||
|
let currentRow = 1;
|
||||||
|
|
||||||
|
// 컴포넌트를 row, col 순으로 정렬
|
||||||
|
const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort(
|
||||||
|
(a, b) => {
|
||||||
|
const posA = sourceLayout.componentPositions[a];
|
||||||
|
const posB = sourceLayout.componentPositions[b];
|
||||||
|
if (posA.row !== posB.row) return posA.row - posB.row;
|
||||||
|
return posA.col - posB.col;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const componentId of sortedComponentIds) {
|
||||||
|
const sourcePos = sourceLayout.componentPositions[componentId];
|
||||||
|
|
||||||
|
if (isOrientationChange) {
|
||||||
|
// 가로 → 세로: 세로 스택 방식
|
||||||
|
const canvasColumns = layout.settings.canvasGrid.columns;
|
||||||
|
convertedPositions[componentId] = {
|
||||||
|
col: 1,
|
||||||
|
row: currentRow,
|
||||||
|
colSpan: canvasColumns,
|
||||||
|
rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)),
|
||||||
|
};
|
||||||
|
currentRow += convertedPositions[componentId].rowSpan + 1;
|
||||||
|
} else {
|
||||||
|
// 같은 방향: 비율 변환
|
||||||
|
convertedPositions[componentId] = {
|
||||||
|
col: Math.max(1, Math.round(sourcePos.col * widthRatio)),
|
||||||
|
row: Math.max(1, Math.round(sourcePos.row * heightRatio)),
|
||||||
|
colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)),
|
||||||
|
rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
componentPositions: convertedPositions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 모드에 맞는 레이아웃 반환 (없으면 자동 변환)
|
||||||
|
*/
|
||||||
|
export function getEffectiveModeLayout(
|
||||||
|
layout: PopLayoutDataV3,
|
||||||
|
targetModeKey: PopLayoutModeKey
|
||||||
|
): {
|
||||||
|
modeLayout: PopModeLayoutV3;
|
||||||
|
isConverted: boolean;
|
||||||
|
sourceModeKey: PopLayoutModeKey;
|
||||||
|
} {
|
||||||
|
// 해당 모드에 레이아웃이 있으면 그대로 사용
|
||||||
|
if (hasModeLayout(layout, targetModeKey)) {
|
||||||
|
return {
|
||||||
|
modeLayout: layout.layouts[targetModeKey],
|
||||||
|
isConverted: false,
|
||||||
|
sourceModeKey: targetModeKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 없으면 태블릿 가로 모드를 기준으로 자동 변환
|
||||||
|
return {
|
||||||
|
modeLayout: autoConvertLayout(layout, targetModeKey),
|
||||||
|
isConverted: true,
|
||||||
|
sourceModeKey: "tablet_landscape",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PopLayoutRenderer;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
// POP 레이아웃 렌더러 모듈 (v3)
|
||||||
|
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
|
||||||
|
// 섹션 제거됨, 컴포넌트 직접 배치
|
||||||
|
|
||||||
|
export { PopLayoutRenderer, default } from "./PopLayoutRenderer";
|
||||||
|
export {
|
||||||
|
hasModeLayout,
|
||||||
|
hasBaseLayout,
|
||||||
|
autoConvertLayout,
|
||||||
|
getEffectiveModeLayout,
|
||||||
|
} from "./PopLayoutRenderer";
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -100,6 +100,7 @@ interface TreeNodeProps {
|
||||||
onMoveGroupDown: (group: PopScreenGroup) => void;
|
onMoveGroupDown: (group: PopScreenGroup) => void;
|
||||||
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
|
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
|
||||||
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
|
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
|
||||||
|
onDeleteScreen: (screen: ScreenDefinition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -116,6 +117,7 @@ function TreeNode({
|
||||||
onMoveGroupDown,
|
onMoveGroupDown,
|
||||||
onMoveScreenUp,
|
onMoveScreenUp,
|
||||||
onMoveScreenDown,
|
onMoveScreenDown,
|
||||||
|
onDeleteScreen,
|
||||||
expandedGroups,
|
expandedGroups,
|
||||||
onToggle,
|
onToggle,
|
||||||
selectedGroupId,
|
selectedGroupId,
|
||||||
|
|
@ -276,6 +278,7 @@ function TreeNode({
|
||||||
onMoveGroupDown={onMoveGroupDown}
|
onMoveGroupDown={onMoveGroupDown}
|
||||||
onMoveScreenUp={onMoveScreenUp}
|
onMoveScreenUp={onMoveScreenUp}
|
||||||
onMoveScreenDown={onMoveScreenDown}
|
onMoveScreenDown={onMoveScreenDown}
|
||||||
|
onDeleteScreen={onDeleteScreen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -343,12 +346,18 @@ function TreeNode({
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => onRemoveScreenFromGroup(screen, group.id)}
|
onClick={() => onRemoveScreenFromGroup(screen, group.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<MoveRight className="h-4 w-4 mr-2" />
|
||||||
그룹에서 제거
|
그룹에서 제거
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => onDeleteScreen(screen)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
화면 삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -389,10 +398,14 @@ export function PopCategoryTree({
|
||||||
icon: "",
|
icon: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 삭제 다이얼로그 상태
|
// 그룹 삭제 다이얼로그 상태
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [deletingGroup, setDeletingGroup] = useState<PopScreenGroup | null>(null);
|
const [deletingGroup, setDeletingGroup] = useState<PopScreenGroup | null>(null);
|
||||||
|
|
||||||
|
// 화면 삭제 다이얼로그 상태
|
||||||
|
const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false);
|
||||||
|
const [deletingScreen, setDeletingScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
||||||
// 이동 모달 상태
|
// 이동 모달 상태
|
||||||
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
|
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
|
||||||
const [movingScreen, setMovingScreen] = useState<ScreenDefinition | null>(null);
|
const [movingScreen, setMovingScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
@ -612,6 +625,37 @@ export function PopCategoryTree({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 화면 삭제 다이얼로그 열기
|
||||||
|
const handleDeleteScreen = (screen: ScreenDefinition) => {
|
||||||
|
setDeletingScreen(screen);
|
||||||
|
setIsScreenDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 삭제 확인
|
||||||
|
const confirmDeleteScreen = async () => {
|
||||||
|
if (!deletingScreen) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 화면 삭제 API 호출 (휴지통으로 이동)
|
||||||
|
await apiClient.delete(`/screen-management/screens/${deletingScreen.screenId}`);
|
||||||
|
toast.success(`"${deletingScreen.screenName}" 화면이 휴지통으로 이동되었습니다.`);
|
||||||
|
|
||||||
|
// 화면 목록 새로고침 (부모 컴포넌트에서 처리해야 함)
|
||||||
|
loadGroups();
|
||||||
|
|
||||||
|
// 삭제된 화면이 선택된 상태였다면 선택 해제
|
||||||
|
if (selectedScreen?.screenId === deletingScreen.screenId) {
|
||||||
|
onScreenSelect(null as any); // 선택 해제
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("화면 삭제 실패:", error);
|
||||||
|
toast.error(error.response?.data?.message || error.message || "화면 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsScreenDeleteDialogOpen(false);
|
||||||
|
setDeletingScreen(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 그룹 순서 위로 이동
|
// 그룹 순서 위로 이동
|
||||||
const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => {
|
const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -857,6 +901,7 @@ export function PopCategoryTree({
|
||||||
onMoveGroupDown={handleMoveGroupDown}
|
onMoveGroupDown={handleMoveGroupDown}
|
||||||
onMoveScreenUp={handleMoveScreenUp}
|
onMoveScreenUp={handleMoveScreenUp}
|
||||||
onMoveScreenDown={handleMoveScreenDown}
|
onMoveScreenDown={handleMoveScreenDown}
|
||||||
|
onDeleteScreen={handleDeleteScreen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -905,6 +950,14 @@ export function PopCategoryTree({
|
||||||
<MoveRight className="h-4 w-4 mr-2" />
|
<MoveRight className="h-4 w-4 mr-2" />
|
||||||
카테고리로 이동
|
카테고리로 이동
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteScreen(screen)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
화면 삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1092,6 +1145,31 @@ export function PopCategoryTree({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 화면 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isScreenDeleteDialogOpen} onOpenChange={setIsScreenDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>화면 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{deletingScreen?.screenName}" 화면을 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
삭제된 화면은 휴지통으로 이동되며, 나중에 복원할 수 있습니다.
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDeleteScreen}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 정의
|
||||||
|
// ========================================
|
||||||
|
export type DeviceType = "mobile" | "tablet";
|
||||||
|
export type OrientationType = "landscape" | "portrait";
|
||||||
|
|
||||||
|
export interface ResponsiveMode {
|
||||||
|
device: DeviceType;
|
||||||
|
orientation: OrientationType;
|
||||||
|
isLandscape: boolean;
|
||||||
|
modeKey: "tablet_landscape" | "tablet_portrait" | "mobile_landscape" | "mobile_portrait";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 브레이크포인트 (화면 너비 기준)
|
||||||
|
// ========================================
|
||||||
|
const BREAKPOINTS = {
|
||||||
|
// 모바일: 0 ~ 767px
|
||||||
|
// 태블릿: 768px 이상
|
||||||
|
TABLET_MIN: 768,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반응형 모드 자동 감지 훅
|
||||||
|
*
|
||||||
|
* - 화면 크기와 방향에 따라 4가지 모드 자동 전환
|
||||||
|
* - tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait
|
||||||
|
* - resize 이벤트와 orientation 변경 모두 감지
|
||||||
|
*
|
||||||
|
* @returns ResponsiveMode 객체
|
||||||
|
*/
|
||||||
|
export function useResponsiveMode(): ResponsiveMode {
|
||||||
|
const [mode, setMode] = useState<ResponsiveMode>({
|
||||||
|
device: "tablet",
|
||||||
|
orientation: "landscape",
|
||||||
|
isLandscape: true,
|
||||||
|
modeKey: "tablet_landscape",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const detectMode = (): ResponsiveMode => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
// 디바이스 타입 결정 (화면 너비 기준)
|
||||||
|
const device: DeviceType = width >= BREAKPOINTS.TABLET_MIN ? "tablet" : "mobile";
|
||||||
|
|
||||||
|
// 방향 결정 (가로/세로 비율)
|
||||||
|
const isLandscape = width > height;
|
||||||
|
const orientation: OrientationType = isLandscape ? "landscape" : "portrait";
|
||||||
|
|
||||||
|
// 모드 키 생성
|
||||||
|
const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"];
|
||||||
|
|
||||||
|
return { device, orientation, isLandscape, modeKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기값 설정
|
||||||
|
setMode(detectMode());
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setMode(detectMode());
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이벤트 리스너 등록
|
||||||
|
window.addEventListener("resize", handleChange);
|
||||||
|
window.addEventListener("orientationchange", handleChange);
|
||||||
|
|
||||||
|
// matchMedia로 orientation 변경 감지
|
||||||
|
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||||
|
if (landscapeQuery.addEventListener) {
|
||||||
|
landscapeQuery.addEventListener("change", handleChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleChange);
|
||||||
|
window.removeEventListener("orientationchange", handleChange);
|
||||||
|
if (landscapeQuery.removeEventListener) {
|
||||||
|
landscapeQuery.removeEventListener("change", handleChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디바이스 방향(orientation) 감지 커스텀 훅
|
||||||
|
*
|
||||||
|
* - 실제 디바이스에서 가로/세로 방향 변경을 감지
|
||||||
|
* - window.matchMedia와 orientationchange 이벤트 활용
|
||||||
|
* - SSR 호환성 고려 (typeof window !== 'undefined')
|
||||||
|
*
|
||||||
|
* @returns isLandscape - true: 가로 모드, false: 세로 모드
|
||||||
|
*/
|
||||||
|
export function useDeviceOrientation(): boolean {
|
||||||
|
const [isLandscape, setIsLandscape] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const detectOrientation = (): boolean => {
|
||||||
|
if (window.matchMedia) {
|
||||||
|
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||||
|
return landscapeQuery.matches;
|
||||||
|
}
|
||||||
|
return window.innerWidth > window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsLandscape(detectOrientation());
|
||||||
|
|
||||||
|
const handleOrientationChange = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLandscape(detectOrientation());
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||||
|
|
||||||
|
if (landscapeQuery.addEventListener) {
|
||||||
|
landscapeQuery.addEventListener("change", handleOrientationChange);
|
||||||
|
} else if (landscapeQuery.addListener) {
|
||||||
|
landscapeQuery.addListener(handleOrientationChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("orientationchange", handleOrientationChange);
|
||||||
|
window.addEventListener("resize", handleOrientationChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (landscapeQuery.removeEventListener) {
|
||||||
|
landscapeQuery.removeEventListener("change", handleOrientationChange);
|
||||||
|
} else if (landscapeQuery.removeListener) {
|
||||||
|
landscapeQuery.removeListener(handleOrientationChange);
|
||||||
|
}
|
||||||
|
window.removeEventListener("orientationchange", handleOrientationChange);
|
||||||
|
window.removeEventListener("resize", handleOrientationChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isLandscape;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수동 방향 전환을 지원하는 확장 훅
|
||||||
|
* 프리뷰 모드에서 테스트 목적으로 사용
|
||||||
|
*
|
||||||
|
* @param initialOverride - 초기 수동 설정값 (undefined면 자동 감지)
|
||||||
|
* @returns [isLandscape, setIsLandscape, isAutoDetect]
|
||||||
|
*/
|
||||||
|
export function useDeviceOrientationWithOverride(
|
||||||
|
initialOverride?: boolean
|
||||||
|
): [boolean, (value: boolean | undefined) => void, boolean] {
|
||||||
|
const autoDetectedIsLandscape = useDeviceOrientation();
|
||||||
|
const [manualOverride, setManualOverride] = useState<boolean | undefined>(initialOverride);
|
||||||
|
|
||||||
|
const isLandscape = manualOverride !== undefined ? manualOverride : autoDetectedIsLandscape;
|
||||||
|
const isAutoDetect = manualOverride === undefined;
|
||||||
|
|
||||||
|
const setOrientation = (value: boolean | undefined) => {
|
||||||
|
setManualOverride(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [isLandscape, setOrientation, isAutoDetect];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반응형 모드 + 수동 오버라이드 지원 훅
|
||||||
|
* 프리뷰 모드에서 디바이스/방향을 수동으로 변경할 때 사용
|
||||||
|
*/
|
||||||
|
export function useResponsiveModeWithOverride(
|
||||||
|
initialDeviceOverride?: DeviceType,
|
||||||
|
initialOrientationOverride?: boolean
|
||||||
|
): {
|
||||||
|
mode: ResponsiveMode;
|
||||||
|
setDevice: (device: DeviceType | undefined) => void;
|
||||||
|
setOrientation: (isLandscape: boolean | undefined) => void;
|
||||||
|
isAutoDetect: boolean;
|
||||||
|
} {
|
||||||
|
const autoMode = useResponsiveMode();
|
||||||
|
const [deviceOverride, setDeviceOverride] = useState<DeviceType | undefined>(initialDeviceOverride);
|
||||||
|
const [orientationOverride, setOrientationOverride] = useState<boolean | undefined>(initialOrientationOverride);
|
||||||
|
|
||||||
|
const mode = useMemo((): ResponsiveMode => {
|
||||||
|
const device = deviceOverride ?? autoMode.device;
|
||||||
|
const isLandscape = orientationOverride ?? autoMode.isLandscape;
|
||||||
|
const orientation: OrientationType = isLandscape ? "landscape" : "portrait";
|
||||||
|
const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"];
|
||||||
|
|
||||||
|
return { device, orientation, isLandscape, modeKey };
|
||||||
|
}, [autoMode, deviceOverride, orientationOverride]);
|
||||||
|
|
||||||
|
const isAutoDetect = deviceOverride === undefined && orientationOverride === undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
setDevice: setDeviceOverride,
|
||||||
|
setOrientation: setOrientationOverride,
|
||||||
|
isAutoDetect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
# POP 변경 이력
|
||||||
|
|
||||||
|
형식: [Keep a Changelog](https://keepachangelog.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [미출시]
|
||||||
|
|
||||||
|
- v4 디자이너 UI 연결
|
||||||
|
- Tier 2, 3 컴포넌트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-04]
|
||||||
|
|
||||||
|
### 오늘 목표
|
||||||
|
POP 화면을 만들 수 있는 환경 완성 (타입 + 렌더러 + 기본 컴포넌트)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **v4 타입 정의** (간결 버전)
|
||||||
|
- `PopLayoutDataV4` - 단일 소스 레이아웃
|
||||||
|
- `PopContainerV4` - 스택 컨테이너 (direction, wrap, gap, alignItems)
|
||||||
|
- `PopComponentDefinitionV4` - 크기 제약 기반 (size: fixed/fill/hug)
|
||||||
|
- `PopSizeConstraintV4` - 크기 규칙
|
||||||
|
- `PopResponsiveRuleV4` - 반응형 규칙 (breakpoint별 변경)
|
||||||
|
- `PopGlobalSettingsV4` - 전역 설정
|
||||||
|
- `createEmptyPopLayoutV4()` - 생성 함수
|
||||||
|
- `isV4Layout()` - 타입 가드
|
||||||
|
|
||||||
|
### 진행중
|
||||||
|
- v4 렌더러 (`PopFlexRenderer`)
|
||||||
|
- 기본 컴포넌트 (PopButton, PopInput, PopLabel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-04] (earlier)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 저장/조회 시스템 구축
|
||||||
|
- rangraph: AI 장기 기억 (시맨틱 검색, 요약)
|
||||||
|
- popdocs: 상세 기록 (파일 기반, 히스토리)
|
||||||
|
- 이중 저장 체계로 검색 + 기록 분리
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- popdocs 문서 구조 정리
|
||||||
|
- README.md: 저장/조회 규칙 추가
|
||||||
|
- 기존 문서 archive/로 이동
|
||||||
|
- 문서 관리 전략 확정
|
||||||
|
- 저장 시: 파일 형식 자동 파악 → 형식 맞춰 추가 → rangraph 요약
|
||||||
|
- 조회 시: rangraph 시맨틱 검색
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- .cursorrules 변경 계획 철회 (Git 커밋 영향)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-03]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- v4 제약조건 기반 레이아웃 계획
|
||||||
|
- 단일 소스 + 자동 적응
|
||||||
|
- 3가지 규칙 (크기, 배치, 반응형)
|
||||||
|
- ADR: `decisions/001-v4-constraint-based.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-02]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 캔버스 rowSpan 문제
|
||||||
|
- 원인: gridTemplateRows 고정 px
|
||||||
|
- 해결: `1fr` 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-01]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 4모드 자동 전환 문제
|
||||||
|
- 해결: useResponsiveMode 훅 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-01-31]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- v3 섹션 제거, 순수 그리드 구조
|
||||||
|
- 4개 모드 독립 그리드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-01-30]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- POP 디자이너 기본 구조
|
||||||
|
- PopDesigner, PopCanvas 컴포넌트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-01-29]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- screen_layouts_pop 테이블
|
||||||
|
- POP 레이아웃 API (CRUD)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최신이 위, 시간순 역순*
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
# POP 개발 계획
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 오늘 목표 (2026-02-04)
|
||||||
|
|
||||||
|
**POP 화면을 만들 수 있는 환경 완성**
|
||||||
|
|
||||||
|
### 필요한 것
|
||||||
|
|
||||||
|
1. **v4 타입 정의** - 완료
|
||||||
|
2. **v4 렌더러** - Flexbox로 화면에 표시
|
||||||
|
3. **기본 컴포넌트** - 실제 배치할 요소들
|
||||||
|
4. **디자이너 UI 연결** - v4 모드로 설계 가능
|
||||||
|
|
||||||
|
### 작업 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
[v4 타입] → [렌더러] → [컴포넌트] → [디자이너 연결]
|
||||||
|
완료 진행 대기 대기
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 진행
|
||||||
|
|
||||||
|
### v4 타입 정의 (완료)
|
||||||
|
|
||||||
|
- [x] `PopLayoutDataV4` - 단일 소스 레이아웃
|
||||||
|
- [x] `PopContainerV4` - 스택 컨테이너
|
||||||
|
- [x] `PopSizeConstraintV4` - 크기 규칙 (fixed/fill/hug)
|
||||||
|
- [x] `PopResponsiveRuleV4` - 반응형 규칙
|
||||||
|
- [x] `createEmptyPopLayoutV4()` - 생성 함수
|
||||||
|
- [x] `isV4Layout()` - 타입 가드
|
||||||
|
|
||||||
|
### v4 렌더러 (진행)
|
||||||
|
|
||||||
|
- [ ] `PopFlexRenderer` - Flexbox 기반 렌더링
|
||||||
|
- [ ] 컨테이너 재귀 렌더링
|
||||||
|
- [ ] 반응형 규칙 적용 (breakpoint)
|
||||||
|
- [ ] 컴포넌트 숨김 처리 (hideBelow)
|
||||||
|
|
||||||
|
### 기본 컴포넌트 (대기)
|
||||||
|
|
||||||
|
- [ ] `PopButton` - 터치 버튼
|
||||||
|
- [ ] `PopInput` - 텍스트 입력
|
||||||
|
- [ ] `PopLabel` - 텍스트 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3 vs v4 비교
|
||||||
|
|
||||||
|
| | v3 (기존) | v4 (새로운) |
|
||||||
|
|---|---|---|
|
||||||
|
| 설계 | 4모드 각각 | 1번만 |
|
||||||
|
| 데이터 | col, row 위치 | 규칙 (fill/fixed/hug) |
|
||||||
|
| 렌더링 | CSS Grid | Flexbox |
|
||||||
|
| 반응형 | 수동 | 자동 + 규칙 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 로드맵
|
||||||
|
|
||||||
|
### Tier 1: Primitive (기본)
|
||||||
|
|
||||||
|
| 컴포넌트 | 용도 | 상태 |
|
||||||
|
|----------|------|------|
|
||||||
|
| PopButton | 터치 버튼 | 대기 |
|
||||||
|
| PopInput | 텍스트 입력 | 대기 |
|
||||||
|
| PopLabel | 텍스트 표시 | 대기 |
|
||||||
|
| PopBadge | 상태 배지 | 계획 |
|
||||||
|
|
||||||
|
### Tier 2: Compound (조합)
|
||||||
|
|
||||||
|
| 컴포넌트 | 용도 | 상태 |
|
||||||
|
|----------|------|------|
|
||||||
|
| PopFormField | 라벨 + 입력 | 계획 |
|
||||||
|
| PopCard | 카드 컨테이너 | 계획 |
|
||||||
|
| PopListItem | 목록 항목 | 계획 |
|
||||||
|
|
||||||
|
### Tier 3: Complex (복합)
|
||||||
|
|
||||||
|
| 컴포넌트 | 용도 | 상태 |
|
||||||
|
|----------|------|------|
|
||||||
|
| PopScanner | 바코드/QR 스캔 | 계획 |
|
||||||
|
| PopNumpad | 숫자 키패드 | 계획 |
|
||||||
|
| PopDataList | 페이징 목록 | 계획 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-04*
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# POP 화면 시스템
|
||||||
|
|
||||||
|
> Point of Production - 현장 작업자용 모바일/태블릿 화면
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 주요 경로
|
||||||
|
|
||||||
|
| 용도 | 경로 |
|
||||||
|
|------|------|
|
||||||
|
| 뷰어 | `/pop/screens/{screenId}` |
|
||||||
|
| 관리 | `/admin/screenMng/popScreenMngList` |
|
||||||
|
| API | `/api/screen-management/layout-pop/:screenId` |
|
||||||
|
|
||||||
|
### 핵심 파일
|
||||||
|
|
||||||
|
| 작업 | 파일 |
|
||||||
|
|------|------|
|
||||||
|
| 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
|
||||||
|
| 렌더러 | `frontend/components/pop/designer/renderers/` |
|
||||||
|
| 디자이너 | `frontend/components/pop/designer/PopDesigner.tsx` |
|
||||||
|
|
||||||
|
### 현재 상태
|
||||||
|
|
||||||
|
- **버전**: v3.0 (4모드 그리드)
|
||||||
|
- **다음**: v4.0 (제약조건 기반) - 계획
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문서 구조
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| [SPEC.md](./SPEC.md) | 기술 스펙 |
|
||||||
|
| [PLAN.md](./PLAN.md) | 계획/로드맵 |
|
||||||
|
| [CHANGELOG.md](./CHANGELOG.md) | 변경 이력 |
|
||||||
|
| [decisions/](./decisions/) | 중요 결정 기록 (ADR) |
|
||||||
|
| [components-spec.md](./components-spec.md) | 컴포넌트 상세 |
|
||||||
|
| [archive/](./archive/) | 이전 문서 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 저장/조회 시스템
|
||||||
|
|
||||||
|
### 역할 분담
|
||||||
|
|
||||||
|
| 저장소 | 역할 | 특징 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **rangraph** | AI 장기 기억 | 시맨틱 검색, 요약 저장 |
|
||||||
|
| **popdocs** | 상세 기록 | 파일 기반, 히스토리 |
|
||||||
|
|
||||||
|
### 저장 요청
|
||||||
|
|
||||||
|
| 요청 예시 | AI 행동 |
|
||||||
|
|----------|--------|
|
||||||
|
| "@CHANGELOG.md 오늘 작업 정리해줘" | 파일 형식 맞춰 추가 + rangraph 요약 |
|
||||||
|
| "이 결정 저장해줘" | rangraph save_decision + decisions/ ADR |
|
||||||
|
| "해결됐어" | rangraph save_lesson + CHANGELOG Fixed |
|
||||||
|
| "작업 완료" | rangraph workflow_submit + CHANGELOG Added |
|
||||||
|
|
||||||
|
### 조회 요청
|
||||||
|
|
||||||
|
| 요청 예시 | AI 행동 |
|
||||||
|
|----------|--------|
|
||||||
|
| "어제 뭐했지?" | rangraph 검색 |
|
||||||
|
| "지금 뭐하는 중이었지?" | rangraph workflow_status |
|
||||||
|
| "이 버그 전에도 있었어?" | rangraph search_memory |
|
||||||
|
| "v4 관련 작업들" | rangraph search_memory "v4" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4 핵심 (요약)
|
||||||
|
|
||||||
|
```
|
||||||
|
v3: 4개 모드 각각 위치 설정 → 4배 작업량
|
||||||
|
v4: 3가지 규칙만 설정 → 자동 적응
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
1. 크기: fixed(고정) / fill(채움) / hug(맞춤)
|
||||||
|
2. 배치: direction, wrap, gap
|
||||||
|
3. 반응형: breakpoint별 변경
|
||||||
|
```
|
||||||
|
|
||||||
|
상세: [SPEC.md](./SPEC.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-04*
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
# POP 기술 스펙
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4 핵심 규칙 (3가지)
|
||||||
|
|
||||||
|
### 1. 크기 규칙 (Size)
|
||||||
|
|
||||||
|
| 모드 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| `fixed` | 고정 px | 버튼 48px |
|
||||||
|
| `fill` | 부모 채움 | 입력창 100% |
|
||||||
|
| `hug` | 내용 맞춤 | 라벨 |
|
||||||
|
|
||||||
|
### 2. 배치 규칙 (Layout)
|
||||||
|
|
||||||
|
| 항목 | 옵션 |
|
||||||
|
|------|------|
|
||||||
|
| direction | horizontal / vertical |
|
||||||
|
| wrap | true / false |
|
||||||
|
| gap | 8 / 16 / 24 px |
|
||||||
|
| alignItems | start / center / end / stretch |
|
||||||
|
|
||||||
|
### 3. 반응형 규칙 (Responsive)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
direction: "horizontal",
|
||||||
|
responsive: [{ breakpoint: 768, direction: "vertical" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 크기 프리셋
|
||||||
|
|
||||||
|
### 터치 요소
|
||||||
|
|
||||||
|
| 요소 | 일반 | 산업 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 버튼 높이 | 48px | 60px |
|
||||||
|
| 입력창 높이 | 48px | 56px |
|
||||||
|
| 터치 영역 | 48px | 60px |
|
||||||
|
|
||||||
|
### 폰트 (clamp)
|
||||||
|
|
||||||
|
| 용도 | 범위 | CSS |
|
||||||
|
|------|-----|-----|
|
||||||
|
| 본문 | 14-18px | `clamp(14px, 1.5vw, 18px)` |
|
||||||
|
| 제목 | 18-28px | `clamp(18px, 2.5vw, 28px)` |
|
||||||
|
|
||||||
|
### 간격
|
||||||
|
|
||||||
|
| 이름 | 값 | 용도 |
|
||||||
|
|------|---|-----|
|
||||||
|
| sm | 8px | 요소 내부 |
|
||||||
|
| md | 16px | 컴포넌트 간 |
|
||||||
|
| lg | 24px | 섹션 간 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 반응형 원칙
|
||||||
|
|
||||||
|
```
|
||||||
|
누르는 것 → 고정 (48px)
|
||||||
|
읽는 것 → 범위 (clamp)
|
||||||
|
담는 것 → 비율 (%)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 구조
|
||||||
|
|
||||||
|
### v3 (현재)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopLayoutDataV3 {
|
||||||
|
version: "pop-3.0";
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: { componentPositions: Record<string, GridPosition> };
|
||||||
|
// ... 4개 모드
|
||||||
|
};
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### v4 (계획)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopLayoutDataV4 {
|
||||||
|
version: "pop-4.0";
|
||||||
|
root: PopContainer;
|
||||||
|
components: Record<string, PopComponentDefinitionV4>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopContainer {
|
||||||
|
type: "stack";
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
children: (string | PopContainer)[];
|
||||||
|
responsive?: { breakpoint: number; direction?: string }[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### 캔버스 rowSpan 문제
|
||||||
|
|
||||||
|
- **증상**: 컴포넌트가 얇게 보임
|
||||||
|
- **원인**: gridTemplateRows 고정 px
|
||||||
|
- **해결**: `1fr` 사용
|
||||||
|
|
||||||
|
### 4모드 전환 안 됨
|
||||||
|
|
||||||
|
- **증상**: 크기 줄여도 레이아웃 유지
|
||||||
|
- **해결**: useResponsiveMode 훅 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*상세 archive 참조: `archive/V4_CORE_RULES.md`, `archive/SIZE_PRESETS.md`*
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
# POP 레이아웃 canvasGrid.rows 버그 수정
|
||||||
|
|
||||||
|
## 문제점
|
||||||
|
|
||||||
|
### 1. 데이터 불일치
|
||||||
|
- **DB에 저장된 데이터**: `canvasGrid.rowHeight: 20` (고정 픽셀)
|
||||||
|
- **코드에서 기대하는 데이터**: `canvasGrid.rows: 24` (비율 기반)
|
||||||
|
- **결과**: `rows`가 `undefined`로 인한 렌더링 오류
|
||||||
|
|
||||||
|
### 2. 타입 정의 불일치
|
||||||
|
- **PopCanvas.tsx 타입**: `{ columns: number; rowHeight: number; gap: number }`
|
||||||
|
- **실제 사용**: `canvasGrid.rows`로 계산
|
||||||
|
- **결과**: 타입 안정성 저하
|
||||||
|
|
||||||
|
### 3. 렌더링 오류
|
||||||
|
- **디자이너**: `rowHeight = resolution.height / undefined` → `NaN`
|
||||||
|
- **뷰어**: `gridTemplateRows: repeat(undefined, 1fr)` → CSS 무효
|
||||||
|
- **결과**: 섹션이 매우 작게 표시됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 내용
|
||||||
|
|
||||||
|
### 1. ensureV2Layout 강화
|
||||||
|
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => {
|
||||||
|
let result: PopLayoutDataV2;
|
||||||
|
|
||||||
|
if (isV2Layout(data)) {
|
||||||
|
result = data;
|
||||||
|
} else if (isV1Layout(data)) {
|
||||||
|
result = migrateV1ToV2(data);
|
||||||
|
} else {
|
||||||
|
console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성");
|
||||||
|
result = createEmptyPopLayoutV2();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ canvasGrid.rows 보장 (구버전 데이터 호환)
|
||||||
|
if (!result.settings.canvasGrid.rows) {
|
||||||
|
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
||||||
|
result.settings.canvasGrid = {
|
||||||
|
...result.settings.canvasGrid,
|
||||||
|
rows: DEFAULT_CANVAS_GRID.rows, // 24
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**효과**: DB에서 로드한 구버전 데이터도 자동으로 `rows: 24` 보장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. PopCanvas.tsx 타입 수정 및 fallback
|
||||||
|
**파일**: `frontend/components/pop/designer/PopCanvas.tsx`
|
||||||
|
|
||||||
|
**타입 정의 수정**:
|
||||||
|
```typescript
|
||||||
|
interface DeviceFrameProps {
|
||||||
|
canvasGrid: { columns: number; rows: number; gap: number }; // rowHeight → rows
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**fallback 추가**:
|
||||||
|
```typescript
|
||||||
|
// ✅ rows가 없으면 24 사용
|
||||||
|
const rows = canvasGrid.rows || 24;
|
||||||
|
const rowHeight = Math.floor(resolution.height / rows);
|
||||||
|
```
|
||||||
|
|
||||||
|
**효과**:
|
||||||
|
- 타입 일관성 확보
|
||||||
|
- `NaN` 방지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. PopLayoutRenderer.tsx fallback
|
||||||
|
**파일**: `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
|
||||||
|
// ✅ fallback 추가
|
||||||
|
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
|
||||||
|
gap: `${canvasGrid.gap}px`,
|
||||||
|
padding: `${canvasGrid.gap}px`,
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**효과**: 뷰어에서도 안전하게 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 백엔드 저장 로직 강화
|
||||||
|
**파일**: `backend-node/src/services/screenManagementService.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (isV2) {
|
||||||
|
dataToSave = {
|
||||||
|
...layoutData,
|
||||||
|
version: "pop-2.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ canvasGrid.rows 검증 및 보정
|
||||||
|
if (dataToSave.settings?.canvasGrid) {
|
||||||
|
if (!dataToSave.settings.canvasGrid.rows) {
|
||||||
|
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
||||||
|
dataToSave.settings.canvasGrid.rows = 24;
|
||||||
|
}
|
||||||
|
// ✅ 구버전 rowHeight 필드 제거
|
||||||
|
if (dataToSave.settings.canvasGrid.rowHeight) {
|
||||||
|
console.warn("구버전 rowHeight 필드 제거");
|
||||||
|
delete dataToSave.settings.canvasGrid.rowHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**효과**: 앞으로 저장되는 모든 데이터는 올바른 구조 보장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 원칙 준수 여부
|
||||||
|
|
||||||
|
### 1. 데스크톱과 완전 분리 ✅
|
||||||
|
- POP 전용 파일만 수정
|
||||||
|
- 데스크톱 코드 0% 영향
|
||||||
|
|
||||||
|
### 2. 4모드 반응형 디자인 ✅
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
### 3. 비율 기반 그리드 시스템 ✅
|
||||||
|
- **오히려 원칙을 바로잡는 수정**
|
||||||
|
- 고정 픽셀(`rowHeight`) → 비율(`rows`) 강제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 해결된 문제
|
||||||
|
|
||||||
|
| 문제 | 수정 전 | 수정 후 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 섹션 크기 | 매우 작게 표시 | 정상 크기 (24x24 그리드) |
|
||||||
|
| 디자이너 렌더링 | `NaN` 오류 | 정상 계산 |
|
||||||
|
| 뷰어 렌더링 | CSS 무효 | 비율 기반 렌더링 |
|
||||||
|
| 타입 안정성 | `rowHeight` vs `rows` 불일치 | `rows`로 통일 |
|
||||||
|
| 구버전 데이터 | 호환 불가 | 자동 보정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 방법
|
||||||
|
|
||||||
|
### 1. 기존 화면 확인 (screen_id: 3884)
|
||||||
|
```bash
|
||||||
|
# 디자이너 접속
|
||||||
|
http://localhost:9771/screen-management/pop-designer/3884
|
||||||
|
|
||||||
|
# 저장 후 뷰어 확인
|
||||||
|
http://localhost:9771/pop/screens/3884
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 결과**:
|
||||||
|
- 섹션이 화면 전체 크기로 정상 표시
|
||||||
|
- 가로/세로 모드 전환 시 비율 유지
|
||||||
|
|
||||||
|
### 2. 새로운 화면 생성
|
||||||
|
- POP 디자이너에서 새 화면 생성
|
||||||
|
- 섹션 추가 및 배치
|
||||||
|
- 저장 후 DB 확인
|
||||||
|
|
||||||
|
**DB 확인**:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
screen_id,
|
||||||
|
layout_data->'settings'->'canvasGrid' as canvas_grid
|
||||||
|
FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = 3884;
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 결과**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gap": 4,
|
||||||
|
"rows": 24,
|
||||||
|
"columns": 24
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 추가 조치 사항
|
||||||
|
|
||||||
|
### 1. 기존 DB 데이터 마이그레이션 (선택)
|
||||||
|
만약 프론트엔드 자동 보정이 아닌 DB 마이그레이션을 원한다면:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE screen_layouts_pop
|
||||||
|
SET layout_data = jsonb_set(
|
||||||
|
jsonb_set(
|
||||||
|
layout_data,
|
||||||
|
'{settings,canvasGrid,rows}',
|
||||||
|
'24'
|
||||||
|
),
|
||||||
|
'{settings,canvasGrid}',
|
||||||
|
(layout_data->'settings'->'canvasGrid') - 'rowHeight'
|
||||||
|
)
|
||||||
|
WHERE layout_data->'settings'->'canvasGrid'->>'rows' IS NULL
|
||||||
|
AND layout_data->>'version' = 'pop-2.0';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 모드별 컴포넌트 위치 반대 문제
|
||||||
|
**별도 이슈**: `activeModeKey` 상태 관리 점검 필요
|
||||||
|
- DeviceFrame 클릭 시 모드 전환
|
||||||
|
- 저장 시 올바른 `modeKey` 전달 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
✅ **원칙 준수**: 데스크톱 분리, 4모드 반응형 유지
|
||||||
|
✅ **비율 기반 강제**: 고정 픽셀 제거
|
||||||
|
✅ **하위 호환**: 구버전 데이터 자동 보정
|
||||||
|
✅ **안정성 향상**: 타입 일관성 확보
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
# POP 컴포넌트 로드맵
|
||||||
|
|
||||||
|
## 큰 그림: 3단계 접근
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 1단계: 기초 블록 2단계: 조합 블록 3단계: 완성 화면 │
|
||||||
|
│ ─────────────── ─────────────── ─────────────── │
|
||||||
|
│ │
|
||||||
|
│ [버튼] [입력창] [폼 그룹] [작업지시 화면] │
|
||||||
|
│ [아이콘] [라벨] → [카드] → [실적입력 화면] │
|
||||||
|
│ [뱃지] [로딩] [리스트] [모니터링 대시보드] │
|
||||||
|
│ [테이블] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1단계: 기초 블록 (Primitive)
|
||||||
|
|
||||||
|
가장 작은 단위. 다른 곳에서 재사용됩니다.
|
||||||
|
|
||||||
|
### 필수 기초 블록
|
||||||
|
|
||||||
|
| 컴포넌트 | 역할 | 우선순위 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `PopButton` | 모든 버튼 | 1 |
|
||||||
|
| `PopInput` | 텍스트 입력 | 1 |
|
||||||
|
| `PopLabel` | 라벨/제목 | 1 |
|
||||||
|
| `PopIcon` | 아이콘 표시 | 1 |
|
||||||
|
| `PopBadge` | 상태 뱃지 | 2 |
|
||||||
|
| `PopLoading` | 로딩 스피너 | 2 |
|
||||||
|
| `PopDivider` | 구분선 | 3 |
|
||||||
|
|
||||||
|
### PopButton 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopButtonProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant: "primary" | "secondary" | "danger" | "success";
|
||||||
|
size: "sm" | "md" | "lg" | "xl";
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
<PopButton variant="primary" size="lg">
|
||||||
|
작업 완료
|
||||||
|
</PopButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PopInput 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopInputProps {
|
||||||
|
type: "text" | "number" | "date" | "time";
|
||||||
|
value: string | number;
|
||||||
|
onChange: (value: string | number) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
size: "md" | "lg"; // POP은 lg 기본
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
<PopInput
|
||||||
|
type="number"
|
||||||
|
label="수량"
|
||||||
|
size="lg"
|
||||||
|
value={qty}
|
||||||
|
onChange={setQty}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2단계: 조합 블록 (Compound)
|
||||||
|
|
||||||
|
기초 블록을 조합한 중간 단위.
|
||||||
|
|
||||||
|
### 조합 블록 목록
|
||||||
|
|
||||||
|
| 컴포넌트 | 구성 | 용도 |
|
||||||
|
|---------|------|-----|
|
||||||
|
| `PopFormField` | Label + Input + Error | 폼 입력 그룹 |
|
||||||
|
| `PopCard` | Container + Header + Body | 정보 카드 |
|
||||||
|
| `PopListItem` | Container + Content + Action | 리스트 항목 |
|
||||||
|
| `PopNumberPad` | Grid + Buttons | 숫자 입력 |
|
||||||
|
| `PopStatusBox` | Icon + Label + Value | 상태 표시 |
|
||||||
|
|
||||||
|
### PopFormField 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기초 블록 조합
|
||||||
|
function PopFormField({ label, required, error, children }) {
|
||||||
|
return (
|
||||||
|
<div className="pop-form-field">
|
||||||
|
<PopLabel required={required}>{label}</PopLabel>
|
||||||
|
{children}
|
||||||
|
{error && <span className="error">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
<PopFormField label="품번" required error={errors.itemCode}>
|
||||||
|
<PopInput type="text" value={itemCode} onChange={setItemCode} />
|
||||||
|
</PopFormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PopCard 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function PopCard({ title, badge, children, onClick }) {
|
||||||
|
return (
|
||||||
|
<div className="pop-card" onClick={onClick}>
|
||||||
|
<div className="pop-card-header">
|
||||||
|
<PopLabel size="lg">{title}</PopLabel>
|
||||||
|
{badge && <PopBadge>{badge}</PopBadge>}
|
||||||
|
</div>
|
||||||
|
<div className="pop-card-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
<PopCard title="작업지시 #1234" badge="진행중">
|
||||||
|
<p>목표 수량: 100개</p>
|
||||||
|
<p>완료 수량: 45개</p>
|
||||||
|
</PopCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3단계: 복합 컴포넌트 (Complex)
|
||||||
|
|
||||||
|
비즈니스 로직이 포함된 완성형.
|
||||||
|
|
||||||
|
### 복합 컴포넌트 목록
|
||||||
|
|
||||||
|
| 컴포넌트 | 기능 | 데이터 |
|
||||||
|
|---------|------|-------|
|
||||||
|
| `PopDataTable` | 대량 데이터 표시/편집 | API 연동 |
|
||||||
|
| `PopCardList` | 카드 형태 리스트 | API 연동 |
|
||||||
|
| `PopBarcodeScanner` | 바코드/QR 스캔 | 카메라/외부장치 |
|
||||||
|
| `PopKpiGauge` | KPI 게이지 | 실시간 데이터 |
|
||||||
|
| `PopAlarmList` | 알람 목록 | 웹소켓 |
|
||||||
|
| `PopProcessFlow` | 공정 흐름도 | 공정 데이터 |
|
||||||
|
|
||||||
|
### PopDataTable 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopDataTableProps {
|
||||||
|
// 데이터
|
||||||
|
data: any[];
|
||||||
|
columns: Column[];
|
||||||
|
|
||||||
|
// 기능
|
||||||
|
selectable?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
sortable?: boolean;
|
||||||
|
|
||||||
|
// 반응형 (자동)
|
||||||
|
responsiveColumns?: {
|
||||||
|
tablet: string[];
|
||||||
|
mobile: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이벤트
|
||||||
|
onRowClick?: (row: any) => void;
|
||||||
|
onSelectionChange?: (selected: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
<PopDataTable
|
||||||
|
data={workOrders}
|
||||||
|
columns={[
|
||||||
|
{ key: "orderNo", label: "지시번호" },
|
||||||
|
{ key: "itemName", label: "품명" },
|
||||||
|
{ key: "qty", label: "수량", align: "right" },
|
||||||
|
{ key: "status", label: "상태" },
|
||||||
|
]}
|
||||||
|
responsiveColumns={{
|
||||||
|
tablet: ["orderNo", "itemName", "qty", "status"],
|
||||||
|
mobile: ["orderNo", "qty"], // 모바일은 2개만
|
||||||
|
}}
|
||||||
|
onRowClick={(row) => openDetail(row.id)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개발 순서 제안
|
||||||
|
|
||||||
|
### Phase 1: 기초 (1-2주)
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 1:
|
||||||
|
- PopButton (모든 버튼의 기반)
|
||||||
|
- PopInput (모든 입력의 기반)
|
||||||
|
- PopLabel
|
||||||
|
- PopIcon
|
||||||
|
|
||||||
|
Week 2:
|
||||||
|
- PopBadge
|
||||||
|
- PopLoading
|
||||||
|
- PopDivider
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: 조합 (2-3주)
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 3:
|
||||||
|
- PopFormField (폼의 기본 단위)
|
||||||
|
- PopCard (카드의 기본 단위)
|
||||||
|
|
||||||
|
Week 4:
|
||||||
|
- PopListItem
|
||||||
|
- PopStatusBox
|
||||||
|
- PopNumberPad
|
||||||
|
|
||||||
|
Week 5:
|
||||||
|
- PopModal
|
||||||
|
- PopToast
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: 복합 (3-4주)
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 6-7:
|
||||||
|
- PopDataTable (가장 복잡)
|
||||||
|
- PopCardList
|
||||||
|
|
||||||
|
Week 8-9:
|
||||||
|
- PopBarcodeScanner
|
||||||
|
- PopKpiGauge
|
||||||
|
- PopAlarmList
|
||||||
|
- PopProcessFlow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 설계 원칙
|
||||||
|
|
||||||
|
### 1. 크기는 외부에서 제어
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 좋음: 크기를 props로 받음
|
||||||
|
<PopButton size="lg">확인</PopButton>
|
||||||
|
|
||||||
|
// 나쁨: 내부에서 크기 고정
|
||||||
|
<button style={{ height: "48px" }}>확인</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 최소 크기는 내부에서 보장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 컴포넌트 내부
|
||||||
|
const styles = {
|
||||||
|
minHeight: 48, // 터치 최소 크기 보장
|
||||||
|
minWidth: 80,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 반응형은 자동
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 좋음: 화면 크기에 따라 자동 조절
|
||||||
|
<PopFormField label="이름">
|
||||||
|
<PopInput />
|
||||||
|
</PopFormField>
|
||||||
|
|
||||||
|
// 나쁨: 모드별로 다른 컴포넌트
|
||||||
|
{isMobile ? <MobileInput /> : <TabletInput />}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 데이터와 UI 분리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 좋음: 데이터 로직은 훅으로
|
||||||
|
const { data, loading, error } = useWorkOrders();
|
||||||
|
|
||||||
|
<PopDataTable data={data} loading={loading} />
|
||||||
|
|
||||||
|
// 나쁨: 컴포넌트 안에서 fetch
|
||||||
|
function PopDataTable() {
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/work-orders')...
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 폴더 구조 제안
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/components/pop/
|
||||||
|
├── primitives/ # 1단계: 기초 블록
|
||||||
|
│ ├── PopButton.tsx
|
||||||
|
│ ├── PopInput.tsx
|
||||||
|
│ ├── PopLabel.tsx
|
||||||
|
│ ├── PopIcon.tsx
|
||||||
|
│ ├── PopBadge.tsx
|
||||||
|
│ ├── PopLoading.tsx
|
||||||
|
│ └── index.ts
|
||||||
|
│
|
||||||
|
├── compounds/ # 2단계: 조합 블록
|
||||||
|
│ ├── PopFormField.tsx
|
||||||
|
│ ├── PopCard.tsx
|
||||||
|
│ ├── PopListItem.tsx
|
||||||
|
│ ├── PopNumberPad.tsx
|
||||||
|
│ ├── PopStatusBox.tsx
|
||||||
|
│ └── index.ts
|
||||||
|
│
|
||||||
|
├── complex/ # 3단계: 복합 컴포넌트
|
||||||
|
│ ├── PopDataTable/
|
||||||
|
│ │ ├── PopDataTable.tsx
|
||||||
|
│ │ ├── PopTableHeader.tsx
|
||||||
|
│ │ ├── PopTableRow.tsx
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── PopCardList/
|
||||||
|
│ ├── PopBarcodeScanner/
|
||||||
|
│ └── index.ts
|
||||||
|
│
|
||||||
|
├── hooks/ # 공용 훅
|
||||||
|
│ ├── usePopTheme.ts
|
||||||
|
│ ├── useResponsiveSize.ts
|
||||||
|
│ └── useTouchFeedback.ts
|
||||||
|
│
|
||||||
|
└── styles/ # 공용 스타일
|
||||||
|
├── pop-variables.css
|
||||||
|
└── pop-base.css
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 스타일 변수
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* pop-variables.css */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* 터치 크기 */
|
||||||
|
--pop-touch-min: 48px;
|
||||||
|
--pop-touch-industrial: 60px;
|
||||||
|
|
||||||
|
/* 폰트 크기 */
|
||||||
|
--pop-font-body: clamp(14px, 1.5vw, 18px);
|
||||||
|
--pop-font-heading: clamp(18px, 2.5vw, 28px);
|
||||||
|
--pop-font-caption: clamp(12px, 1vw, 14px);
|
||||||
|
|
||||||
|
/* 간격 */
|
||||||
|
--pop-gap-sm: 8px;
|
||||||
|
--pop-gap-md: 16px;
|
||||||
|
--pop-gap-lg: 24px;
|
||||||
|
|
||||||
|
/* 색상 */
|
||||||
|
--pop-primary: #2563eb;
|
||||||
|
--pop-success: #16a34a;
|
||||||
|
--pop-warning: #f59e0b;
|
||||||
|
--pop-danger: #dc2626;
|
||||||
|
|
||||||
|
/* 고대비 (야외용) */
|
||||||
|
--pop-high-contrast-bg: #000000;
|
||||||
|
--pop-high-contrast-fg: #ffffff;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
1. **기초 블록부터 시작**: PopButton, PopInput 먼저 만들기
|
||||||
|
2. **스토리북 설정**: 컴포넌트별 문서화
|
||||||
|
3. **테스트**: 터치 크기, 반응형 확인
|
||||||
|
4. **디자이너 연동**: v4 레이아웃 시스템과 통합
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-03*
|
||||||
|
|
@ -887,6 +887,117 @@ const filteredMoveGroups = useMemo(() => {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 14. 비율 기반 그리드 시스템 (2026-02-03)
|
||||||
|
|
||||||
|
### 문제 발견
|
||||||
|
|
||||||
|
POP 디자이너에서 섹션을 크게 설정해도 뷰어에서 매우 얇게(약 20px) 렌더링되는 문제 발생.
|
||||||
|
|
||||||
|
### 근본 원인 분석
|
||||||
|
|
||||||
|
1. **기존 구조**: `canvasGrid.rowHeight = 20` (고정 픽셀)
|
||||||
|
2. **react-grid-layout 동작**: 작은 리사이즈 → `rowSpan: 1`로 반올림 → DB 저장
|
||||||
|
3. **뷰어 렌더링**: `gridAutoRows: 20px` → 섹션 높이 = 20px (매우 얇음)
|
||||||
|
4. **비교**: 가로(columns)는 `1fr` 비율 기반으로 잘 작동
|
||||||
|
|
||||||
|
### 해결책: 비율 기반 행 시스템
|
||||||
|
|
||||||
|
| 구분 | 이전 | 이후 |
|
||||||
|
|------|------|------|
|
||||||
|
| 타입 | `rowHeight: number` (px) | `rows: number` (개수) |
|
||||||
|
| 기본값 | `rowHeight: 20` | `rows: 24` |
|
||||||
|
| 뷰어 CSS | `gridAutoRows: 20px` | `gridTemplateRows: repeat(24, 1fr)` |
|
||||||
|
| 디자이너 계산 | 고정 20px | `resolution.height / 24` |
|
||||||
|
|
||||||
|
### 수정된 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| `types/pop-layout.ts` | `PopCanvasGrid.rowHeight` → `rows`, `DEFAULT_CANVAS_GRID.rows = 24` |
|
||||||
|
| `renderers/PopLayoutRenderer.tsx` | `gridAutoRows` → `gridTemplateRows: repeat(rows, 1fr)` |
|
||||||
|
| `PopCanvas.tsx` | `rowHeight = Math.floor(resolution.height / canvasGrid.rows)` |
|
||||||
|
|
||||||
|
### 모드별 행 높이 계산
|
||||||
|
|
||||||
|
| 모드 | 해상도 높이 | 행 높이 (24행 기준) |
|
||||||
|
|------|-------------|---------------------|
|
||||||
|
| tablet_landscape | 768px | 32px |
|
||||||
|
| tablet_portrait | 1024px | 42.7px |
|
||||||
|
| mobile_landscape | 375px | 15.6px |
|
||||||
|
| mobile_portrait | 667px | 27.8px |
|
||||||
|
|
||||||
|
### 기존 데이터 호환성
|
||||||
|
|
||||||
|
- 기존 `rowHeight: 20` 데이터는 `rows || 24` fallback으로 처리
|
||||||
|
- 기존 `rowSpan: 1` 데이터는 1/24 = 4.17%로 렌더링 (여전히 작음)
|
||||||
|
- **권장**: 디자이너에서 섹션 재조정 후 재저장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 화면 삭제 기능 추가 (2026-02-03)
|
||||||
|
|
||||||
|
### 추가된 기능
|
||||||
|
|
||||||
|
POP 카테고리 트리에서 화면 자체를 삭제하는 기능 추가.
|
||||||
|
|
||||||
|
### UI 변경
|
||||||
|
|
||||||
|
| 위치 | 메뉴 항목 | 동작 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 그룹 내 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 |
|
||||||
|
| 미분류 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 |
|
||||||
|
|
||||||
|
### 삭제 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 드롭다운 메뉴에서 "화면 삭제" 클릭
|
||||||
|
2. 확인 다이얼로그 표시 ("삭제된 화면은 휴지통으로 이동됩니다")
|
||||||
|
3. 확인 → DELETE /api/screen-management/screens/:id
|
||||||
|
4. 화면 is_deleted = 'Y'로 변경 (soft delete)
|
||||||
|
5. 그룹 목록 새로고침
|
||||||
|
```
|
||||||
|
|
||||||
|
### 완전 삭제 vs 휴지통 이동
|
||||||
|
|
||||||
|
| API | 동작 | 복원 가능 |
|
||||||
|
|-----|------|----------|
|
||||||
|
| `DELETE /screens/:id` | 휴지통으로 이동 (is_deleted='Y') | O |
|
||||||
|
| `DELETE /screens/:id/permanent` | DB에서 완전 삭제 | X |
|
||||||
|
|
||||||
|
### 수정된 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| `PopCategoryTree.tsx` | `handleDeleteScreen`, `confirmDeleteScreen` 함수 추가 |
|
||||||
|
| `PopCategoryTree.tsx` | `isScreenDeleteDialogOpen`, `deletingScreen` 상태 추가 |
|
||||||
|
| `PopCategoryTree.tsx` | TreeNode에 `onDeleteScreen` prop 추가 |
|
||||||
|
| `PopCategoryTree.tsx` | 화면 삭제 확인 AlertDialog 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 멀티테넌시 이슈 해결 (2026-02-03)
|
||||||
|
|
||||||
|
### 문제
|
||||||
|
|
||||||
|
화면 그룹에서 제거 시 404 에러 발생.
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
|
||||||
|
- DB 데이터: `company_code = "*"` (최고 관리자 전용)
|
||||||
|
- 현재 세션: `company_code = "COMPANY_7"`
|
||||||
|
- 컨트롤러 WHERE 조건: `id = $1 AND company_code = $2` → 0 rows
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
|
||||||
|
세션 불일치 문제로 DB에서 직접 삭제 처리.
|
||||||
|
|
||||||
|
### 교훈
|
||||||
|
|
||||||
|
- 최고 관리자로 생성한 데이터는 일반 회사 사용자가 삭제 불가
|
||||||
|
- 로그인 후 토큰 갱신 필요 시 브라우저 완전 새로고침
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 트러블슈팅
|
## 트러블슈팅
|
||||||
|
|
||||||
### Export default doesn't exist in target module
|
### Export default doesn't exist in target module
|
||||||
|
|
@ -897,6 +1008,22 @@ const filteredMoveGroups = useMemo(() => {
|
||||||
|
|
||||||
**해결:** `import { apiClient } from "@/lib/api/client"` 사용
|
**해결:** `import { apiClient } from "@/lib/api/client"` 사용
|
||||||
|
|
||||||
|
### 섹션이 매우 얇게 렌더링되는 문제
|
||||||
|
|
||||||
|
**문제:** 디자이너에서 크게 설정한 섹션이 뷰어에서 20px 높이로 표시
|
||||||
|
|
||||||
|
**원인:** `canvasGrid.rowHeight = 20` 고정값 + react-grid-layout의 rowSpan 반올림
|
||||||
|
|
||||||
|
**해결:** 비율 기반 rows 시스템으로 변경 (섹션 14 참조)
|
||||||
|
|
||||||
|
### 화면 삭제 404 에러
|
||||||
|
|
||||||
|
**문제:** 화면 그룹에서 제거 시 404 에러
|
||||||
|
|
||||||
|
**원인:** company_code 불일치 (세션 vs DB)
|
||||||
|
|
||||||
|
**해결:** 브라우저 새로고침으로 토큰 갱신 또는 DB 직접 처리
|
||||||
|
|
||||||
### 관련 파일
|
### 관련 파일
|
||||||
|
|
||||||
| 파일 | 역할 |
|
| 파일 | 역할 |
|
||||||
|
|
@ -905,7 +1032,10 @@ const filteredMoveGroups = useMemo(() => {
|
||||||
| `frontend/lib/api/popScreenGroup.ts` | POP 그룹 API 클라이언트 |
|
| `frontend/lib/api/popScreenGroup.ts` | POP 그룹 API 클라이언트 |
|
||||||
| `backend-node/src/controllers/screenGroupController.ts` | 그룹 CRUD 컨트롤러 |
|
| `backend-node/src/controllers/screenGroupController.ts` | 그룹 CRUD 컨트롤러 |
|
||||||
| `backend-node/src/routes/screenGroupRoutes.ts` | 그룹 API 라우트 |
|
| `backend-node/src/routes/screenGroupRoutes.ts` | 그룹 API 라우트 |
|
||||||
|
| `frontend/components/pop/designer/types/pop-layout.ts` | POP 레이아웃 타입 정의 |
|
||||||
|
| `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` | CSS Grid 기반 렌더러 |
|
||||||
|
| `frontend/components/pop/designer/PopCanvas.tsx` | react-grid-layout 디자이너 캔버스 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*최종 업데이트: 2026-01-29*
|
*최종 업데이트: 2026-02-03*
|
||||||
|
|
@ -0,0 +1,760 @@
|
||||||
|
# POP v4.0 제약조건 기반 시스템 구현 계획
|
||||||
|
|
||||||
|
## 1. 현재 시스템 분석
|
||||||
|
|
||||||
|
### 1.1 현재 구조 (v3.0)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 4개 모드별 그리드 위치 기반
|
||||||
|
interface PopLayoutDataV3 {
|
||||||
|
version: "pop-3.0";
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: { componentPositions: Record<string, GridPosition> };
|
||||||
|
tablet_portrait: { componentPositions: Record<string, GridPosition> };
|
||||||
|
mobile_landscape: { componentPositions: Record<string, GridPosition> };
|
||||||
|
mobile_portrait: { componentPositions: Record<string, GridPosition> };
|
||||||
|
};
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GridPosition {
|
||||||
|
col: number; // 1-based
|
||||||
|
row: number; // 1-based
|
||||||
|
colSpan: number;
|
||||||
|
rowSpan: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 현재 문제점
|
||||||
|
|
||||||
|
1. **4배 작업량**: 4개 모드 각각 설계 필요
|
||||||
|
2. **수동 동기화**: 컴포넌트 추가/삭제 시 4모드 수동 동기화
|
||||||
|
3. **그리드 한계**: col/row 기반이라 자동 재배치 불가
|
||||||
|
4. **반응형 미흡**: 화면 크기 변화에 자동 적응 불가
|
||||||
|
5. **디바이스 차이 무시**: 태블릿/모바일 물리적 크기 차이 고려 안됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 새로운 시스템 설계 (v4.0)
|
||||||
|
|
||||||
|
### 2.1 핵심 철학
|
||||||
|
|
||||||
|
```
|
||||||
|
"하나의 레이아웃 설계 → 제약조건 설정 → 모든 화면 자동 적응"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **단일 소스**: 1개 레이아웃만 설계
|
||||||
|
- **제약조건 기반**: 컴포넌트가 "어떻게 반응할지" 규칙 정의
|
||||||
|
- **Flexbox 렌더링**: CSS Grid에서 Flexbox 기반으로 전환
|
||||||
|
- **자동 줄바꿈**: 공간 부족 시 자동 재배치
|
||||||
|
|
||||||
|
### 2.2 새로운 데이터 구조
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// v4.0 레이아웃
|
||||||
|
interface PopLayoutDataV4 {
|
||||||
|
version: "pop-4.0";
|
||||||
|
|
||||||
|
// 루트 컨테이너
|
||||||
|
root: PopContainer;
|
||||||
|
|
||||||
|
// 컴포넌트 정의 (ID → 정의)
|
||||||
|
components: Record<string, PopComponentDefinitionV4>;
|
||||||
|
|
||||||
|
// 데이터 흐름
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
|
||||||
|
// 전역 설정
|
||||||
|
settings: PopGlobalSettingsV4;
|
||||||
|
|
||||||
|
// 메타데이터
|
||||||
|
metadata?: PopLayoutMetadata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 컨테이너 (스택)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 컨테이너: 컴포넌트들을 담는 그룹
|
||||||
|
interface PopContainer {
|
||||||
|
id: string;
|
||||||
|
type: "stack";
|
||||||
|
|
||||||
|
// 스택 방향
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
|
||||||
|
// 줄바꿈 허용
|
||||||
|
wrap: boolean;
|
||||||
|
|
||||||
|
// 요소 간 간격
|
||||||
|
gap: number;
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
alignItems: "start" | "center" | "end" | "stretch";
|
||||||
|
justifyContent: "start" | "center" | "end" | "space-between" | "space-around";
|
||||||
|
|
||||||
|
// 패딩
|
||||||
|
padding?: {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 반응형 규칙 (선택)
|
||||||
|
responsive?: {
|
||||||
|
// 브레이크포인트 (이 너비 이하에서 적용)
|
||||||
|
breakpoint: number;
|
||||||
|
// 변경할 방향
|
||||||
|
direction?: "horizontal" | "vertical";
|
||||||
|
// 변경할 간격
|
||||||
|
gap?: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// 자식 요소 (컴포넌트 ID 또는 중첩 컨테이너)
|
||||||
|
children: (string | PopContainer)[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 컴포넌트 제약조건
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopComponentDefinitionV4 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
// ===== 크기 제약 (핵심) =====
|
||||||
|
size: {
|
||||||
|
// 너비 모드
|
||||||
|
width: "fixed" | "fill" | "hug";
|
||||||
|
// 높이 모드
|
||||||
|
height: "fixed" | "fill" | "hug";
|
||||||
|
|
||||||
|
// 고정 크기 (width/height가 fixed일 때)
|
||||||
|
fixedWidth?: number;
|
||||||
|
fixedHeight?: number;
|
||||||
|
|
||||||
|
// 최소/최대 크기
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
|
||||||
|
// 비율 (fill일 때, 같은 컨테이너 내 다른 요소와의 비율)
|
||||||
|
flexGrow?: number; // 기본 1
|
||||||
|
flexShrink?: number; // 기본 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 정렬 =====
|
||||||
|
alignSelf?: "start" | "center" | "end" | "stretch";
|
||||||
|
|
||||||
|
// ===== 여백 =====
|
||||||
|
margin?: {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 모바일 스케일 (선택) =====
|
||||||
|
// 모바일에서 컴포넌트를 더 크게 표시
|
||||||
|
mobileScale?: number; // 기본 1.0, 예: 1.2 = 20% 더 크게
|
||||||
|
|
||||||
|
// ===== 기존 속성 =====
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
style?: PopStylePreset;
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 크기 모드 설명
|
||||||
|
|
||||||
|
| 모드 | 설명 | CSS 변환 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `fixed` | 고정 크기 (px) | `width: {fixedWidth}px` |
|
||||||
|
| `fill` | 부모 공간 채우기 | `flex: {flexGrow} {flexShrink} 0` |
|
||||||
|
| `hug` | 내용에 맞춤 | `flex: 0 0 auto` |
|
||||||
|
|
||||||
|
### 2.6 전역 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopGlobalSettingsV4 {
|
||||||
|
// 기본 터치 타겟 크기
|
||||||
|
touchTargetMin: number; // 48px
|
||||||
|
|
||||||
|
// 모드 (일반/산업현장)
|
||||||
|
mode: "normal" | "industrial";
|
||||||
|
|
||||||
|
// 기본 간격
|
||||||
|
defaultGap: number; // 8px
|
||||||
|
|
||||||
|
// 기본 패딩
|
||||||
|
defaultPadding: number; // 16px
|
||||||
|
|
||||||
|
// 반응형 브레이크포인트 (전역)
|
||||||
|
breakpoints: {
|
||||||
|
tablet: number; // 768px
|
||||||
|
mobile: number; // 480px
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 디자이너 UI 변경
|
||||||
|
|
||||||
|
### 3.1 기존 디자이너 vs 새 디자이너
|
||||||
|
|
||||||
|
```
|
||||||
|
기존 (그리드 기반):
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ [태블릿 가로] [태블릿 세로] [모바일 가로] [모바일 세로] │
|
||||||
|
│ │
|
||||||
|
│ 24x24 그리드에 컴포넌트 드래그 배치 │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
새로운 (제약조건 기반):
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ [단일 캔버스] 미리보기: [태블릿▼] │
|
||||||
|
│ │
|
||||||
|
│ 스택(컨테이너)에 컴포넌트 배치 │
|
||||||
|
│ + 우측 패널에서 제약조건 설정 │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 새로운 디자이너 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ POP 화면 디자이너 v4 [저장] [미리보기] │
|
||||||
|
├────────────────┬────────────────────────┬───────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ 컴포넌트 │ 캔버스 │ 속성 패널 │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ 기본 │ ┌──────────────────┐ │ ▼ 선택됨: 입력창 │
|
||||||
|
│ [필드] │ │ ┌──────────────┐ │ │ │
|
||||||
|
│ [버튼] │ │ │입력창 │ │ │ ▼ 크기 │
|
||||||
|
│ [리스트] │ │ └──────────────┘ │ │ 너비: [채우기 ▼] │
|
||||||
|
│ [인디케이터] │ │ │ │ 최소: [100] px │
|
||||||
|
│ │ │ ┌─────┐ ┌─────┐ │ │ 최대: [없음] │
|
||||||
|
│ ▼ 입력 │ │ │버튼1│ │버튼2│ │ │ │
|
||||||
|
│ [스캐너] │ │ └─────┘ └─────┘ │ │ 높이: [고정 ▼] │
|
||||||
|
│ [숫자패드] │ │ │ │ 값: [48] px │
|
||||||
|
│ │ └──────────────────┘ │ │
|
||||||
|
│ ───────── │ │ ▼ 정렬 │
|
||||||
|
│ │ 미리보기: │ [늘이기 ▼] │
|
||||||
|
│ ▼ 레이아웃 │ ┌──────────────────┐ │ │
|
||||||
|
│ [스택 (가로)] │ │[태블릿 가로 ▼] │ │ ▼ 여백 │
|
||||||
|
│ [스택 (세로)] │ │[768px] │ │ 상[8] 우[0] 하[8] 좌[0]│
|
||||||
|
│ │ └──────────────────┘ │ │
|
||||||
|
│ │ │ ▼ 반응형 │
|
||||||
|
│ │ │ 모바일 스케일: [1.2] │
|
||||||
|
│ │ │ │
|
||||||
|
└────────────────┴────────────────────────┴───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 컨테이너(스택) 편집
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 스택 속성 ─────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 방향: [가로 ▼] │
|
||||||
|
│ 줄바꿈: [허용 ☑] │
|
||||||
|
│ 간격: [8] px │
|
||||||
|
│ │
|
||||||
|
│ 정렬 (가로): [가운데 ▼] │
|
||||||
|
│ 정렬 (세로): [늘이기 ▼] │
|
||||||
|
│ │
|
||||||
|
│ ▼ 반응형 규칙 │
|
||||||
|
│ ┌─────────────────────────────┐ │
|
||||||
|
│ │ 768px 이하: 세로 방향 │ │
|
||||||
|
│ │ [+ 규칙 추가] │ │
|
||||||
|
│ └─────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 렌더링 로직 변경
|
||||||
|
|
||||||
|
### 4.1 기존 렌더링 (CSS Grid)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// v3: CSS Grid 기반
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(24, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(24, 1fr)`,
|
||||||
|
gap: "4px",
|
||||||
|
}}>
|
||||||
|
{componentIds.map(id => (
|
||||||
|
<div style={{
|
||||||
|
gridColumn: `${pos.col} / span ${pos.colSpan}`,
|
||||||
|
gridRow: `${pos.row} / span ${pos.rowSpan}`,
|
||||||
|
}}>
|
||||||
|
<Component />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 새로운 렌더링 (Flexbox)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// v4: Flexbox 기반
|
||||||
|
function renderContainer(container: PopContainer, components: Record<string, PopComponentDefinitionV4>) {
|
||||||
|
const direction = useResponsiveValue(container, 'direction');
|
||||||
|
const gap = useResponsiveValue(container, 'gap');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: direction === "horizontal" ? "row" : "column",
|
||||||
|
flexWrap: container.wrap ? "wrap" : "nowrap",
|
||||||
|
gap: `${gap}px`,
|
||||||
|
alignItems: container.alignItems,
|
||||||
|
justifyContent: container.justifyContent,
|
||||||
|
padding: container.padding ?
|
||||||
|
`${container.padding.top}px ${container.padding.right}px ${container.padding.bottom}px ${container.padding.left}px`
|
||||||
|
: undefined,
|
||||||
|
}}>
|
||||||
|
{container.children.map(child => {
|
||||||
|
if (typeof child === "string") {
|
||||||
|
// 컴포넌트 렌더링
|
||||||
|
return renderComponent(components[child]);
|
||||||
|
} else {
|
||||||
|
// 중첩 컨테이너 렌더링
|
||||||
|
return renderContainer(child, components);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponent(component: PopComponentDefinitionV4) {
|
||||||
|
const { size, margin, mobileScale } = component;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const scale = isMobile && mobileScale ? mobileScale : 1;
|
||||||
|
|
||||||
|
// 크기 계산
|
||||||
|
let width: string;
|
||||||
|
let flex: string;
|
||||||
|
|
||||||
|
if (size.width === "fixed") {
|
||||||
|
width = `${(size.fixedWidth || 100) * scale}px`;
|
||||||
|
flex = "0 0 auto";
|
||||||
|
} else if (size.width === "fill") {
|
||||||
|
width = "auto";
|
||||||
|
flex = `${size.flexGrow || 1} ${size.flexShrink || 1} 0`;
|
||||||
|
} else { // hug
|
||||||
|
width = "auto";
|
||||||
|
flex = "0 0 auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex,
|
||||||
|
width,
|
||||||
|
minWidth: size.minWidth ? `${size.minWidth * scale}px` : undefined,
|
||||||
|
maxWidth: size.maxWidth ? `${size.maxWidth * scale}px` : undefined,
|
||||||
|
height: size.height === "fixed" ? `${(size.fixedHeight || 48) * scale}px` : "auto",
|
||||||
|
minHeight: size.minHeight ? `${size.minHeight * scale}px` : undefined,
|
||||||
|
maxHeight: size.maxHeight ? `${size.maxHeight * scale}px` : undefined,
|
||||||
|
margin: margin ?
|
||||||
|
`${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`
|
||||||
|
: undefined,
|
||||||
|
alignSelf: component.alignSelf,
|
||||||
|
}}>
|
||||||
|
<ActualComponent {...component} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 반응형 훅
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function useResponsiveValue<T>(
|
||||||
|
container: PopContainer,
|
||||||
|
property: keyof PopContainer
|
||||||
|
): T {
|
||||||
|
const windowWidth = useWindowWidth();
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
let value = container[property] as T;
|
||||||
|
|
||||||
|
// 반응형 규칙 적용 (작은 브레이크포인트 우선)
|
||||||
|
if (container.responsive) {
|
||||||
|
const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint);
|
||||||
|
for (const rule of sortedRules) {
|
||||||
|
if (windowWidth <= rule.breakpoint && rule[property] !== undefined) {
|
||||||
|
value = rule[property] as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 구현 단계
|
||||||
|
|
||||||
|
### Phase 1: 데이터 구조 (1-2일)
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||||
|
|
||||||
|
1. `PopLayoutDataV4` 인터페이스 정의
|
||||||
|
2. `PopContainer` 인터페이스 정의
|
||||||
|
3. `PopComponentDefinitionV4` 인터페이스 정의
|
||||||
|
4. `createEmptyPopLayoutV4()` 함수
|
||||||
|
5. `migrateV3ToV4()` 마이그레이션 함수
|
||||||
|
6. `ensureV4Layout()` 함수
|
||||||
|
7. 타입 가드 함수들
|
||||||
|
|
||||||
|
### Phase 2: 렌더러 (2-3일)
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/renderers/PopLayoutRendererV4.tsx`
|
||||||
|
|
||||||
|
1. `renderContainer()` 함수
|
||||||
|
2. `renderComponent()` 함수
|
||||||
|
3. `useResponsiveValue()` 훅
|
||||||
|
4. `useWindowWidth()` 훅
|
||||||
|
5. CSS 스타일 계산 로직
|
||||||
|
6. 반응형 브레이크포인트 처리
|
||||||
|
|
||||||
|
### Phase 3: 디자이너 UI (3-4일)
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/PopDesignerV4.tsx`
|
||||||
|
|
||||||
|
1. 캔버스 영역 (드래그 앤 드롭)
|
||||||
|
2. 컴포넌트 팔레트 (기존 + 스택)
|
||||||
|
3. 속성 패널
|
||||||
|
- 크기 제약 편집
|
||||||
|
- 정렬 편집
|
||||||
|
- 여백 편집
|
||||||
|
- 반응형 규칙 편집
|
||||||
|
4. 미리보기 모드 (다양한 화면 크기)
|
||||||
|
5. 컨테이너(스택) 관리
|
||||||
|
- 컨테이너 추가/삭제
|
||||||
|
- 컨테이너 설정 편집
|
||||||
|
- 컴포넌트 이동 (컨테이너 간)
|
||||||
|
|
||||||
|
### Phase 4: 뷰어 통합 (1-2일)
|
||||||
|
|
||||||
|
**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
|
||||||
|
|
||||||
|
1. v4 레이아웃 감지 및 렌더링
|
||||||
|
2. 기존 v3 호환 유지
|
||||||
|
3. 반응형 모드 감지 연동
|
||||||
|
4. 성능 최적화
|
||||||
|
|
||||||
|
### Phase 5: 백엔드 수정 (1일)
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/services/screenManagementService.ts`
|
||||||
|
|
||||||
|
1. `saveLayoutPop` - v4 버전 감지 및 저장
|
||||||
|
2. `getLayoutPop` - v4 버전 반환
|
||||||
|
3. 버전 마이그레이션 로직
|
||||||
|
|
||||||
|
### Phase 6: 테스트 및 마이그레이션 (2-3일)
|
||||||
|
|
||||||
|
1. 단위 테스트
|
||||||
|
2. 통합 테스트
|
||||||
|
3. 기존 v3 레이아웃 마이그레이션 도구
|
||||||
|
4. 크로스 디바이스 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 마이그레이션 전략
|
||||||
|
|
||||||
|
### 6.1 v3 → v4 자동 변환
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function migrateV3ToV4(v3: PopLayoutDataV3): PopLayoutDataV4 {
|
||||||
|
// 태블릿 가로 모드 기준으로 변환
|
||||||
|
const baseLayout = v3.layouts.tablet_landscape;
|
||||||
|
const componentIds = Object.keys(baseLayout.componentPositions);
|
||||||
|
|
||||||
|
// 컴포넌트를 row, col 순으로 정렬
|
||||||
|
const sortedIds = componentIds.sort((a, b) => {
|
||||||
|
const posA = baseLayout.componentPositions[a];
|
||||||
|
const posB = baseLayout.componentPositions[b];
|
||||||
|
if (posA.row !== posB.row) return posA.row - posB.row;
|
||||||
|
return posA.col - posB.col;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 같은 row에 있는 컴포넌트들을 가로 스택으로 그룹화
|
||||||
|
const rowGroups = groupByRow(sortedIds, baseLayout.componentPositions);
|
||||||
|
|
||||||
|
// 루트 컨테이너 (세로 스택)
|
||||||
|
const rootContainer: PopContainer = {
|
||||||
|
id: "root",
|
||||||
|
type: "stack",
|
||||||
|
direction: "vertical",
|
||||||
|
wrap: false,
|
||||||
|
gap: v3.settings.canvasGrid.gap,
|
||||||
|
alignItems: "stretch",
|
||||||
|
justifyContent: "start",
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 각 행을 가로 스택으로 변환
|
||||||
|
for (const [row, ids] of rowGroups) {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
// 단일 컴포넌트면 직접 추가
|
||||||
|
rootContainer.children.push(ids[0]);
|
||||||
|
} else {
|
||||||
|
// 여러 컴포넌트면 가로 스택으로 감싸기
|
||||||
|
const rowStack: PopContainer = {
|
||||||
|
id: `row-${row}`,
|
||||||
|
type: "stack",
|
||||||
|
direction: "horizontal",
|
||||||
|
wrap: true,
|
||||||
|
gap: v3.settings.canvasGrid.gap,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "start",
|
||||||
|
children: ids,
|
||||||
|
};
|
||||||
|
rootContainer.children.push(rowStack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 정의 변환
|
||||||
|
const components: Record<string, PopComponentDefinitionV4> = {};
|
||||||
|
for (const id of componentIds) {
|
||||||
|
const v3Comp = v3.components[id];
|
||||||
|
const pos = baseLayout.componentPositions[id];
|
||||||
|
|
||||||
|
components[id] = {
|
||||||
|
...v3Comp,
|
||||||
|
size: {
|
||||||
|
// colSpan을 기반으로 크기 모드 결정
|
||||||
|
width: pos.colSpan >= 20 ? "fill" : "fixed",
|
||||||
|
height: "fixed",
|
||||||
|
fixedWidth: pos.colSpan * (1024 / 24), // 대략적인 픽셀 변환
|
||||||
|
fixedHeight: pos.rowSpan * (768 / 24),
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "pop-4.0",
|
||||||
|
root: rootContainer,
|
||||||
|
components,
|
||||||
|
dataFlow: v3.dataFlow,
|
||||||
|
settings: {
|
||||||
|
touchTargetMin: v3.settings.touchTargetMin,
|
||||||
|
mode: v3.settings.mode,
|
||||||
|
defaultGap: v3.settings.canvasGrid.gap,
|
||||||
|
defaultPadding: 16,
|
||||||
|
breakpoints: {
|
||||||
|
tablet: 768,
|
||||||
|
mobile: 480,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: v3.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 하위 호환
|
||||||
|
|
||||||
|
- v3 레이아웃은 계속 지원
|
||||||
|
- 디자이너에서 v3 → v4 업그레이드 버튼 제공
|
||||||
|
- 새로 생성하는 레이아웃은 v4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 예상 효과
|
||||||
|
|
||||||
|
### 7.1 사용자 경험
|
||||||
|
|
||||||
|
| 항목 | 기존 (v3) | 새로운 (v4) |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| 설계 개수 | 4개 | 1개 |
|
||||||
|
| 작업 시간 | 4배 | 1배 |
|
||||||
|
| 반응형 | 수동 | 자동 |
|
||||||
|
| 디바이스 대응 | 각각 설정 | mobileScale |
|
||||||
|
|
||||||
|
### 7.2 개발자 경험
|
||||||
|
|
||||||
|
| 항목 | 기존 (v3) | 새로운 (v4) |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| 렌더링 | CSS Grid | Flexbox |
|
||||||
|
| 위치 계산 | col/row | 자동 |
|
||||||
|
| 반응형 로직 | 4모드 분기 | 브레이크포인트 |
|
||||||
|
| 유지보수 | 복잡 | 단순 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 일정 (예상)
|
||||||
|
|
||||||
|
| Phase | 내용 | 기간 |
|
||||||
|
|-------|------|------|
|
||||||
|
| 1 | 데이터 구조 | 1-2일 |
|
||||||
|
| 2 | 렌더러 | 2-3일 |
|
||||||
|
| 3 | 디자이너 UI | 3-4일 |
|
||||||
|
| 4 | 뷰어 통합 | 1-2일 |
|
||||||
|
| 5 | 백엔드 수정 | 1일 |
|
||||||
|
| 6 | 테스트/마이그레이션 | 2-3일 |
|
||||||
|
| **총계** | | **10-15일** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 리스크 및 대응
|
||||||
|
|
||||||
|
### 9.1 기존 레이아웃 호환성
|
||||||
|
|
||||||
|
- **리스크**: v3 → v4 자동 변환이 완벽하지 않을 수 있음
|
||||||
|
- **대응**:
|
||||||
|
- 마이그레이션 미리보기 기능
|
||||||
|
- 수동 조정 도구 제공
|
||||||
|
- v3 유지 옵션
|
||||||
|
|
||||||
|
### 9.2 학습 곡선
|
||||||
|
|
||||||
|
- **리스크**: 제약조건 개념이 익숙하지 않을 수 있음
|
||||||
|
- **대응**:
|
||||||
|
- 프리셋 제공 (예: "화면 전체 채우기", "고정 크기")
|
||||||
|
- 툴팁/도움말
|
||||||
|
- 예제 템플릿
|
||||||
|
|
||||||
|
### 9.3 성능
|
||||||
|
|
||||||
|
- **리스크**: Flexbox 중첩으로 렌더링 성능 저하
|
||||||
|
- **대응**:
|
||||||
|
- 컨테이너 중첩 깊이 제한 (최대 3-4)
|
||||||
|
- React.memo 활용
|
||||||
|
- 가상화 (리스트 컴포넌트)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 결론
|
||||||
|
|
||||||
|
v4.0 제약조건 기반 시스템은 업계 표준(Figma, Flutter, SwiftUI)을 따르며, 사용자의 작업량을 75% 줄이고 자동 반응형을 제공합니다.
|
||||||
|
|
||||||
|
구현 후 POP 디자이너는:
|
||||||
|
- **1개 레이아웃**만 설계
|
||||||
|
- **모든 화면 크기**에 자동 적응
|
||||||
|
- **모바일 특화 설정** (mobileScale)으로 세밀한 제어 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 추가 설정 (2026-02-03 업데이트)
|
||||||
|
|
||||||
|
### 11.1 확장된 전역 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopGlobalSettingsV4 {
|
||||||
|
// 기존
|
||||||
|
touchTargetMin: number; // 48 (normal) / 60 (industrial)
|
||||||
|
mode: "normal" | "industrial";
|
||||||
|
defaultGap: number;
|
||||||
|
defaultPadding: number;
|
||||||
|
breakpoints: {
|
||||||
|
tablet: number; // 768
|
||||||
|
mobile: number; // 480
|
||||||
|
};
|
||||||
|
|
||||||
|
// 신규 추가
|
||||||
|
environment: "indoor" | "outdoor"; // 야외면 대비 높임
|
||||||
|
|
||||||
|
typography: {
|
||||||
|
body: { min: number; max: number }; // 14-18px
|
||||||
|
heading: { min: number; max: number }; // 18-28px
|
||||||
|
caption: { min: number; max: number }; // 12-14px
|
||||||
|
};
|
||||||
|
|
||||||
|
contrast: "normal" | "high"; // outdoor면 자동 high
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 컴포넌트 기본값 프리셋
|
||||||
|
|
||||||
|
컴포넌트 추가 시 자동 적용되는 안전한 기본값:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const COMPONENT_DEFAULTS = {
|
||||||
|
"pop-button": {
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 48,
|
||||||
|
height: "fixed",
|
||||||
|
fixedHeight: 48,
|
||||||
|
},
|
||||||
|
"pop-field": {
|
||||||
|
minWidth: 120,
|
||||||
|
minHeight: 40,
|
||||||
|
height: "fixed",
|
||||||
|
fixedHeight: 48,
|
||||||
|
},
|
||||||
|
"pop-list": {
|
||||||
|
minHeight: 200,
|
||||||
|
itemHeight: 48,
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 리스트 반응형 컬럼
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopListConfig {
|
||||||
|
// 기존
|
||||||
|
listType: PopListType;
|
||||||
|
displayColumns?: string[];
|
||||||
|
|
||||||
|
// 신규 추가
|
||||||
|
responsiveColumns?: {
|
||||||
|
tablet: string[]; // 전체 컬럼
|
||||||
|
mobile: string[]; // 주요 컬럼만
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 라벨 배치 자동화
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopContainer {
|
||||||
|
// 기존
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
|
||||||
|
// 신규 추가
|
||||||
|
labelPlacement?: "auto" | "above" | "beside";
|
||||||
|
// auto: 모바일 세로=위, 태블릿 가로=옆
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 관련 문서
|
||||||
|
|
||||||
|
- [v4 핵심 규칙 가이드](./V4_CORE_RULES.md) - **3가지 핵심 규칙 (필독)**
|
||||||
|
- [반응형 디자인 가이드](./RESPONSIVE_DESIGN_GUIDE.md)
|
||||||
|
- [컴포넌트 로드맵](./COMPONENT_ROADMAP.md)
|
||||||
|
- [크기 프리셋 가이드](./SIZE_PRESETS.md)
|
||||||
|
- [컴포넌트 상세 스펙](./components-spec.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 현재 상태 (2026-02-03)
|
||||||
|
|
||||||
|
**구현 대기**: 컴포넌트가 아직 없어서 레이아웃 시스템보다 컴포넌트 개발이 선행되어야 함.
|
||||||
|
|
||||||
|
**권장 진행 순서**:
|
||||||
|
1. 기초 컴포넌트 개발 (PopButton, PopInput 등)
|
||||||
|
2. 조합 컴포넌트 개발 (PopFormField, PopCard 등)
|
||||||
|
3. 복합 컴포넌트 개발 (PopDataTable, PopCardList 등)
|
||||||
|
4. v4 레이아웃 시스템 구현
|
||||||
|
5. 디자이너 UI 개발
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-03*
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
# POP 반응형 디자인 가이드
|
||||||
|
|
||||||
|
## 쉬운 요약
|
||||||
|
|
||||||
|
### 핵심 원칙: 3가지만 기억하세요
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 누르는 것 → 크기 고정 (최소 48px)
|
||||||
|
2. 읽는 것 → 범위 안에서 자동 조절
|
||||||
|
3. 담는 것 → 화면에 맞춰 늘어남
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 터치 요소 (고정 크기)
|
||||||
|
|
||||||
|
손가락 크기는 화면이 커져도 변하지 않습니다.
|
||||||
|
|
||||||
|
| 요소 | 일반 | 산업현장(장갑) |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| 버튼 | 48px | 60px |
|
||||||
|
| 아이콘 (누르는 용) | 48px | 60px |
|
||||||
|
| 체크박스 | 24px (터치영역 48px) | 24px (터치영역 60px) |
|
||||||
|
| 리스트 한 줄 높이 | 48px | 56px |
|
||||||
|
| 입력창 높이 | 40px | 48px |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 텍스트 (범위 조절)
|
||||||
|
|
||||||
|
화면 크기에 따라 자동으로 커지거나 작아집니다.
|
||||||
|
|
||||||
|
| 용도 | 최소 | 최대 |
|
||||||
|
|------|-----|-----|
|
||||||
|
| 본문 | 14px | 18px |
|
||||||
|
| 제목 | 18px | 28px |
|
||||||
|
| 설명 | 12px | 14px |
|
||||||
|
|
||||||
|
**CSS 예시**:
|
||||||
|
```css
|
||||||
|
font-size: clamp(14px, 1.5vw, 18px);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 레이아웃 (비율 기반)
|
||||||
|
|
||||||
|
컨테이너는 화면에 맞춰 늘어납니다.
|
||||||
|
|
||||||
|
| 요소 | 방식 | 예시 |
|
||||||
|
|------|-----|-----|
|
||||||
|
| 컨테이너 | 100% | 화면 전체 채움 |
|
||||||
|
| 카드 2열 | 48% + 48% | 화면 반씩 |
|
||||||
|
| 입력창 너비 | fill | 부모 채움 |
|
||||||
|
| 여백 | 8/16/24px | 화면 크기별 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 환경별 설정
|
||||||
|
|
||||||
|
### 일반 (실내)
|
||||||
|
- 터치: 48px
|
||||||
|
- 대비: 4.5:1
|
||||||
|
- 폰트: 14-18px
|
||||||
|
|
||||||
|
### 산업현장 (야외/장갑)
|
||||||
|
- 터치: 60px (+25%)
|
||||||
|
- 대비: 7:1 이상
|
||||||
|
- 폰트: 18-22px (+25%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 리스트/테이블 반응형
|
||||||
|
|
||||||
|
화면이 좁아지면 컬럼을 줄입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
태블릿 (넓음) 모바일 (좁음)
|
||||||
|
┌──────┬──────┬──────┬──────┐ ┌──────┬──────┐
|
||||||
|
│품번 │품명 │수량 │상태 │ │품번 │수량 │
|
||||||
|
├──────┼──────┼──────┼──────┤ ├──────┼──────┤
|
||||||
|
│A001 │나사 │100 │완료 │ │A001 │100 │
|
||||||
|
└──────┴──────┴──────┴──────┘ └──────┴──────┘
|
||||||
|
↳ 터치하면 상세보기
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 폼 라벨 배치
|
||||||
|
|
||||||
|
| 화면 | 라벨 위치 | 이유 |
|
||||||
|
|------|----------|-----|
|
||||||
|
| 모바일 세로 | 위 | 입력창 너비 확보 |
|
||||||
|
| 태블릿 가로 | 옆 | 공간 여유 |
|
||||||
|
|
||||||
|
```
|
||||||
|
모바일 태블릿
|
||||||
|
┌─────────────────┐ ┌─────────────────────────┐
|
||||||
|
│ 이름 │ │ 이름: [입력____________] │
|
||||||
|
│ [입력__________]│ └─────────────────────────┘
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 그림으로 보는 반응형
|
||||||
|
|
||||||
|
```
|
||||||
|
8인치 태블릿 12인치 태블릿
|
||||||
|
┌─────────────────┐ ┌───────────────────────┐
|
||||||
|
│ [버튼 48px] │ │ [버튼 48px] │ ← 버튼 크기 동일!
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌─────────────┐ │ │ ┌─────────────────┐ │
|
||||||
|
│ │ 입력창 │ │ │ │ 입력창 │ │ ← 너비만 늘어남
|
||||||
|
│ └─────────────┘ │ │ └─────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 글자 14px │ │ 글자 18px │ ← 글자만 커짐
|
||||||
|
└─────────────────┘ └───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 색상 대비 (야외용)
|
||||||
|
|
||||||
|
| 환경 | 최소 대비 | 권장 |
|
||||||
|
|------|----------|-----|
|
||||||
|
| 실내 | 4.5:1 | 7:1 |
|
||||||
|
| 야외 | 7:1 | 10:1+ |
|
||||||
|
|
||||||
|
**좋은 조합**:
|
||||||
|
- 흰 배경 + 검정 글자
|
||||||
|
- 검정 배경 + 흰 글자
|
||||||
|
- 노랑 경고 + 검정 글자
|
||||||
|
|
||||||
|
**피해야 할 조합**:
|
||||||
|
- 연한 회색 + 밝은 회색
|
||||||
|
- 빨강 + 녹색 (색맹 고려)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 체크리스트
|
||||||
|
|
||||||
|
### 컴포넌트 만들 때 확인
|
||||||
|
|
||||||
|
- [ ] 버튼/터치 요소 최소 48px인가?
|
||||||
|
- [ ] 폰트에 clamp() 적용했나?
|
||||||
|
- [ ] 색상 대비 4.5:1 이상인가?
|
||||||
|
- [ ] 모바일에서 라벨이 위에 있나?
|
||||||
|
- [ ] 리스트가 좁은 화면에서 컬럼 줄어드나?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-03*
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
# POP 크기 프리셋 가이드
|
||||||
|
|
||||||
|
## 컴포넌트별 기본 크기
|
||||||
|
|
||||||
|
컴포넌트를 만들면 자동으로 적용되는 크기입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 버튼 (PopButton)
|
||||||
|
|
||||||
|
| 사이즈 | 높이 | 최소 너비 | 폰트 | 용도 |
|
||||||
|
|-------|------|----------|------|-----|
|
||||||
|
| sm | 32px | 60px | 12px | 보조 버튼 |
|
||||||
|
| md | 40px | 80px | 14px | 일반 버튼 |
|
||||||
|
| **lg** | **48px** | **100px** | **16px** | **POP 기본** |
|
||||||
|
| xl | 56px | 120px | 18px | 주요 액션 |
|
||||||
|
| industrial | 60px | 140px | 20px | 장갑 착용 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// POP에서는 lg가 기본
|
||||||
|
<PopButton size="lg">확인</PopButton>
|
||||||
|
|
||||||
|
// 산업현장
|
||||||
|
<PopButton size="industrial">작업 완료</PopButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 입력창 (PopInput)
|
||||||
|
|
||||||
|
| 사이즈 | 높이 | 폰트 | 용도 |
|
||||||
|
|-------|------|------|-----|
|
||||||
|
| md | 40px | 14px | 일반 |
|
||||||
|
| **lg** | **48px** | **16px** | **POP 기본** |
|
||||||
|
| xl | 56px | 18px | 강조 입력 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 입력창 너비는 항상 부모 채움 (fill)
|
||||||
|
<PopInput size="lg" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리스트 행 (PopListItem)
|
||||||
|
|
||||||
|
| 사이즈 | 높이 | 폰트 | 용도 |
|
||||||
|
|-------|------|------|-----|
|
||||||
|
| compact | 40px | 14px | 많은 데이터 |
|
||||||
|
| **normal** | **48px** | **16px** | **POP 기본** |
|
||||||
|
| spacious | 56px | 18px | 여유로운 |
|
||||||
|
| industrial | 64px | 20px | 장갑 착용 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<PopListItem size="normal">
|
||||||
|
<span>작업지시 #1234</span>
|
||||||
|
</PopListItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아이콘 (PopIcon)
|
||||||
|
|
||||||
|
| 사이즈 | 크기 | 터치 영역 | 용도 |
|
||||||
|
|-------|-----|----------|-----|
|
||||||
|
| sm | 16px | 32px | 뱃지 안 |
|
||||||
|
| md | 20px | 40px | 텍스트 옆 |
|
||||||
|
| **lg** | **24px** | **48px** | **POP 기본** |
|
||||||
|
| xl | 32px | 56px | 강조 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 아이콘만 있는 버튼
|
||||||
|
<PopButton icon="check" size="lg" />
|
||||||
|
|
||||||
|
// 텍스트 + 아이콘
|
||||||
|
<PopButton icon="save" size="lg">저장</PopButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 카드 (PopCard)
|
||||||
|
|
||||||
|
| 요소 | 크기 |
|
||||||
|
|------|-----|
|
||||||
|
| 패딩 | 16px |
|
||||||
|
| 제목 폰트 | 18px (heading) |
|
||||||
|
| 본문 폰트 | 16px (body) |
|
||||||
|
| 모서리 | 8px |
|
||||||
|
| 최소 높이 | 100px |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<PopCard>
|
||||||
|
<PopCard.Header>작업지시</PopCard.Header>
|
||||||
|
<PopCard.Body>내용</PopCard.Body>
|
||||||
|
</PopCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 숫자패드 (PopNumberPad)
|
||||||
|
|
||||||
|
| 요소 | 크기 |
|
||||||
|
|------|-----|
|
||||||
|
| 버튼 크기 | 60px x 60px |
|
||||||
|
| 버튼 간격 | 8px |
|
||||||
|
| 전체 너비 | 240px |
|
||||||
|
| 폰트 | 24px |
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ [ 123 ] │ ← 디스플레이 48px
|
||||||
|
├─────┬─────┬─────────┤
|
||||||
|
│ 7 │ 8 │ 9 │ ← │ ← 각 버튼 60x60
|
||||||
|
├─────┼─────┼─────────┤
|
||||||
|
│ 4 │ 5 │ 6 │ C │
|
||||||
|
├─────┼─────┼─────────┤
|
||||||
|
│ 1 │ 2 │ 3 │ │
|
||||||
|
├─────┼─────┼─────│ OK│
|
||||||
|
│ 0 │ . │ +- │ │
|
||||||
|
└─────┴─────┴─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 상태 표시 (PopStatusBox)
|
||||||
|
|
||||||
|
| 사이즈 | 너비 | 높이 | 아이콘 | 용도 |
|
||||||
|
|-------|-----|------|-------|-----|
|
||||||
|
| sm | 80px | 60px | 24px | 여러 개 나열 |
|
||||||
|
| **md** | **120px** | **80px** | **32px** | **POP 기본** |
|
||||||
|
| lg | 160px | 100px | 40px | 강조 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<PopStatusBox
|
||||||
|
label="설비 상태"
|
||||||
|
value="가동중"
|
||||||
|
status="success"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KPI 게이지 (PopKpiGauge)
|
||||||
|
|
||||||
|
| 사이즈 | 너비 | 높이 | 용도 |
|
||||||
|
|-------|-----|------|-----|
|
||||||
|
| sm | 120px | 120px | 여러 개 나열 |
|
||||||
|
| **md** | **180px** | **180px** | **POP 기본** |
|
||||||
|
| lg | 240px | 240px | 강조 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 간격 (Gap/Padding)
|
||||||
|
|
||||||
|
| 이름 | 값 | 용도 |
|
||||||
|
|------|---|-----|
|
||||||
|
| xs | 4px | 아이콘-텍스트 |
|
||||||
|
| sm | 8px | 요소 내부 |
|
||||||
|
| **md** | **16px** | **컴포넌트 간** |
|
||||||
|
| lg | 24px | 섹션 간 |
|
||||||
|
| xl | 32px | 영역 구분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 반응형 조절
|
||||||
|
|
||||||
|
화면 크기에 따라 자동 조절되는 값들:
|
||||||
|
|
||||||
|
| 요소 | 8인치 태블릿 | 12인치 태블릿 |
|
||||||
|
|------|------------|--------------|
|
||||||
|
| 본문 폰트 | 14px | 18px |
|
||||||
|
| 제목 폰트 | 18px | 28px |
|
||||||
|
| 컨테이너 패딩 | 12px | 24px |
|
||||||
|
| 카드 간격 | 12px | 16px |
|
||||||
|
|
||||||
|
**고정되는 값들 (변하지 않음)**:
|
||||||
|
- 버튼 높이: 48px
|
||||||
|
- 입력창 높이: 48px
|
||||||
|
- 리스트 행 높이: 48px
|
||||||
|
- 터치 최소 영역: 48px
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 적용 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 컴포넌트 내부에서 자동 적용
|
||||||
|
function PopButton({ size = "lg", ...props }) {
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: { height: 32, minWidth: 60, fontSize: 12 },
|
||||||
|
md: { height: 40, minWidth: 80, fontSize: 14 },
|
||||||
|
lg: { height: 48, minWidth: 100, fontSize: 16 }, // POP 기본
|
||||||
|
xl: { height: 56, minWidth: 120, fontSize: 18 },
|
||||||
|
industrial: { height: 60, minWidth: 140, fontSize: 20 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button style={sizeStyles[size]} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-03*
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
# POP v4 핵심 규칙 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
v4에서는 **"위치"를 설정하는 게 아니라 "규칙"을 설정**합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
v3 (기존): 4개 모드 각각 컴포넌트 위치 설정 → 4배 작업량
|
||||||
|
v4 (신규): 3가지 규칙만 설정 → 모든 화면 자동 적응
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 규칙 3가지
|
||||||
|
|
||||||
|
### 1. 크기 규칙 (Size Rules)
|
||||||
|
|
||||||
|
각 컴포넌트의 **너비**와 **높이**를 어떻게 결정할지 정합니다.
|
||||||
|
|
||||||
|
| 모드 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| **fixed** | 고정 px | 버튼 높이 48px |
|
||||||
|
| **fill** | 부모 공간 채움 | 입력창 너비 = 화면 너비 |
|
||||||
|
| **hug** | 내용에 맞춤 | 라벨 너비 = 텍스트 길이 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예시: 버튼
|
||||||
|
{
|
||||||
|
width: "fill", // 화면 너비에 맞춤
|
||||||
|
height: "fixed", // 고정
|
||||||
|
fixedHeight: 48 // 48px
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예시: 라벨
|
||||||
|
{
|
||||||
|
width: "hug", // 텍스트 길이만큼
|
||||||
|
height: "hug" // 텍스트 높이만큼
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 크기 모드 시각화
|
||||||
|
|
||||||
|
```
|
||||||
|
fixed (고정):
|
||||||
|
├────48px────┤
|
||||||
|
┌────────────┐
|
||||||
|
│ 버튼 │ ← 화면 커져도 48px 유지
|
||||||
|
└────────────┘
|
||||||
|
|
||||||
|
fill (채움):
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 입력창 │ ← 화면 크기에 맞춤
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
|
||||||
|
hug (맞춤):
|
||||||
|
├──────┤
|
||||||
|
┌──────┐
|
||||||
|
│라벨 │ ← 내용 길이만큼만
|
||||||
|
└──────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 배치 규칙 (Layout Rules)
|
||||||
|
|
||||||
|
컴포넌트들을 **어떻게 나열할지** 정합니다.
|
||||||
|
|
||||||
|
#### 스택 방향
|
||||||
|
|
||||||
|
```
|
||||||
|
가로 스택 (horizontal): 세로 스택 (vertical):
|
||||||
|
┌─────┬─────┬─────┐ ┌─────────────┐
|
||||||
|
│ A │ B │ C │ │ A │
|
||||||
|
└─────┴─────┴─────┘ ├─────────────┤
|
||||||
|
│ B │
|
||||||
|
├─────────────┤
|
||||||
|
│ C │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 설정 항목
|
||||||
|
|
||||||
|
| 항목 | 설명 | 옵션 |
|
||||||
|
|------|------|------|
|
||||||
|
| **direction** | 스택 방향 | horizontal / vertical |
|
||||||
|
| **wrap** | 줄바꿈 허용 | true / false |
|
||||||
|
| **gap** | 요소 간 간격 | 8 / 16 / 24 px |
|
||||||
|
| **alignItems** | 교차축 정렬 | start / center / end / stretch |
|
||||||
|
| **justifyContent** | 주축 정렬 | start / center / end / space-between |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예시: 버튼 그룹 (가로 배치)
|
||||||
|
{
|
||||||
|
direction: "horizontal",
|
||||||
|
wrap: true, // 공간 부족하면 줄바꿈
|
||||||
|
gap: 16, // 버튼 간격 16px
|
||||||
|
alignItems: "center" // 세로 중앙 정렬
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 반응형 규칙 (Responsive Rules)
|
||||||
|
|
||||||
|
**화면이 좁아지면** 어떻게 바꿀지 정합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예시: 768px 이하면 가로→세로 전환
|
||||||
|
{
|
||||||
|
direction: "horizontal",
|
||||||
|
responsive: [
|
||||||
|
{
|
||||||
|
breakpoint: 768, // 768px 이하일 때
|
||||||
|
direction: "vertical" // 세로로 바꿈
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 시각화
|
||||||
|
|
||||||
|
```
|
||||||
|
768px 이상 (태블릿): 768px 이하 (모바일):
|
||||||
|
┌─────┬─────┬─────┐ ┌─────────────┐
|
||||||
|
│ A │ B │ C │ → │ A │
|
||||||
|
└─────┴─────┴─────┘ │ B │
|
||||||
|
│ C │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실제 예시: 작업지시 화면
|
||||||
|
|
||||||
|
### 규칙 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
root: {
|
||||||
|
type: "stack",
|
||||||
|
direction: "vertical",
|
||||||
|
gap: 16,
|
||||||
|
padding: { top: 16, right: 16, bottom: 16, left: 16 },
|
||||||
|
children: ["header", "form", "buttons"]
|
||||||
|
},
|
||||||
|
|
||||||
|
containers: {
|
||||||
|
"header": {
|
||||||
|
type: "stack",
|
||||||
|
direction: "horizontal",
|
||||||
|
alignItems: "center",
|
||||||
|
children: ["title", "status"]
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
type: "stack",
|
||||||
|
direction: "vertical",
|
||||||
|
gap: 12,
|
||||||
|
children: ["field1", "field2", "field3"]
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
type: "stack",
|
||||||
|
direction: "horizontal",
|
||||||
|
gap: 12,
|
||||||
|
responsive: [
|
||||||
|
{ breakpoint: 480, direction: "vertical" }
|
||||||
|
],
|
||||||
|
children: ["cancelBtn", "submitBtn"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
"title": { width: "hug", height: "hug" },
|
||||||
|
"status": { width: "hug", height: "hug" },
|
||||||
|
"field1": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||||
|
"field2": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||||
|
"field3": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||||
|
"cancelBtn": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||||
|
"submitBtn": { width: "fill", height: "fixed", fixedHeight: 48 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 결과
|
||||||
|
|
||||||
|
```
|
||||||
|
태블릿 가로 (1024px) 모바일 세로 (375px)
|
||||||
|
┌──────────────────────────┐ ┌─────────────────┐
|
||||||
|
│ 작업지시 #1234 [진행중]│ │작업지시 [진행]│
|
||||||
|
├──────────────────────────┤ ├─────────────────┤
|
||||||
|
│ [품번____________] │ │[품번_________] │
|
||||||
|
│ [품명____________] │ │[품명_________] │
|
||||||
|
│ [수량____________] │ │[수량_________] │
|
||||||
|
├──────────────────────────┤ ├─────────────────┤
|
||||||
|
│ [취소] [작업완료] │ │[취소] │
|
||||||
|
└──────────────────────────┘ │[작업완료] │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3 vs v4 비교
|
||||||
|
|
||||||
|
| 항목 | v3 (기존) | v4 (신규) |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **설계 방식** | 4개 모드 각각 위치 설정 | 3가지 규칙 설정 |
|
||||||
|
| **작업량** | 4배 | 1배 |
|
||||||
|
| **데이터** | col, row, colSpan, rowSpan | width, height, direction, gap |
|
||||||
|
| **반응형** | 수동 (모드별 설정) | 자동 (브레이크포인트) |
|
||||||
|
| **유지보수** | 4곳 수정 | 1곳 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 규칙 설계 체크리스트
|
||||||
|
|
||||||
|
### 크기 규칙
|
||||||
|
- [ ] 터치 요소(버튼, 입력창) 높이: fixed 48px
|
||||||
|
- [ ] 너비가 화면에 맞아야 하는 요소: fill
|
||||||
|
- [ ] 내용 길이에 맞아야 하는 요소: hug
|
||||||
|
|
||||||
|
### 배치 규칙
|
||||||
|
- [ ] 컴포넌트 나열 방향 결정 (가로/세로)
|
||||||
|
- [ ] 간격 설정 (8/16/24px)
|
||||||
|
- [ ] 정렬 방식 결정 (start/center/stretch)
|
||||||
|
|
||||||
|
### 반응형 규칙
|
||||||
|
- [ ] 768px 이하에서 가로→세로 전환 필요한 곳
|
||||||
|
- [ ] 480px 이하에서 추가 조정 필요한 곳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- [반응형 디자인 가이드](./RESPONSIVE_DESIGN_GUIDE.md) - 크기 기준
|
||||||
|
- [크기 프리셋](./SIZE_PRESETS.md) - 컴포넌트별 기본값
|
||||||
|
- [v4 구현 계획](./POP_V4_CONSTRAINT_SYSTEM_PLAN.md) - 전체 계획
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-04*
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
# ADR-001: v4 제약조건 기반 레이아웃 채택
|
||||||
|
|
||||||
|
**날짜**: 2026-02-03
|
||||||
|
**상태**: 채택됨
|
||||||
|
**의사결정자**: 프로젝트 담당자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
v3에서는 4개 모드(tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait)에 대해 각각 컴포넌트 위치를 설정해야 했습니다.
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
1. 같은 컴포넌트를 4번 배치해야 함 (4배 작업량)
|
||||||
|
2. 모드 간 일관성 유지 어려움
|
||||||
|
3. 새 모드 추가 시 또 다른 배치 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
**"단일 소스 + 자동 적응" 방식 채택**
|
||||||
|
|
||||||
|
Figma, Framer, Flutter, SwiftUI에서 사용하는 업계 표준 접근법:
|
||||||
|
- 하나의 레이아웃 정의
|
||||||
|
- 제약조건(constraints) 설정
|
||||||
|
- 모든 화면에 자동 적응
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 규칙 3가지
|
||||||
|
|
||||||
|
### 1. 크기 규칙 (Size Rules)
|
||||||
|
|
||||||
|
| 모드 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| fixed | 고정 px | 버튼 높이 48px |
|
||||||
|
| fill | 부모 채움 | 입력창 100% |
|
||||||
|
| hug | 내용 맞춤 | 라벨 = 텍스트 길이 |
|
||||||
|
|
||||||
|
### 2. 배치 규칙 (Layout Rules)
|
||||||
|
|
||||||
|
- 스택 방향: horizontal / vertical
|
||||||
|
- 줄바꿈: wrap / nowrap
|
||||||
|
- 간격: gap (8/16/24px)
|
||||||
|
- 정렬: start / center / end / stretch
|
||||||
|
|
||||||
|
### 3. 반응형 규칙 (Responsive Rules)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
direction: "horizontal",
|
||||||
|
responsive: [
|
||||||
|
{ breakpoint: 768, direction: "vertical" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 방식
|
||||||
|
|
||||||
|
**Flexbox 기반** (CSS Grid 아님)
|
||||||
|
|
||||||
|
이유:
|
||||||
|
- 1차원 배치에 최적화
|
||||||
|
- 자동 크기 계산 (hug)
|
||||||
|
- 반응형 전환 간단
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 검토
|
||||||
|
|
||||||
|
### A. 기존 4모드 유지 (기각)
|
||||||
|
|
||||||
|
장점: 기존 코드 변경 없음
|
||||||
|
단점: 근본 문제 해결 안 됨
|
||||||
|
|
||||||
|
### B. CSS Grid 기반 (기각)
|
||||||
|
|
||||||
|
장점: 2차원 배치 가능
|
||||||
|
단점: hug 구현 복잡, 학습 곡선
|
||||||
|
|
||||||
|
### C. 제약조건 기반 (채택)
|
||||||
|
|
||||||
|
장점: 업계 표준, 1회 설계
|
||||||
|
단점: 기존 v3와 호환성 고려 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 영향
|
||||||
|
|
||||||
|
### 변경 필요
|
||||||
|
|
||||||
|
- 타입 정의 (PopLayoutDataV4)
|
||||||
|
- 렌더러 (Flexbox 기반)
|
||||||
|
- 디자이너 UI (제약조건 편집)
|
||||||
|
|
||||||
|
### 호환성
|
||||||
|
|
||||||
|
- v3 레이아웃은 기존 방식으로 계속 작동
|
||||||
|
- v4는 새로운 레이아웃에만 적용
|
||||||
|
- 점진적 마이그레이션 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참조
|
||||||
|
|
||||||
|
- Figma Auto Layout: https://help.figma.com/hc/en-us/articles/5731482952599-Using-auto-layout
|
||||||
|
- Flutter Flex: https://docs.flutter.dev/development/ui/layout
|
||||||
|
- SwiftUI Stacks: https://developer.apple.com/documentation/swiftui/hstack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련
|
||||||
|
|
||||||
|
- rangraph 검색: "v4 constraint", "layout system"
|
||||||
|
- SPEC.md: 상세 규칙
|
||||||
|
- PLAN.md: 구현 로드맵
|
||||||
Loading…
Reference in New Issue