feat(pop): POP 화면 관리 시스템 구현

Backend:
- screen_layouts_pop 테이블용 CRUD API 추가 (getLayoutPop, saveLayoutPop, deleteLayoutPop, getScreenIdsWithPopLayout)
- 멀티테넌시 권한 체크 포함

Frontend API:
- screenApi에 POP 레이아웃 함수 4개 추가

POP 관리 페이지:
- popScreenMngList 신규 생성
- isPop prop으로 미리보기 URL 분기 (/pop/screens/{id})
- CreateScreenModal에서 POP 화면 생성 시 빈 레이아웃 자동 생성

POP 디자이너:
- PopDesigner, PopCanvas, PopPanel, SectionGrid 컴포넌트 구현
- react-dnd로 팔레트→캔버스 드래그앤드롭
- react-grid-layout으로 컴포넌트 자유 배치/리사이즈
- 그리드 단순화: 고정 셀 크기(40px) 기반 자동 계산, 그리드 점 제거
- onLayoutChange를 onDragStop/onResizeStop으로 변경하여 드롭 시 크기 유지
This commit is contained in:
SeongHyun Kim
2026-02-02 15:15:01 +09:00
parent 3fca677f3d
commit 8c045acab3
29 changed files with 5611 additions and 62 deletions

View File

@@ -213,6 +213,32 @@ export const screenApi = {
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
},
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================
// POP 레이아웃 조회
getLayoutPop: async (screenId: number): Promise<any> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`);
return response.data.data;
},
// POP 레이아웃 저장
saveLayoutPop: async (screenId: number, layoutData: any): Promise<void> => {
await apiClient.post(`/screen-management/screens/${screenId}/layout-pop`, layoutData);
},
// POP 레이아웃 삭제
deleteLayoutPop: async (screenId: number): Promise<void> => {
await apiClient.delete(`/screen-management/screens/${screenId}/layout-pop`);
},
// POP 레이아웃이 존재하는 화면 ID 목록 조회
getScreenIdsWithPopLayout: async (): Promise<number[]> => {
const response = await apiClient.get(`/screen-management/pop-layout-screen-ids`);
return response.data.data || [];
},
// 연결된 모달 화면 감지
detectLinkedModals: async (
screenId: number,

View File

@@ -0,0 +1,267 @@
"use client";
import React from "react";
/**
* POP 컴포넌트 정의 인터페이스
*/
export interface PopComponentDefinition {
id: string;
name: string;
description: string;
category: PopComponentCategory;
icon?: string;
component: React.ComponentType<any>;
configPanel?: React.ComponentType<any>;
defaultProps?: Record<string, any>;
// POP 전용 속성
touchOptimized?: boolean;
minTouchArea?: number;
supportedDevices?: ("mobile" | "tablet")[];
createdAt?: Date;
updatedAt?: Date;
}
/**
* POP 컴포넌트 카테고리
*/
export type PopComponentCategory =
| "display" // 데이터 표시 (카드, 리스트, 배지)
| "input" // 입력 (스캐너, 터치 입력)
| "action" // 액션 (버튼, 스와이프)
| "layout" // 레이아웃 (컨테이너, 그리드)
| "feedback"; // 피드백 (토스트, 로딩)
/**
* POP 컴포넌트 레지스트리 이벤트
*/
export interface PopComponentRegistryEvent {
type: "component_registered" | "component_unregistered";
data: PopComponentDefinition;
timestamp: Date;
}
/**
* POP 컴포넌트 레지스트리 클래스
* 모바일/태블릿 전용 컴포넌트를 등록, 관리, 조회할 수 있는 중앙 레지스트리
*/
export class PopComponentRegistry {
private static components = new Map<string, PopComponentDefinition>();
private static eventListeners: Array<(event: PopComponentRegistryEvent) => void> = [];
/**
* 컴포넌트 등록
*/
static registerComponent(definition: PopComponentDefinition): void {
// 유효성 검사
if (!definition.id || !definition.name || !definition.component) {
throw new Error(
`POP 컴포넌트 등록 실패 (${definition.id || "unknown"}): 필수 필드 누락`
);
}
// 중복 등록 체크
if (this.components.has(definition.id)) {
console.warn(`[POP Registry] 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`);
}
// 타임스탬프 추가
const enhancedDefinition: PopComponentDefinition = {
...definition,
touchOptimized: definition.touchOptimized ?? true,
minTouchArea: definition.minTouchArea ?? 44,
supportedDevices: definition.supportedDevices ?? ["mobile", "tablet"],
createdAt: definition.createdAt || new Date(),
updatedAt: new Date(),
};
this.components.set(definition.id, enhancedDefinition);
// 이벤트 발생
this.emitEvent({
type: "component_registered",
data: enhancedDefinition,
timestamp: new Date(),
});
// 개발 모드에서만 로깅
if (process.env.NODE_ENV === "development") {
console.log(`[POP Registry] 컴포넌트 등록: ${definition.id}`);
}
}
/**
* 컴포넌트 등록 해제
*/
static unregisterComponent(id: string): void {
const definition = this.components.get(id);
if (!definition) {
console.warn(`[POP Registry] 등록되지 않은 컴포넌트 해제 시도: ${id}`);
return;
}
this.components.delete(id);
// 이벤트 발생
this.emitEvent({
type: "component_unregistered",
data: definition,
timestamp: new Date(),
});
console.log(`[POP Registry] 컴포넌트 해제: ${id}`);
}
/**
* 특정 컴포넌트 조회
*/
static getComponent(id: string): PopComponentDefinition | undefined {
return this.components.get(id);
}
/**
* URL로 컴포넌트 조회
*/
static getComponentByUrl(url: string): PopComponentDefinition | undefined {
// "@/lib/registry/pop-components/pop-card-list" → "pop-card-list"
const parts = url.split("/");
const componentId = parts[parts.length - 1];
return this.getComponent(componentId);
}
/**
* 모든 컴포넌트 조회
*/
static getAllComponents(): PopComponentDefinition[] {
return Array.from(this.components.values()).sort((a, b) => {
// 카테고리별 정렬, 그 다음 이름순
if (a.category !== b.category) {
return a.category.localeCompare(b.category);
}
return a.name.localeCompare(b.name);
});
}
/**
* 카테고리별 컴포넌트 조회
*/
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[] {
return Array.from(this.components.values())
.filter((def) => def.category === category)
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* 디바이스별 컴포넌트 조회
*/
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[] {
return Array.from(this.components.values())
.filter((def) => def.supportedDevices?.includes(device))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* 컴포넌트 검색
*/
static searchComponents(query: string): PopComponentDefinition[] {
const lowerQuery = query.toLowerCase();
return Array.from(this.components.values()).filter(
(def) =>
def.id.toLowerCase().includes(lowerQuery) ||
def.name.toLowerCase().includes(lowerQuery) ||
def.description?.toLowerCase().includes(lowerQuery)
);
}
/**
* 등록된 컴포넌트 개수
*/
static getComponentCount(): number {
return this.components.size;
}
/**
* 카테고리별 통계
*/
static getStatsByCategory(): Record<PopComponentCategory, number> {
const stats: Record<PopComponentCategory, number> = {
display: 0,
input: 0,
action: 0,
layout: 0,
feedback: 0,
};
for (const def of this.components.values()) {
stats[def.category]++;
}
return stats;
}
/**
* 이벤트 리스너 등록
*/
static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void {
this.eventListeners.push(callback);
}
/**
* 이벤트 리스너 해제
*/
static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void {
const index = this.eventListeners.indexOf(callback);
if (index > -1) {
this.eventListeners.splice(index, 1);
}
}
/**
* 이벤트 발생
*/
private static emitEvent(event: PopComponentRegistryEvent): void {
for (const listener of this.eventListeners) {
try {
listener(event);
} catch (error) {
console.error("[POP Registry] 이벤트 리스너 오류:", error);
}
}
}
/**
* 레지스트리 초기화 (테스트용)
*/
static clear(): void {
this.components.clear();
console.log("[POP Registry] 레지스트리 초기화됨");
}
/**
* 컴포넌트 존재 여부 확인
*/
static hasComponent(id: string): boolean {
return this.components.has(id);
}
/**
* 디버그 정보 출력
*/
static debug(): void {
console.group("[POP Registry] 등록된 컴포넌트");
console.log(`${this.components.size}개 컴포넌트`);
console.table(
Array.from(this.components.values()).map((c) => ({
id: c.id,
name: c.name,
category: c.category,
touchOptimized: c.touchOptimized,
devices: c.supportedDevices?.join(", "),
}))
);
console.groupEnd();
}
}
// 기본 export
export default PopComponentRegistry;

View File

@@ -0,0 +1,24 @@
/**
* POP 컴포넌트 인덱스
*
* POP(모바일/태블릿) 전용 컴포넌트를 export합니다.
* 새로운 POP 컴포넌트 추가 시 여기에 export를 추가하세요.
*/
// ============================================
// POP 컴포넌트 목록
// ============================================
// 4단계에서 추가될 컴포넌트들:
// - pop-card-list: 카드형 리스트
// - pop-touch-button: 터치 버튼
// - pop-scanner-input: 스캐너 입력
// - pop-status-badge: 상태 배지
// 예시: 컴포넌트가 추가되면 다음과 같이 export
// export * from "./pop-card-list";
// export * from "./pop-touch-button";
// export * from "./pop-scanner-input";
// export * from "./pop-status-badge";
// 현재는 빈 export (컴포넌트 개발 전)
export { };

View File

@@ -0,0 +1,231 @@
/**
* POP 컴포넌트 설정 스키마 및 유틸리티
*
* POP(모바일/태블릿) 컴포넌트의 overrides 스키마 및 기본값을 관리
* - 공통 요소는 componentConfig.ts에서 import하여 재사용
* - POP 전용 컴포넌트의 overrides 스키마만 새로 정의
*/
import { z } from "zod";
// ============================================
// 공통 요소 재사용 (componentConfig.ts에서 import)
// ============================================
export {
// 공통 스키마
customConfigSchema,
componentV2Schema,
layoutV2Schema,
// 공통 유틸리티 함수
deepMerge,
mergeComponentConfig,
extractCustomConfig,
isDeepEqual,
getComponentTypeFromUrl,
} from "./componentConfig";
// ============================================
// POP 전용 URL 생성 함수
// ============================================
export function getPopComponentUrl(componentType: string): string {
return `@/lib/registry/pop-components/${componentType}`;
}
// ============================================
// POP 전용 컴포넌트 기본값
// ============================================
// POP 카드 리스트 기본값
export const popCardListDefaults = {
displayMode: "card" as const,
cardStyle: "compact" as const,
showHeader: true,
showFooter: false,
pageSize: 10,
enablePullToRefresh: true,
enableInfiniteScroll: false,
cardColumns: 1,
gap: 8,
padding: 16,
// 터치 최적화
touchFeedback: true,
swipeActions: false,
};
// POP 터치 버튼 기본값
export const popTouchButtonDefaults = {
variant: "primary" as const,
size: "lg" as const,
text: "확인",
icon: null,
iconPosition: "left" as const,
fullWidth: true,
// 터치 최적화
minHeight: 48, // 최소 터치 영역 48px
hapticFeedback: true,
pressDelay: 0,
};
// POP 스캐너 입력 기본값
export const popScannerInputDefaults = {
placeholder: "바코드를 스캔하세요",
showKeyboard: false,
autoFocus: true,
autoSubmit: true,
submitDelay: 300,
// 스캐너 설정
scannerMode: "auto" as const,
beepOnScan: true,
vibrationOnScan: true,
clearOnSubmit: true,
};
// POP 상태 배지 기본값
export const popStatusBadgeDefaults = {
variant: "default" as const,
size: "md" as const,
text: "",
icon: null,
// 스타일
rounded: true,
pulse: false,
};
// ============================================
// POP 전용 overrides 스키마
// ============================================
// POP 카드 리스트 overrides 스키마
export const popCardListOverridesSchema = z
.object({
displayMode: z.enum(["card", "list", "grid"]).default("card"),
cardStyle: z.enum(["compact", "default", "expanded"]).default("compact"),
showHeader: z.boolean().default(true),
showFooter: z.boolean().default(false),
pageSize: z.number().default(10),
enablePullToRefresh: z.boolean().default(true),
enableInfiniteScroll: z.boolean().default(false),
cardColumns: z.number().default(1),
gap: z.number().default(8),
padding: z.number().default(16),
touchFeedback: z.boolean().default(true),
swipeActions: z.boolean().default(false),
// 데이터 바인딩
tableName: z.string().optional(),
columns: z.array(z.string()).optional(),
titleField: z.string().optional(),
subtitleField: z.string().optional(),
statusField: z.string().optional(),
})
.passthrough();
// POP 터치 버튼 overrides 스키마
export const popTouchButtonOverridesSchema = z
.object({
variant: z.enum(["primary", "secondary", "success", "warning", "danger", "ghost"]).default("primary"),
size: z.enum(["sm", "md", "lg", "xl"]).default("lg"),
text: z.string().default("확인"),
icon: z.string().nullable().default(null),
iconPosition: z.enum(["left", "right", "top", "bottom"]).default("left"),
fullWidth: z.boolean().default(true),
minHeight: z.number().default(48),
hapticFeedback: z.boolean().default(true),
pressDelay: z.number().default(0),
// 액션
actionType: z.string().optional(),
actionParams: z.record(z.string(), z.any()).optional(),
})
.passthrough();
// POP 스캐너 입력 overrides 스키마
export const popScannerInputOverridesSchema = z
.object({
placeholder: z.string().default("바코드를 스캔하세요"),
showKeyboard: z.boolean().default(false),
autoFocus: z.boolean().default(true),
autoSubmit: z.boolean().default(true),
submitDelay: z.number().default(300),
scannerMode: z.enum(["auto", "camera", "external"]).default("auto"),
beepOnScan: z.boolean().default(true),
vibrationOnScan: z.boolean().default(true),
clearOnSubmit: z.boolean().default(true),
// 데이터 바인딩
tableName: z.string().optional(),
columnName: z.string().optional(),
})
.passthrough();
// POP 상태 배지 overrides 스키마
export const popStatusBadgeOverridesSchema = z
.object({
variant: z.enum(["default", "success", "warning", "danger", "info"]).default("default"),
size: z.enum(["sm", "md", "lg"]).default("md"),
text: z.string().default(""),
icon: z.string().nullable().default(null),
rounded: z.boolean().default(true),
pulse: z.boolean().default(false),
// 조건부 스타일
conditionField: z.string().optional(),
conditionMapping: z.record(z.string(), z.string()).optional(),
})
.passthrough();
// ============================================
// POP 컴포넌트 overrides 스키마 레지스트리
// ============================================
export const popComponentOverridesSchemaRegistry: Record<string, z.ZodTypeAny> = {
"pop-card-list": popCardListOverridesSchema,
"pop-touch-button": popTouchButtonOverridesSchema,
"pop-scanner-input": popScannerInputOverridesSchema,
"pop-status-badge": popStatusBadgeOverridesSchema,
};
// ============================================
// POP 컴포넌트 기본값 레지스트리
// ============================================
export const popComponentDefaultsRegistry: Record<string, Record<string, any>> = {
"pop-card-list": popCardListDefaults,
"pop-touch-button": popTouchButtonDefaults,
"pop-scanner-input": popScannerInputDefaults,
"pop-status-badge": popStatusBadgeDefaults,
};
// ============================================
// POP 기본값 조회 함수
// ============================================
export function getPopComponentDefaults(componentType: string): Record<string, any> {
return popComponentDefaultsRegistry[componentType] || {};
}
// ============================================
// POP URL로 기본값 조회
// ============================================
export function getPopDefaultsByUrl(componentUrl: string): Record<string, any> {
// "@/lib/registry/pop-components/pop-card-list" → "pop-card-list"
const parts = componentUrl.split("/");
const componentType = parts[parts.length - 1];
return getPopComponentDefaults(componentType);
}
// ============================================
// POP overrides 파싱 및 검증
// ============================================
export function parsePopOverridesByUrl(
componentUrl: string,
overrides: Record<string, any>,
): Record<string, any> {
const parts = componentUrl.split("/");
const componentType = parts[parts.length - 1];
const schema = popComponentOverridesSchemaRegistry[componentType];
if (!schema) {
// 스키마 없으면 그대로 반환
return overrides || {};
}
try {
return schema.parse(overrides || {});
} catch {
// 파싱 실패 시 기본값 반환
return getPopComponentDefaults(componentType);
}
}