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:
@@ -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,
|
||||
|
||||
267
frontend/lib/registry/PopComponentRegistry.ts
Normal file
267
frontend/lib/registry/PopComponentRegistry.ts
Normal 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;
|
||||
24
frontend/lib/registry/pop-components/index.ts
Normal file
24
frontend/lib/registry/pop-components/index.ts
Normal 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 { };
|
||||
231
frontend/lib/schemas/popComponentConfig.ts
Normal file
231
frontend/lib/schemas/popComponentConfig.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user