레이아웃 추가기능

This commit is contained in:
kjs 2025-09-10 18:36:28 +09:00
parent f7aa71ec30
commit 083f053851
69 changed files with 10218 additions and 3 deletions

View File

@ -5020,6 +5020,10 @@ model screen_layouts {
height Int
properties Json?
display_order Int @default(0)
layout_type String? @db.VarChar(50)
layout_config Json?
zones_config Json?
zone_id String? @db.VarChar(100)
created_date DateTime @default(now()) @db.Timestamp(6)
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
widgets screen_widgets[]
@ -5302,3 +5306,30 @@ model component_standards {
@@index([category], map: "idx_component_standards_category")
@@index([company_code], map: "idx_component_standards_company")
}
// 레이아웃 표준 관리 테이블
model layout_standards {
layout_code String @id @db.VarChar(50)
layout_name String @db.VarChar(100)
layout_name_eng String? @db.VarChar(100)
description String? @db.Text
layout_type String @db.VarChar(50)
category String @db.VarChar(50)
icon_name String? @db.VarChar(50)
default_size Json? // { width: number, height: number }
layout_config Json // 레이아웃 설정 (그리드, 플렉스박스 등)
zones_config Json // 존 설정 (영역 정의)
preview_image String? @db.VarChar(255)
sort_order Int? @default(0)
is_active String? @default("Y") @db.Char(1)
is_public String? @default("Y") @db.Char(1)
company_code String @db.VarChar(50)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
@@index([layout_type], map: "idx_layout_standards_type")
@@index([category], map: "idx_layout_standards_category")
@@index([company_code], map: "idx_layout_standards_company")
}

View File

@ -0,0 +1,105 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addMissingColumns() {
try {
console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중...");
// layout_type 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50);
`;
console.log("✅ layout_type 컬럼 추가 완료");
} catch (error) {
console.log(
" layout_type 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// layout_config 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS layout_config JSONB;
`;
console.log("✅ layout_config 컬럼 추가 완료");
} catch (error) {
console.log(
" layout_config 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// zones_config 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS zones_config JSONB;
`;
console.log("✅ zones_config 컬럼 추가 완료");
} catch (error) {
console.log(
" zones_config 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// zone_id 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100);
`;
console.log("✅ zone_id 컬럼 추가 완료");
} catch (error) {
console.log(
" zone_id 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// 인덱스 생성 (성능 향상)
try {
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type
ON screen_layouts(layout_type);
`;
console.log("✅ layout_type 인덱스 생성 완료");
} catch (error) {
console.log(" layout_type 인덱스 생성 중 오류:", error.message);
}
try {
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id
ON screen_layouts(zone_id);
`;
console.log("✅ zone_id 인덱스 생성 완료");
} catch (error) {
console.log(" zone_id 인덱스 생성 중 오류:", error.message);
}
// 최종 테이블 구조 확인
const columns = await prisma.$queryRaw`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'screen_layouts'
ORDER BY ordinal_position
`;
console.log("\n📋 screen_layouts 테이블 최종 구조:");
console.table(columns);
console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!");
} catch (error) {
console.error("❌ 컬럼 추가 중 오류 발생:", error);
} finally {
await prisma.$disconnect();
}
}
addMissingColumns();

View File

@ -0,0 +1,309 @@
/**
* 레이아웃 표준 데이터 초기화 스크립트
* 기본 레이아웃들을 layout_standards 테이블에 삽입합니다.
*/
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// 기본 레이아웃 데이터
const PREDEFINED_LAYOUTS = [
{
layout_code: "GRID_2X2_001",
layout_name: "2x2 그리드",
layout_name_eng: "2x2 Grid",
description: "2행 2열의 균등한 그리드 레이아웃입니다.",
layout_type: "grid",
category: "basic",
icon_name: "grid",
default_size: { width: 800, height: 600 },
layout_config: {
grid: { rows: 2, columns: 2, gap: 16 },
},
zones_config: [
{
id: "zone1",
name: "상단 좌측",
position: { row: 0, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone2",
name: "상단 우측",
position: { row: 0, column: 1 },
size: { width: "50%", height: "50%" },
},
{
id: "zone3",
name: "하단 좌측",
position: { row: 1, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone4",
name: "하단 우측",
position: { row: 1, column: 1 },
size: { width: "50%", height: "50%" },
},
],
sort_order: 1,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "FORM_TWO_COLUMN_001",
layout_name: "2단 폼 레이아웃",
layout_name_eng: "Two Column Form",
description: "좌우 2단으로 구성된 폼 레이아웃입니다.",
layout_type: "grid",
category: "form",
icon_name: "columns",
default_size: { width: 800, height: 400 },
layout_config: {
grid: { rows: 1, columns: 2, gap: 24 },
},
zones_config: [
{
id: "left",
name: "좌측 입력 영역",
position: { row: 0, column: 0 },
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 입력 영역",
position: { row: 0, column: 1 },
size: { width: "50%", height: "100%" },
},
],
sort_order: 2,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "FLEXBOX_ROW_001",
layout_name: "가로 플렉스박스",
layout_name_eng: "Horizontal Flexbox",
description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.",
layout_type: "flexbox",
category: "basic",
icon_name: "flex",
default_size: { width: 800, height: 300 },
layout_config: {
flexbox: {
direction: "row",
justify: "flex-start",
align: "stretch",
wrap: "nowrap",
gap: 16,
},
},
zones_config: [
{
id: "left",
name: "좌측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
],
sort_order: 3,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "SPLIT_HORIZONTAL_001",
layout_name: "수평 분할",
layout_name_eng: "Horizontal Split",
description: "크기 조절이 가능한 수평 분할 레이아웃입니다.",
layout_type: "split",
category: "basic",
icon_name: "separator-horizontal",
default_size: { width: 800, height: 400 },
layout_config: {
split: {
direction: "horizontal",
ratio: [50, 50],
minSize: [200, 200],
resizable: true,
splitterSize: 4,
},
},
zones_config: [
{
id: "left",
name: "좌측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
{
id: "right",
name: "우측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
],
sort_order: 4,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "TABS_HORIZONTAL_001",
layout_name: "수평 탭",
layout_name_eng: "Horizontal Tabs",
description: "상단에 탭이 있는 탭 레이아웃입니다.",
layout_type: "tabs",
category: "navigation",
icon_name: "tabs",
default_size: { width: 800, height: 500 },
layout_config: {
tabs: {
position: "top",
variant: "default",
size: "md",
defaultTab: "tab1",
closable: false,
},
},
zones_config: [
{
id: "tab1",
name: "첫 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab2",
name: "두 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab3",
name: "세 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
],
sort_order: 5,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "TABLE_WITH_FILTERS_001",
layout_name: "필터가 있는 테이블",
layout_name_eng: "Table with Filters",
description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.",
layout_type: "flexbox",
category: "table",
icon_name: "table",
default_size: { width: 1000, height: 600 },
layout_config: {
flexbox: {
direction: "column",
justify: "flex-start",
align: "stretch",
wrap: "nowrap",
gap: 16,
},
},
zones_config: [
{
id: "filters",
name: "검색 필터",
position: {},
size: { width: "100%", height: "auto" },
},
{
id: "table",
name: "데이터 테이블",
position: {},
size: { width: "100%", height: "1fr" },
},
],
sort_order: 6,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
];
async function initializeLayoutStandards() {
try {
console.log("🏗️ 레이아웃 표준 데이터 초기화 시작...");
// 기존 데이터 확인
const existingLayouts = await prisma.layout_standards.count();
if (existingLayouts > 0) {
console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`);
console.log(
"기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)"
);
// 기존 데이터가 있으면 건너뛰기 (안전을 위해)
console.log("💡 기존 데이터를 유지하고 건너뜁니다.");
return;
}
// 데이터 삽입
let insertedCount = 0;
for (const layoutData of PREDEFINED_LAYOUTS) {
try {
await prisma.layout_standards.create({
data: {
...layoutData,
created_date: new Date(),
updated_date: new Date(),
created_by: "SYSTEM",
updated_by: "SYSTEM",
},
});
console.log(`${layoutData.layout_name} 생성 완료`);
insertedCount++;
} catch (error) {
console.error(`${layoutData.layout_name} 생성 실패:`, error.message);
}
}
console.log(
`🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})`
);
} catch (error) {
console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error);
throw error;
}
}
// 스크립트 실행
if (require.main === module) {
initializeLayoutStandards()
.then(() => {
console.log("✨ 스크립트 실행 완료");
process.exit(0);
})
.catch((error) => {
console.error("💥 스크립트 실행 실패:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
}
module.exports = { initializeLayoutStandards };

View File

@ -24,6 +24,7 @@ import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
import screenStandardRoutes from "./routes/screenStandardRoutes";
import templateStandardRoutes from "./routes/templateStandardRoutes";
import componentStandardRoutes from "./routes/componentStandardRoutes";
import layoutRoutes from "./routes/layoutRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@ -110,6 +111,7 @@ app.use("/api/admin/web-types", webTypeStandardRoutes);
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
app.use("/api/admin/template-standards", templateStandardRoutes);
app.use("/api/admin/component-standards", componentStandardRoutes);
app.use("/api/layouts", layoutRoutes);
app.use("/api/screen", screenStandardRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@ -0,0 +1,276 @@
import { Request, Response } from "express";
import { layoutService } from "../services/layoutService";
import {
CreateLayoutRequest,
UpdateLayoutRequest,
GetLayoutsRequest,
DuplicateLayoutRequest,
} from "../types/layout";
export class LayoutController {
/**
*
*/
async getLayouts(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const {
page = 1,
size = 20,
category,
layoutType,
searchTerm,
includePublic = true,
} = req.query as any;
const params = {
page: parseInt(page, 10),
size: parseInt(size, 10),
category,
layoutType,
searchTerm,
companyCode: user.companyCode,
includePublic: includePublic === "true",
};
const result = await layoutService.getLayouts(params);
const response = {
...result,
page: params.page,
size: params.size,
totalPages: Math.ceil(result.total / params.size),
};
res.json({
success: true,
data: response,
});
} catch (error) {
console.error("레이아웃 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
*/
async getLayoutById(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
const layout = await layoutService.getLayoutById(
layoutCode,
user.companyCode
);
if (!layout) {
res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
return;
}
res.json({
success: true,
data: layout,
});
} catch (error) {
console.error("레이아웃 상세 조회 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 상세 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
*/
async createLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const layoutRequest: CreateLayoutRequest = req.body;
// 요청 데이터 검증
if (
!layoutRequest.layoutName ||
!layoutRequest.layoutType ||
!layoutRequest.category
) {
res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (layoutName, layoutType, category)",
});
return;
}
if (!layoutRequest.layoutConfig || !layoutRequest.zonesConfig) {
res.status(400).json({
success: false,
message: "레이아웃 설정과 존 설정은 필수입니다.",
});
return;
}
const layout = await layoutService.createLayout(
layoutRequest,
user.companyCode,
user.userId
);
res.status(201).json({
success: true,
data: layout,
message: "레이아웃이 성공적으로 생성되었습니다.",
});
} catch (error) {
console.error("레이아웃 생성 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 생성에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
*/
async updateLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
const updateRequest: Partial<CreateLayoutRequest> = req.body;
const updatedLayout = await layoutService.updateLayout(
{ ...updateRequest, layoutCode },
user.companyCode,
user.userId
);
if (!updatedLayout) {
res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없거나 수정 권한이 없습니다.",
});
return;
}
res.json({
success: true,
data: updatedLayout,
message: "레이아웃이 성공적으로 수정되었습니다.",
});
} catch (error) {
console.error("레이아웃 수정 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 수정에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
*/
async deleteLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
await layoutService.deleteLayout(
layoutCode,
user.companyCode,
user.userId
);
res.json({
success: true,
message: "레이아웃이 성공적으로 삭제되었습니다.",
});
} catch (error) {
console.error("레이아웃 삭제 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 삭제에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
*/
async duplicateLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
const { newName }: DuplicateLayoutRequest = req.body;
if (!newName) {
res.status(400).json({
success: false,
message: "새 레이아웃 이름이 필요합니다.",
});
return;
}
const duplicatedLayout = await layoutService.duplicateLayout(
layoutCode,
newName,
user.companyCode,
user.userId
);
res.status(201).json({
success: true,
data: duplicatedLayout,
message: "레이아웃이 성공적으로 복제되었습니다.",
});
} catch (error) {
console.error("레이아웃 복제 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 복제에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
*/
async getLayoutCountsByCategory(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const counts = await layoutService.getLayoutCountsByCategory(
user.companyCode
);
res.json({
success: true,
data: counts,
});
} catch (error) {
console.error("카테고리별 레이아웃 개수 조회 오류:", error);
res.status(500).json({
success: false,
message: "카테고리별 레이아웃 개수 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
export const layoutController = new LayoutController();

View File

@ -0,0 +1,73 @@
import { Router } from "express";
import { layoutController } from "../controllers/layoutController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 레이아웃 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
* @route GET /api/layouts
* @desc
* @access Private
* @params page, size, category, layoutType, searchTerm, includePublic
*/
router.get("/", layoutController.getLayouts.bind(layoutController));
/**
* @route GET /api/layouts/counts-by-category
* @desc
* @access Private
*/
router.get(
"/counts-by-category",
layoutController.getLayoutCountsByCategory.bind(layoutController)
);
/**
* @route GET /api/layouts/:id
* @desc
* @access Private
* @params id (layoutCode)
*/
router.get("/:id", layoutController.getLayoutById.bind(layoutController));
/**
* @route POST /api/layouts
* @desc
* @access Private
* @body CreateLayoutRequest
*/
router.post("/", layoutController.createLayout.bind(layoutController));
/**
* @route PUT /api/layouts/:id
* @desc
* @access Private
* @params id (layoutCode)
* @body Partial<CreateLayoutRequest>
*/
router.put("/:id", layoutController.updateLayout.bind(layoutController));
/**
* @route DELETE /api/layouts/:id
* @desc
* @access Private
* @params id (layoutCode)
*/
router.delete("/:id", layoutController.deleteLayout.bind(layoutController));
/**
* @route POST /api/layouts/:id/duplicate
* @desc
* @access Private
* @params id (layoutCode)
* @body { newName: string }
*/
router.post(
"/:id/duplicate",
layoutController.duplicateLayout.bind(layoutController)
);
export default router;

View File

@ -0,0 +1,425 @@
import { PrismaClient } from "@prisma/client";
import {
CreateLayoutRequest,
UpdateLayoutRequest,
LayoutStandard,
LayoutType,
LayoutCategory,
} from "../types/layout";
const prisma = new PrismaClient();
// JSON 데이터를 안전하게 파싱하는 헬퍼 함수
function safeJSONParse(data: any): any {
if (data === null || data === undefined) {
return null;
}
// 이미 객체인 경우 그대로 반환
if (typeof data === "object") {
return data;
}
// 문자열인 경우 파싱 시도
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (error) {
console.error("JSON 파싱 오류:", error, "Data:", data);
return null;
}
}
return data;
}
// JSON 데이터를 안전하게 문자열화하는 헬퍼 함수
function safeJSONStringify(data: any): string | null {
if (data === null || data === undefined) {
return null;
}
// 이미 문자열인 경우 그대로 반환
if (typeof data === "string") {
return data;
}
// 객체인 경우 문자열로 변환
try {
return JSON.stringify(data);
} catch (error) {
console.error("JSON 문자열화 오류:", error, "Data:", data);
return null;
}
}
export class LayoutService {
/**
*
*/
async getLayouts(params: {
page?: number;
size?: number;
category?: string;
layoutType?: string;
searchTerm?: string;
companyCode: string;
includePublic?: boolean;
}): Promise<{ data: LayoutStandard[]; total: number }> {
const {
page = 1,
size = 20,
category,
layoutType,
searchTerm,
companyCode,
includePublic = true,
} = params;
const skip = (page - 1) * size;
// 검색 조건 구성
const where: any = {
is_active: "Y",
OR: [
{ company_code: companyCode },
...(includePublic ? [{ is_public: "Y" }] : []),
],
};
if (category) {
where.category = category;
}
if (layoutType) {
where.layout_type = layoutType;
}
if (searchTerm) {
where.OR = [
...where.OR,
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
{ layout_name_eng: { contains: searchTerm, mode: "insensitive" } },
{ description: { contains: searchTerm, mode: "insensitive" } },
];
}
const [data, total] = await Promise.all([
prisma.layout_standards.findMany({
where,
skip,
take: size,
orderBy: [{ sort_order: "asc" }, { created_date: "desc" }],
}),
prisma.layout_standards.count({ where }),
]);
return {
data: data.map(
(layout) =>
({
layoutCode: layout.layout_code,
layoutName: layout.layout_name,
layoutNameEng: layout.layout_name_eng,
description: layout.description,
layoutType: layout.layout_type as LayoutType,
category: layout.category as LayoutCategory,
iconName: layout.icon_name,
defaultSize: safeJSONParse(layout.default_size),
layoutConfig: safeJSONParse(layout.layout_config),
zonesConfig: safeJSONParse(layout.zones_config),
previewImage: layout.preview_image,
sortOrder: layout.sort_order,
isActive: layout.is_active,
isPublic: layout.is_public,
companyCode: layout.company_code,
createdDate: layout.created_date,
createdBy: layout.created_by,
updatedDate: layout.updated_date,
updatedBy: layout.updated_by,
}) as LayoutStandard
),
total,
};
}
/**
*
*/
async getLayoutById(
layoutCode: string,
companyCode: string
): Promise<LayoutStandard | null> {
const layout = await prisma.layout_standards.findFirst({
where: {
layout_code: layoutCode,
is_active: "Y",
OR: [{ company_code: companyCode }, { is_public: "Y" }],
},
});
if (!layout) return null;
return {
layoutCode: layout.layout_code,
layoutName: layout.layout_name,
layoutNameEng: layout.layout_name_eng,
description: layout.description,
layoutType: layout.layout_type as LayoutType,
category: layout.category as LayoutCategory,
iconName: layout.icon_name,
defaultSize: safeJSONParse(layout.default_size),
layoutConfig: safeJSONParse(layout.layout_config),
zonesConfig: safeJSONParse(layout.zones_config),
previewImage: layout.preview_image,
sortOrder: layout.sort_order,
isActive: layout.is_active,
isPublic: layout.is_public,
companyCode: layout.company_code,
createdDate: layout.created_date,
createdBy: layout.created_by,
updatedDate: layout.updated_date,
updatedBy: layout.updated_by,
} as LayoutStandard;
}
/**
*
*/
async createLayout(
request: CreateLayoutRequest,
companyCode: string,
userId: string
): Promise<LayoutStandard> {
// 레이아웃 코드 생성 (자동)
const layoutCode = await this.generateLayoutCode(
request.layoutType,
companyCode
);
const layout = await prisma.layout_standards.create({
data: {
layout_code: layoutCode,
layout_name: request.layoutName,
layout_name_eng: request.layoutNameEng,
description: request.description,
layout_type: request.layoutType,
category: request.category,
icon_name: request.iconName,
default_size: safeJSONStringify(request.defaultSize) as any,
layout_config: safeJSONStringify(request.layoutConfig) as any,
zones_config: safeJSONStringify(request.zonesConfig) as any,
is_public: request.isPublic ? "Y" : "N",
company_code: companyCode,
created_by: userId,
updated_by: userId,
},
});
return this.mapToLayoutStandard(layout);
}
/**
*
*/
async updateLayout(
request: UpdateLayoutRequest,
companyCode: string,
userId: string
): Promise<LayoutStandard | null> {
// 수정 권한 확인
const existing = await prisma.layout_standards.findFirst({
where: {
layout_code: request.layoutCode,
company_code: companyCode,
is_active: "Y",
},
});
if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다.");
}
const updateData: any = {
updated_by: userId,
updated_date: new Date(),
};
// 수정할 필드만 업데이트
if (request.layoutName !== undefined)
updateData.layout_name = request.layoutName;
if (request.layoutNameEng !== undefined)
updateData.layout_name_eng = request.layoutNameEng;
if (request.description !== undefined)
updateData.description = request.description;
if (request.layoutType !== undefined)
updateData.layout_type = request.layoutType;
if (request.category !== undefined) updateData.category = request.category;
if (request.iconName !== undefined) updateData.icon_name = request.iconName;
if (request.defaultSize !== undefined)
updateData.default_size = safeJSONStringify(request.defaultSize) as any;
if (request.layoutConfig !== undefined)
updateData.layout_config = safeJSONStringify(request.layoutConfig) as any;
if (request.zonesConfig !== undefined)
updateData.zones_config = safeJSONStringify(request.zonesConfig) as any;
if (request.isPublic !== undefined)
updateData.is_public = request.isPublic ? "Y" : "N";
const updated = await prisma.layout_standards.update({
where: { layout_code: request.layoutCode },
data: updateData,
});
return this.mapToLayoutStandard(updated);
}
/**
* ( )
*/
async deleteLayout(
layoutCode: string,
companyCode: string,
userId: string
): Promise<boolean> {
const existing = await prisma.layout_standards.findFirst({
where: {
layout_code: layoutCode,
company_code: companyCode,
is_active: "Y",
},
});
if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다.");
}
await prisma.layout_standards.update({
where: { layout_code: layoutCode },
data: {
is_active: "N",
updated_by: userId,
updated_date: new Date(),
},
});
return true;
}
/**
*
*/
async duplicateLayout(
layoutCode: string,
newName: string,
companyCode: string,
userId: string
): Promise<LayoutStandard> {
const original = await this.getLayoutById(layoutCode, companyCode);
if (!original) {
throw new Error("복제할 레이아웃을 찾을 수 없습니다.");
}
const duplicateRequest: CreateLayoutRequest = {
layoutName: newName,
layoutNameEng: original.layoutNameEng
? `${original.layoutNameEng} Copy`
: undefined,
description: original.description,
layoutType: original.layoutType,
category: original.category,
iconName: original.iconName,
defaultSize: original.defaultSize,
layoutConfig: original.layoutConfig,
zonesConfig: original.zonesConfig,
isPublic: false, // 복제본은 비공개로 시작
};
return this.createLayout(duplicateRequest, companyCode, userId);
}
/**
*
*/
async getLayoutCountsByCategory(
companyCode: string
): Promise<Record<string, number>> {
const counts = await prisma.layout_standards.groupBy({
by: ["category"],
_count: {
layout_code: true,
},
where: {
is_active: "Y",
OR: [{ company_code: companyCode }, { is_public: "Y" }],
},
});
return counts.reduce(
(acc: Record<string, number>, item: any) => {
acc[item.category] = item._count.layout_code;
return acc;
},
{} as Record<string, number>
);
}
/**
*
*/
private async generateLayoutCode(
layoutType: string,
companyCode: string
): Promise<string> {
const prefix = `${layoutType.toUpperCase()}_${companyCode}`;
const existingCodes = await prisma.layout_standards.findMany({
where: {
layout_code: {
startsWith: prefix,
},
},
select: {
layout_code: true,
},
});
const maxNumber = existingCodes.reduce((max: number, item: any) => {
const match = item.layout_code.match(/_(\d+)$/);
if (match) {
const number = parseInt(match[1], 10);
return Math.max(max, number);
}
return max;
}, 0);
return `${prefix}_${String(maxNumber + 1).padStart(3, "0")}`;
}
/**
* LayoutStandard
*/
private mapToLayoutStandard(layout: any): LayoutStandard {
return {
layoutCode: layout.layout_code,
layoutName: layout.layout_name,
layoutNameEng: layout.layout_name_eng,
description: layout.description,
layoutType: layout.layout_type,
category: layout.category,
iconName: layout.icon_name,
defaultSize: layout.default_size,
layoutConfig: layout.layout_config,
zonesConfig: layout.zones_config,
previewImage: layout.preview_image,
sortOrder: layout.sort_order,
isActive: layout.is_active,
isPublic: layout.is_public,
companyCode: layout.company_code,
createdDate: layout.created_date,
createdBy: layout.created_by,
updatedDate: layout.updated_date,
updatedBy: layout.updated_by,
};
}
}
export const layoutService = new LayoutService();

View File

@ -0,0 +1,198 @@
// 레이아웃 관련 타입 정의
// 레이아웃 타입
export type LayoutType =
| "grid"
| "flexbox"
| "split"
| "card"
| "tabs"
| "accordion"
| "sidebar"
| "header-footer"
| "three-column"
| "dashboard"
| "form"
| "table"
| "custom";
// 레이아웃 카테고리
export type LayoutCategory =
| "basic"
| "form"
| "table"
| "dashboard"
| "navigation"
| "content"
| "business";
// 레이아웃 존 정의
export interface LayoutZone {
id: string;
name: string;
position: {
row?: number;
column?: number;
x?: number;
y?: number;
};
size: {
width: number | string;
height: number | string;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
};
style?: Record<string, any>;
allowedComponents?: string[];
isResizable?: boolean;
isRequired?: boolean;
}
// 레이아웃 설정
export interface LayoutConfig {
grid?: {
rows: number;
columns: number;
gap: number;
rowGap?: number;
columnGap?: number;
autoRows?: string;
autoColumns?: string;
};
flexbox?: {
direction: "row" | "column" | "row-reverse" | "column-reverse";
justify:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly";
align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
wrap: "nowrap" | "wrap" | "wrap-reverse";
gap: number;
};
split?: {
direction: "horizontal" | "vertical";
ratio: number[];
minSize: number[];
resizable: boolean;
splitterSize: number;
};
tabs?: {
position: "top" | "bottom" | "left" | "right";
variant: "default" | "pills" | "underline";
size: "sm" | "md" | "lg";
defaultTab: string;
closable: boolean;
};
accordion?: {
multiple: boolean;
defaultExpanded: string[];
collapsible: boolean;
};
sidebar?: {
position: "left" | "right";
width: number | string;
collapsible: boolean;
collapsed: boolean;
overlay: boolean;
};
headerFooter?: {
headerHeight: number | string;
footerHeight: number | string;
stickyHeader: boolean;
stickyFooter: boolean;
};
dashboard?: {
columns: number;
rowHeight: number;
margin: [number, number];
padding: [number, number];
isDraggable: boolean;
isResizable: boolean;
};
custom?: {
cssProperties: Record<string, string>;
className: string;
template: string;
};
}
// 레이아웃 표준 정의
export interface LayoutStandard {
layoutCode: string;
layoutName: string;
layoutNameEng?: string;
description?: string;
layoutType: LayoutType;
category: LayoutCategory;
iconName?: string;
defaultSize?: { width: number; height: number };
layoutConfig: LayoutConfig;
zonesConfig: LayoutZone[];
previewImage?: string;
sortOrder?: number;
isActive?: string;
isPublic?: string;
companyCode: string;
createdDate?: Date;
createdBy?: string;
updatedDate?: Date;
updatedBy?: string;
}
// 레이아웃 생성 요청
export interface CreateLayoutRequest {
layoutName: string;
layoutNameEng?: string;
description?: string;
layoutType: LayoutType;
category: LayoutCategory;
iconName?: string;
defaultSize?: { width: number; height: number };
layoutConfig: LayoutConfig;
zonesConfig: LayoutZone[];
isPublic?: boolean;
}
// 레이아웃 수정 요청
export interface UpdateLayoutRequest extends Partial<CreateLayoutRequest> {
layoutCode: string;
}
// 레이아웃 목록 조회 요청
export interface GetLayoutsRequest {
page?: number;
size?: number;
category?: LayoutCategory;
layoutType?: LayoutType;
searchTerm?: string;
includePublic?: boolean;
}
// 레이아웃 목록 응답
export interface GetLayoutsResponse {
data: LayoutStandard[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 레이아웃 복제 요청
export interface DuplicateLayoutRequest {
layoutCode: string;
newName: string;
}

View File

@ -0,0 +1,416 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LayoutFormModal } from "@/components/admin/LayoutFormModal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Plus,
Search,
MoreHorizontal,
Edit,
Copy,
Trash2,
Eye,
Grid,
Layout,
LayoutDashboard,
Table,
Navigation,
FileText,
Building,
} from "lucide-react";
import { LayoutStandard, LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout";
import { layoutApi } from "@/lib/api/layout";
import { toast } from "sonner";
// 코드 레벨 레이아웃 타입
interface CodeLayout {
id: string;
name: string;
nameEng?: string;
description?: string;
category: string;
type: "code";
isActive: boolean;
tags: string[];
metadata?: any;
zones: number;
}
// 카테고리 아이콘 매핑
const CATEGORY_ICONS = {
basic: Grid,
form: FileText,
table: Table,
dashboard: LayoutDashboard,
navigation: Navigation,
content: Layout,
business: Building,
};
// 카테고리 이름 매핑
const CATEGORY_NAMES = {
basic: "기본",
form: "폼",
table: "테이블",
dashboard: "대시보드",
navigation: "네비게이션",
content: "컨텐츠",
business: "업무용",
};
export default function LayoutManagementPage() {
const [layouts, setLayouts] = useState<LayoutStandard[]>([]);
const [codeLayouts, setCodeLayouts] = useState<CodeLayout[]>([]);
const [loading, setLoading] = useState(true);
const [codeLoading, setCodeLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [activeTab, setActiveTab] = useState("db");
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [layoutToDelete, setLayoutToDelete] = useState<LayoutStandard | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
// 카테고리별 개수
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
// 레이아웃 목록 로드
const loadLayouts = async () => {
try {
setLoading(true);
const params = {
page: currentPage,
size: 20,
searchTerm: searchTerm || undefined,
category: selectedCategory !== "all" ? (selectedCategory as LayoutCategory) : undefined,
};
const response = await layoutApi.getLayouts(params);
setLayouts(response.data);
setTotalPages(response.totalPages);
setTotal(response.total);
} catch (error) {
console.error("레이아웃 목록 조회 실패:", error);
toast.error("레이아웃 목록을 불러오는데 실패했습니다.");
setLayouts([]);
} finally {
setLoading(false);
}
};
// 카테고리별 개수 로드
const loadCategoryCounts = async () => {
try {
const counts = await layoutApi.getLayoutCountsByCategory();
setCategoryCounts(counts);
} catch (error) {
console.error("카테고리 개수 조회 실패:", error);
}
};
// 코드 레벨 레이아웃 로드
const loadCodeLayouts = async () => {
try {
setCodeLoading(true);
const response = await fetch("/api/admin/layouts/list");
const result = await response.json();
if (result.success) {
setCodeLayouts(result.data.codeLayouts);
} else {
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
setCodeLayouts([]);
}
} catch (error) {
console.error("코드 레이아웃 조회 실패:", error);
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
setCodeLayouts([]);
} finally {
setCodeLoading(false);
}
};
useEffect(() => {
loadLayouts();
}, [currentPage, selectedCategory]);
useEffect(() => {
loadCategoryCounts();
loadCodeLayouts();
}, []);
// 검색
const handleSearch = () => {
setCurrentPage(1);
loadLayouts();
};
// 엔터키 검색
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
// 레이아웃 삭제
const handleDelete = async (layout: LayoutStandard) => {
setLayoutToDelete(layout);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!layoutToDelete) return;
try {
await layoutApi.deleteLayout(layoutToDelete.layoutCode);
toast.success("레이아웃이 삭제되었습니다.");
loadLayouts();
loadCategoryCounts();
} catch (error) {
console.error("레이아웃 삭제 실패:", error);
toast.error("레이아웃 삭제에 실패했습니다.");
} finally {
setDeleteDialogOpen(false);
setLayoutToDelete(null);
}
};
// 레이아웃 복제
const handleDuplicate = async (layout: LayoutStandard) => {
try {
const newName = `${layout.layoutName} (복사)`;
await layoutApi.duplicateLayout(layout.layoutCode, { newName });
toast.success("레이아웃이 복제되었습니다.");
loadLayouts();
loadCategoryCounts();
} catch (error) {
console.error("레이아웃 복제 실패:", error);
toast.error("레이아웃 복제에 실패했습니다.");
}
};
// 페이지네이션
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<div className="container mx-auto p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-gray-600"> .</p>
</div>
<Button className="flex items-center gap-2" onClick={() => setCreateModalOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 검색 및 필터 */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="레이아웃 이름 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={handleKeyPress}
className="pl-10"
/>
</div>
</div>
<Button onClick={handleSearch}></Button>
</div>
</CardContent>
</Card>
{/* 카테고리 탭 */}
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="mb-6">
<TabsList className="grid w-full grid-cols-8">
<TabsTrigger value="all" className="flex items-center gap-2">
({total})
</TabsTrigger>
{Object.entries(LAYOUT_CATEGORIES).map(([key, value]) => {
const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS];
const count = categoryCounts[value] || 0;
return (
<TabsTrigger key={key} value={value} className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{CATEGORY_NAMES[value as keyof typeof CATEGORY_NAMES]} ({count})
</TabsTrigger>
);
})}
</TabsList>
<div className="mt-6">
{loading ? (
<div className="py-8 text-center"> ...</div>
) : layouts.length === 0 ? (
<div className="py-8 text-center text-gray-500"> .</div>
) : (
<>
{/* 레이아웃 그리드 */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{layouts.map((layout) => {
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
return (
<Card key={layout.layoutCode} className="transition-shadow hover:shadow-lg">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<CategoryIcon className="h-5 w-5 text-gray-600" />
<Badge variant="secondary">
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
</Badge>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicate(layout)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardTitle className="text-lg">{layout.layoutName}</CardTitle>
{layout.description && (
<p className="line-clamp-2 text-sm text-gray-600">{layout.description}</p>
)}
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">:</span>
<Badge variant="outline">{layout.layoutType}</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500"> :</span>
<span>{layout.zonesConfig.length}</span>
</div>
{layout.isPublic === "Y" && (
<Badge variant="default" className="w-full justify-center">
</Badge>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="mt-8 flex justify-center">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</>
)}
</div>
</Tabs>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{layoutToDelete?.layoutName}" ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 새 레이아웃 생성 모달 */}
<LayoutFormModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSuccess={() => {
loadLayouts();
loadCategoryCounts();
}}
/>
</div>
);
}

View File

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs";
const execAsync = promisify(exec);
export async function POST(request: NextRequest) {
try {
const { command, layoutData } = await request.json();
if (!command || !layoutData) {
return NextResponse.json({ success: false, message: "명령어와 레이아웃 데이터가 필요합니다." }, { status: 400 });
}
// 프론트엔드 디렉토리 경로
const frontendDir = path.join(process.cwd());
// CLI 명령어 실행
const fullCommand = `cd ${frontendDir} && node scripts/create-layout.js ${command}`;
console.log("실행할 명령어:", fullCommand);
const { stdout, stderr } = await execAsync(fullCommand);
if (stderr && !stderr.includes("warning")) {
console.error("CLI 실행 오류:", stderr);
return NextResponse.json(
{
success: false,
message: "레이아웃 생성 중 오류가 발생했습니다.",
error: stderr,
},
{ status: 500 },
);
}
// 생성된 파일들 확인
const layoutId = layoutData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
const layoutDir = path.join(frontendDir, "lib/registry/layouts", layoutId);
const generatedFiles: string[] = [];
if (fs.existsSync(layoutDir)) {
const files = fs.readdirSync(layoutDir);
files.forEach((file) => {
generatedFiles.push(`layouts/${layoutId}/${file}`);
});
}
// 자동 등록을 위해 index.ts 업데이트
await updateLayoutIndex(layoutId);
return NextResponse.json({
success: true,
message: "레이아웃이 성공적으로 생성되었습니다.",
files: generatedFiles,
output: stdout,
});
} catch (error) {
console.error("레이아웃 생성 API 오류:", error);
return NextResponse.json(
{
success: false,
message: "서버 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 },
);
}
}
/**
* layouts/index.ts에 import
*/
async function updateLayoutIndex(layoutId: string) {
try {
const indexPath = path.join(process.cwd(), "lib/registry/layouts/index.ts");
if (!fs.existsSync(indexPath)) {
console.warn("layouts/index.ts 파일을 찾을 수 없습니다.");
return;
}
let content = fs.readFileSync(indexPath, "utf8");
// 새 import 문 추가
const newImport = `import "./${layoutId}/${layoutId.charAt(0).toUpperCase() + layoutId.slice(1)}LayoutRenderer";`;
// 이미 import되어 있는지 확인
if (content.includes(newImport)) {
console.log("이미 import되어 있습니다.");
return;
}
// 다른 import 문들 찾기
const importRegex = /import "\.\/.+\/\w+LayoutRenderer";/g;
const imports = content.match(importRegex) || [];
if (imports.length > 0) {
// 마지막 import 뒤에 추가
const lastImport = imports[imports.length - 1];
const lastImportIndex = content.lastIndexOf(lastImport);
const insertPosition = lastImportIndex + lastImport.length;
content = content.slice(0, insertPosition) + "\n" + newImport + content.slice(insertPosition);
} else {
// import가 없다면 파일 시작 부분에 추가
const newStructureComment = "// 새 구조 레이아웃들 (자동 등록)";
const commentIndex = content.indexOf(newStructureComment);
if (commentIndex !== -1) {
const insertPosition = content.indexOf("\n", commentIndex) + 1;
content = content.slice(0, insertPosition) + newImport + "\n" + content.slice(insertPosition);
}
}
fs.writeFileSync(indexPath, content);
console.log(`layouts/index.ts에 ${layoutId} import 추가 완료`);
} catch (error) {
console.error("index.ts 업데이트 오류:", error);
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { LayoutRegistry } from "@/lib/registry/LayoutRegistry";
/**
* ( + DB)
*/
export async function GET(request: NextRequest) {
try {
// 코드 레벨에서 등록된 레이아웃들
const codeLayouts = LayoutRegistry.getAllLayouts().map((layout) => ({
id: layout.id,
name: layout.name,
nameEng: layout.nameEng,
description: layout.description,
category: layout.category,
type: "code", // 코드로 생성된 레이아웃
isActive: layout.isActive !== false,
tags: layout.tags || [],
metadata: layout.metadata,
zones: layout.defaultZones?.length || 0,
}));
// 레지스트리 통계
const registryInfo = LayoutRegistry.getRegistryInfo();
return NextResponse.json({
success: true,
data: {
codeLayouts,
statistics: {
total: registryInfo.totalLayouts,
active: registryInfo.activeLayouts,
categories: registryInfo.categoryCounts,
types: registryInfo.registeredTypes,
},
summary: {
codeLayoutCount: codeLayouts.length,
activeCodeLayouts: codeLayouts.filter((l) => l.isActive).length,
},
},
});
} catch (error) {
console.error("레이아웃 목록 조회 오류:", error);
return NextResponse.json(
{
success: false,
message: "레이아웃 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,534 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Grid,
Layout,
Navigation,
Building,
FileText,
Table,
LayoutDashboard,
Plus,
Minus,
Info,
Wand2,
} from "lucide-react";
import { LayoutCategory } from "@/types/layout";
import { toast } from "sonner";
interface LayoutFormModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
// 카테고리 정의
const CATEGORIES = [
{ id: "basic", name: "기본", icon: Grid, description: "그리드, 플렉스박스 등 기본 레이아웃" },
{ id: "navigation", name: "네비게이션", icon: Navigation, description: "메뉴, 탭, 아코디언 등" },
{ id: "business", name: "비즈니스", icon: Building, description: "대시보드, 차트, 리포트 등" },
{ id: "form", name: "폼", icon: FileText, description: "입력 폼, 설정 패널 등" },
{ id: "table", name: "테이블", icon: Table, description: "데이터 테이블, 목록 등" },
{ id: "dashboard", name: "대시보드", icon: LayoutDashboard, description: "위젯, 카드 레이아웃 등" },
] as const;
// 레이아웃 템플릿 정의
const LAYOUT_TEMPLATES = [
{
id: "2-column",
name: "2열 레이아웃",
description: "좌우 2개 영역으로 구성",
zones: 2,
example: "사이드바 + 메인 콘텐츠",
icon: "▢ ▢",
},
{
id: "3-column",
name: "3열 레이아웃",
description: "좌측, 중앙, 우측 3개 영역",
zones: 3,
example: "네비 + 콘텐츠 + 사이드",
icon: "▢ ▢ ▢",
},
{
id: "header-content",
name: "헤더-콘텐츠",
description: "상단 헤더 + 하단 콘텐츠",
zones: 2,
example: "제목 + 내용 영역",
icon: "▬\n▢",
},
{
id: "card-grid",
name: "카드 그리드",
description: "2x2 카드 격자 구조",
zones: 4,
example: "대시보드, 통계 패널",
icon: "▢▢\n▢▢",
},
{
id: "accordion",
name: "아코디언",
description: "접고 펼칠 수 있는 섹션들",
zones: 3,
example: "FAQ, 설정 패널",
icon: "▷ ▽ ▷",
},
{
id: "tabs",
name: "탭 레이아웃",
description: "탭으로 구성된 다중 패널",
zones: 3,
example: "설정, 상세 정보",
icon: "[Tab1][Tab2][Tab3]",
},
];
export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenChange, onSuccess }) => {
const [step, setStep] = useState<"basic" | "template" | "advanced">("basic");
const [formData, setFormData] = useState({
name: "",
nameEng: "",
description: "",
category: "" as LayoutCategory | "",
zones: 2,
template: "",
author: "Developer",
});
const [isGenerating, setIsGenerating] = useState(false);
const [generationResult, setGenerationResult] = useState<{
success: boolean;
message: string;
files?: string[];
} | null>(null);
const handleReset = () => {
setStep("basic");
setFormData({
name: "",
nameEng: "",
description: "",
category: "",
zones: 2,
template: "",
author: "Developer",
});
setGenerationResult(null);
};
const handleClose = () => {
handleReset();
onOpenChange(false);
};
const handleNext = () => {
if (step === "basic") {
setStep("template");
} else if (step === "template") {
setStep("advanced");
}
};
const handleBack = () => {
if (step === "template") {
setStep("basic");
} else if (step === "advanced") {
setStep("template");
}
};
const validateBasic = () => {
return formData.name.trim() && formData.category && formData.description.trim();
};
const validateTemplate = () => {
return formData.template && formData.zones > 0;
};
const generateLayout = async () => {
try {
setIsGenerating(true);
// CLI 명령어 구성
const command = [
formData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
`--category=${formData.category}`,
`--zones=${formData.zones}`,
`--description="${formData.description}"`,
formData.author !== "Developer" ? `--author="${formData.author}"` : null,
]
.filter(Boolean)
.join(" ");
// API 호출로 CLI 명령어 실행
const response = await fetch("/api/admin/layouts/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
command,
layoutData: formData,
}),
});
const result = await response.json();
if (result.success) {
setGenerationResult({
success: true,
message: "레이아웃이 성공적으로 생성되었습니다!",
files: result.files || [],
});
toast.success("레이아웃 생성 완료");
// 3초 후 자동으로 모달 닫고 새로고침
setTimeout(() => {
handleClose();
onSuccess();
}, 3000);
} else {
setGenerationResult({
success: false,
message: result.message || "레이아웃 생성에 실패했습니다.",
});
toast.error("레이아웃 생성 실패");
}
} catch (error) {
console.error("레이아웃 생성 오류:", error);
setGenerationResult({
success: false,
message: "서버 오류가 발생했습니다.",
});
toast.error("서버 오류");
} finally {
setIsGenerating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5" />
</DialogTitle>
<DialogDescription>GUI를 .</DialogDescription>
</DialogHeader>
{/* 단계 표시기 */}
<div className="mb-6 flex items-center justify-center">
<div className="flex items-center gap-4">
<div
className={`flex items-center gap-2 ${step === "basic" ? "text-blue-600" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-blue-100 text-blue-600" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
>
1
</div>
<span className="text-sm font-medium"> </span>
</div>
<div className="h-px w-8 bg-gray-300" />
<div
className={`flex items-center gap-2 ${step === "template" ? "text-blue-600" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-blue-100 text-blue-600" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
>
2
</div>
<span className="text-sm font-medium">릿 </span>
</div>
<div className="h-px w-8 bg-gray-300" />
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-blue-600" : "text-gray-400"}`}>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-blue-100 text-blue-600" : "bg-gray-100"}`}
>
3
</div>
<span className="text-sm font-medium"> </span>
</div>
</div>
</div>
{/* 단계별 컨텐츠 */}
<div className="space-y-6">
{step === "basic" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
placeholder="예: 사이드바, 대시보드, 카드그리드"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEng"> </Label>
<Input
id="nameEng"
placeholder="예: Sidebar, Dashboard, CardGrid"
value={formData.nameEng}
onChange={(e) => setFormData((prev) => ({ ...prev, nameEng: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label> *</Label>
<div className="grid grid-cols-2 gap-3">
{CATEGORIES.map((category) => {
const IconComponent = category.icon;
return (
<Card
key={category.id}
className={`cursor-pointer transition-all ${
formData.category === category.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
}`}
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<IconComponent className="h-5 w-5 text-gray-600" />
<div>
<div className="font-medium">{category.name}</div>
<div className="text-xs text-gray-500">{category.description}</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"> *</Label>
<Textarea
id="description"
placeholder="레이아웃의 용도와 특징을 설명해주세요..."
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
</div>
)}
{step === "template" && (
<div className="space-y-4">
<div>
<Label> 릿 *</Label>
<p className="mb-3 text-sm text-gray-500"> </p>
<div className="grid grid-cols-2 gap-3">
{LAYOUT_TEMPLATES.map((template) => (
<Card
key={template.id}
className={`cursor-pointer transition-all ${
formData.template === template.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
}`}
onClick={() =>
setFormData((prev) => ({
...prev,
template: template.id,
zones: template.zones,
}))
}
>
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="font-medium">{template.name}</div>
<Badge variant="secondary">{template.zones} </Badge>
</div>
<div className="text-sm text-gray-600">{template.description}</div>
<div className="text-xs text-gray-500">: {template.example}</div>
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="zones"> </Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setFormData((prev) => ({
...prev,
zones: Math.max(1, prev.zones - 1),
}))
}
disabled={formData.zones <= 1}
>
<Minus className="h-4 w-4" />
</Button>
<Input
id="zones"
type="number"
min="1"
max="10"
value={formData.zones}
onChange={(e) =>
setFormData((prev) => ({
...prev,
zones: parseInt(e.target.value) || 1,
}))
}
className="w-20 text-center"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setFormData((prev) => ({
...prev,
zones: Math.min(10, prev.zones + 1),
}))
}
disabled={formData.zones >= 10}
>
<Plus className="h-4 w-4" />
</Button>
<span className="text-sm text-gray-500"> </span>
</div>
</div>
</div>
)}
{step === "advanced" && (
<div className="space-y-4">
{generationResult ? (
<Alert
className={generationResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}
>
<Info className="h-4 w-4" />
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
{generationResult.message}
{generationResult.success && generationResult.files && (
<div className="mt-2">
<div className="text-sm font-medium"> :</div>
<ul className="mt-1 space-y-1 text-xs">
{generationResult.files.map((file, index) => (
<li key={index} className="text-green-700">
{file}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
) : (
<>
<div className="space-y-2">
<Label htmlFor="author"></Label>
<Input
id="author"
value={formData.author}
onChange={(e) => setFormData((prev) => ({ ...prev, author: e.target.value }))}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<strong>:</strong> {formData.name || "이름 없음"}
</div>
<div>
<strong>:</strong>{" "}
{CATEGORIES.find((c) => c.id === formData.category)?.name || "선택 안됨"}
</div>
<div>
<strong>릿:</strong>{" "}
{LAYOUT_TEMPLATES.find((t) => t.id === formData.template)?.name || "선택 안됨"}
</div>
<div>
<strong> :</strong> {formData.zones}
</div>
<div>
<strong> :</strong>
</div>
<ul className="ml-4 space-y-1 text-xs text-gray-600">
<li> {formData.name.toLowerCase()}/index.ts</li>
<li>
{formData.name.toLowerCase()}/{formData.name}Layout.tsx
</li>
<li>
{formData.name.toLowerCase()}/{formData.name}LayoutRenderer.tsx
</li>
<li> {formData.name.toLowerCase()}/config.ts</li>
<li> {formData.name.toLowerCase()}/types.ts</li>
<li> {formData.name.toLowerCase()}/README.md</li>
</ul>
</CardContent>
</Card>
</>
)}
</div>
)}
</div>
<DialogFooter className="gap-2">
{step !== "basic" && !generationResult && (
<Button variant="outline" onClick={handleBack}>
</Button>
)}
{step === "basic" && (
<Button onClick={handleNext} disabled={!validateBasic()}>
</Button>
)}
{step === "template" && (
<Button onClick={handleNext} disabled={!validateTemplate()}>
</Button>
)}
{step === "advanced" && !generationResult && (
<Button onClick={generateLayout} disabled={isGenerating}>
{isGenerating ? "생성 중..." : "레이아웃 생성"}
</Button>
)}
<Button variant="outline" onClick={handleClose}>
{generationResult?.success ? "완료" : "취소"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -17,6 +17,7 @@ import {
Cog,
Layout,
Monitor,
Square,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -33,6 +34,8 @@ interface DesignerToolbarProps {
canUndo: boolean;
canRedo: boolean;
isSaving?: boolean;
showZoneBorders?: boolean;
onToggleZoneBorders?: () => void;
}
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
@ -48,6 +51,8 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
canUndo,
canRedo,
isSaving = false,
showZoneBorders = true,
onToggleZoneBorders,
}) => {
return (
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
@ -154,6 +159,23 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
</Badge>
</Button>
{/* 구역 경계 표시 토글 버튼 */}
{onToggleZoneBorders && (
<Button
variant={showZoneBorders ? "default" : "outline"}
size="sm"
onClick={onToggleZoneBorders}
className={cn("flex items-center space-x-2", showZoneBorders && "bg-green-600 text-white")}
title="구역 경계 표시/숨김"
>
<Square className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
Z
</Badge>
</Button>
)}
<Button
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
size="sm"

View File

@ -46,12 +46,16 @@ import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import ComponentsPanel from "./panels/ComponentsPanel";
import LayoutsPanel from "./panels/LayoutsPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
// 레이아웃 초기화
import "@/lib/registry/layouts";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
@ -75,6 +79,14 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 700,
shortcutKey: "m", // template의 m
},
{
id: "layouts",
title: "레이아웃",
defaultPosition: "left",
defaultWidth: 380,
defaultHeight: 700,
shortcutKey: "l", // layout의 l
},
{
id: "properties",
title: "속성 편집",
@ -1212,6 +1224,74 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
);
// 레이아웃 드래그 처리
const handleLayoutDrop = useCallback(
(e: React.DragEvent, layoutData: any) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🏗️ 레이아웃 드롭:", {
layoutType: layoutData.layoutType,
zonesCount: layoutData.zones.length,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 레이아웃 컴포넌트 생성
const newLayoutComponent: ComponentData = {
id: layoutData.id,
type: "layout",
layoutType: layoutData.layoutType,
layoutConfig: layoutData.layoutConfig,
zones: layoutData.zones.map((zone: any) => ({
...zone,
id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가
})),
children: [],
position: snappedPosition,
size: layoutData.size,
label: layoutData.label,
allowedComponentTypes: layoutData.allowedComponentTypes,
dropZoneConfig: layoutData.dropZoneConfig,
} as ComponentData;
// 레이아웃에 새 컴포넌트 추가
const newLayout = {
...layout,
components: [...layout.components, newLayoutComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 레이아웃 컴포넌트 선택
setSelectedComponent(newLayoutComponent);
openPanel("properties");
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
);
// 컴포넌트 드래그 처리
const handleComponentDrop = useCallback(
(e: React.DragEvent, component: any) => {
@ -1357,6 +1437,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return;
}
// 레이아웃 드래그인 경우
if (parsedData.type === "layout") {
handleLayoutDrop(e, parsedData.layout);
return;
}
// 컴포넌트 드래그인 경우
if (parsedData.type === "component") {
handleComponentDrop(e, parsedData.component);
@ -3129,6 +3215,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
/>
</FloatingPanel>
<FloatingPanel
id="layouts"
title="레이아웃"
isOpen={panelStates.layouts?.isOpen || false}
onClose={() => closePanelState("layouts")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<LayoutsPanel
onDragStart={(e, layoutData) => {
const dragData = {
type: "layout",
layout: layoutData,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel>
<FloatingPanel
id="components"
title="컴포넌트"

View File

@ -0,0 +1,195 @@
"use client";
import React, { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Grid, Layout, LayoutDashboard, Table, Navigation, FileText, Building, Search, Plus } from "lucide-react";
import { LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout";
import { LayoutRegistry } from "@/lib/registry/LayoutRegistry";
// 카테고리 아이콘 매핑
const CATEGORY_ICONS = {
basic: Grid,
form: FileText,
table: Table,
dashboard: LayoutDashboard,
navigation: Navigation,
content: Layout,
business: Building,
};
// 카테고리 이름 매핑
const CATEGORY_NAMES = {
basic: "기본",
form: "폼",
table: "테이블",
dashboard: "대시보드",
navigation: "네비게이션",
content: "컨텐츠",
business: "업무용",
};
interface LayoutsPanelProps {
onDragStart: (e: React.DragEvent, layoutData: any) => void;
onLayoutSelect?: (layoutDefinition: any) => void;
className?: string;
}
export default function LayoutsPanel({ onDragStart, onLayoutSelect, className }: LayoutsPanelProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
// 레지스트리에서 레이아웃 조회
const allLayouts = useMemo(() => LayoutRegistry.getAllLayouts(), []);
// 필터링된 레이아웃
const filteredLayouts = useMemo(() => {
let layouts = allLayouts;
// 카테고리 필터
if (selectedCategory !== "all") {
layouts = layouts.filter((layout) => layout.category === selectedCategory);
}
// 검색 필터
if (searchTerm) {
layouts = layouts.filter(
(layout) =>
layout.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
layout.nameEng?.toLowerCase().includes(searchTerm.toLowerCase()) ||
layout.description?.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
return layouts;
}, [allLayouts, selectedCategory, searchTerm]);
// 카테고리별 개수
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
Object.values(LAYOUT_CATEGORIES).forEach((category) => {
counts[category] = allLayouts.filter((layout) => layout.category === category).length;
});
return counts;
}, [allLayouts]);
// 레이아웃 드래그 시작 핸들러
const handleDragStart = (e: React.DragEvent, layoutDefinition: any) => {
// 새 레이아웃 컴포넌트 데이터 생성
const layoutData = {
id: `layout_${Date.now()}`,
type: "layout",
layoutType: layoutDefinition.id,
layoutConfig: layoutDefinition.defaultConfig,
zones: layoutDefinition.defaultZones,
children: [],
allowedComponentTypes: [],
position: { x: 0, y: 0 },
size: layoutDefinition.defaultSize || { width: 400, height: 300 },
label: layoutDefinition.name,
};
// 드래그 데이터 설정
e.dataTransfer.setData("application/json", JSON.stringify(layoutData));
e.dataTransfer.setData("text/plain", layoutDefinition.name);
e.dataTransfer.effectAllowed = "copy";
onDragStart(e, layoutData);
};
// 레이아웃 선택 핸들러
const handleLayoutSelect = (layoutDefinition: any) => {
onLayoutSelect?.(layoutDefinition);
};
return (
<div className={`layouts-panel h-full ${className || ""}`}>
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold"></h3>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="레이아웃 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* 카테고리 탭 */}
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="flex-1">
<TabsList className="grid w-full grid-cols-4 px-4 pt-2">
<TabsTrigger value="all" className="text-xs">
({allLayouts.length})
</TabsTrigger>
<TabsTrigger value="basic" className="text-xs">
({categoryCounts.basic || 0})
</TabsTrigger>
<TabsTrigger value="form" className="text-xs">
({categoryCounts.form || 0})
</TabsTrigger>
<TabsTrigger value="navigation" className="text-xs">
({categoryCounts.navigation || 0})
</TabsTrigger>
</TabsList>
{/* 레이아웃 목록 */}
<div className="flex-1 overflow-auto p-4">
{filteredLayouts.length === 0 ? (
<div className="flex h-32 items-center justify-center text-center text-sm text-gray-500">
{searchTerm ? "검색 결과가 없습니다." : "레이아웃이 없습니다."}
</div>
) : (
<div className="space-y-3">
{filteredLayouts.map((layout) => {
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
return (
<Card
key={layout.id}
className="cursor-move transition-shadow hover:shadow-md"
draggable
onDragStart={(e) => handleDragStart(e, layout)}
onClick={() => handleLayoutSelect(layout)}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CategoryIcon className="h-4 w-4 text-gray-600" />
<Badge variant="secondary" className="text-xs">
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
</Badge>
</div>
</div>
<CardTitle className="text-sm">{layout.name}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
{layout.description && (
<p className="line-clamp-2 text-xs text-gray-600">{layout.description}</p>
)}
<div className="mt-2 text-xs text-gray-500"> : {layout.defaultZones.length}</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
</Tabs>
</div>
</div>
);
}

View File

@ -0,0 +1,283 @@
# 새로운 레이아웃 시스템 가이드
## 🎉 개요
화면관리 시스템의 레이아웃 구조가 크게 개선되었습니다. 새로운 시스템은 **자동 디스커버리**, **CLI 도구**, **Hot Reload**, **개발자 도구**를 제공하여 개발 경험을 혁신적으로 향상시킵니다.
## 📊 Before vs After
### Before (기존 구조)
```
❌ 수동 등록 (5개 파일 수정)
❌ 50줄+ 보일러플레이트 코드
❌ Git 충돌 위험
❌ 30분-1시간 소요
❌ 타입 안전성 부족
```
### After (새 구조)
```
✅ 1개 명령어로 완전 자동화
✅ 자동 생성된 템플릿
✅ 독립적 개발 (충돌 없음)
✅ 10-15분 소요
✅ 완전한 타입 안전성
```
## 🚀 새 레이아웃 생성
### 1. CLI 도구 사용
```bash
cd frontend
npm run create-layout <name> [options]
```
### 2. 실제 예시
```bash
# 기본 아코디언 레이아웃
npm run create-layout accordion --category=navigation --zones=3
# 대시보드 레이아웃
npm run create-layout dashboard --category=business --zones=4
# 사이드바 레이아웃
npm run create-layout sidebar --category=navigation --zones=2
```
### 3. 생성되는 파일들
```
layouts/myLayout/
├── index.ts # 레이아웃 정의 및 메타데이터
├── MyLayoutLayout.tsx # React 컴포넌트 (비즈니스 로직)
├── MyLayoutRenderer.tsx # 렌더러 (자동 등록)
├── config.ts # 기본 설정
├── types.ts # 타입 정의
└── README.md # 문서
```
## 🔧 개발 과정
### 1단계: 스캐폴딩
```bash
npm run create-layout sidebar --category=navigation --zones=2
```
### 2단계: 비즈니스 로직 구현
`SidebarLayout.tsx`에서 렌더링 로직 구현:
```typescript
export const SidebarLayout: React.FC<SidebarLayoutProps> = ({
layout, isDesignMode, renderer, ...props
}) => {
const sidebarConfig = layout.layoutConfig.sidebar;
// 사이드바 전용 로직 구현
const sidebarStyle = {
display: "flex",
flexDirection: sidebarConfig.position === "left" ? "row" : "row-reverse",
width: "100%",
height: "100%"
};
return (
<div className="sidebar-layout" style={sidebarStyle}>
{/* 사이드바와 메인 콘텐츠 영역 렌더링 */}
</div>
);
};
```
### 3단계: 자동 등록 및 테스트
- 파일 저장 시 **자동으로 화면편집기에서 사용 가능**
- Hot Reload로 실시간 업데이트
- 브라우저 DevTools에서 확인 가능
## 🛠️ 개발자 도구
### 브라우저 콘솔에서 사용 가능한 명령어들:
```javascript
// 모든 레이아웃 목록 보기
__LAYOUT_REGISTRY__.list();
// 특정 레이아웃 상세 정보
__LAYOUT_REGISTRY__.get("grid");
// 레지스트리 통계
__LAYOUT_REGISTRY__.stats();
// 레이아웃 검색
__LAYOUT_REGISTRY__.search("flex");
// 카테고리별 레이아웃
__LAYOUT_REGISTRY__.categories();
// 도움말
__LAYOUT_REGISTRY__.help();
```
## 📁 새 구조 특징
### 1. 자동 등록
```typescript
// 클래스 로드 시 자동 등록
export class MyLayoutRenderer extends AutoRegisteringLayoutRenderer {
static readonly layoutDefinition = MyLayoutDefinition;
static {
this.registerSelf(); // 자동 실행
}
}
```
### 2. 타입 안전성
```typescript
// 완전한 타입 정의
export const MyLayoutDefinition = createLayoutDefinition({
id: "myLayout",
name: "내 레이아웃",
component: MyLayout,
defaultConfig: {
myLayout: {
setting1: "value1",
setting2: true,
},
},
});
```
### 3. 메타데이터 관리
```typescript
{
version: "1.0.0",
author: "Developer Name",
documentation: "레이아웃 설명",
tags: ["tag1", "tag2"],
createdAt: "2025-01-10T..."
}
```
## 🔥 Hot Reload 지원
### 개발 모드에서 자동 업데이트
```typescript
// 개발 모드에서 Hot Reload 지원
if (process.env.NODE_ENV === "development") {
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.dispose(() => {
MyLayoutRenderer.unregisterSelf();
});
}
}
```
## 📋 현재 상태
### ✅ 완료된 기능들
- [x] 자동 디스커버리 시스템
- [x] CLI 스캐폴딩 도구
- [x] 자동 등록 베이스 클래스
- [x] 브라우저 개발자 도구
- [x] Hot Reload 지원
- [x] 타입 안전성
- [x] 메타데이터 관리
### 🔄 마이그레이션 현황
- [x] **Grid 레이아웃** → 새 구조 완료
- [x] **Flexbox 레이아웃** → 새 구조 완료
- [x] **Accordion 레이아웃** → 새 구조 완료 (신규)
- [ ] Split 레이아웃 → 예정
- [ ] Tabs 레이아웃 → 예정
## 🎯 마이그레이션 가이드
### 기존 레이아웃을 새 구조로 변환하기:
1. **CLI로 스캐폴딩 생성**
```bash
npm run create-layout myExistingLayout --category=basic
```
2. **기존 로직 복사**
- 기존 `MyLayoutRenderer.tsx`의 로직을 새 `MyLayout.tsx`로 복사
- 렌더링 로직 적응
3. **설정 정의**
- `index.ts`에서 `defaultConfig` 정의
- 기존 설정과 호환성 유지
4. **자동 등록 활성화**
- `layouts/index.ts`에 새 import 추가
- 기존 수동 등록 제거
## 🚦 마이그레이션 체크리스트
### 레이아웃 개발자용:
- [ ] CLI 도구로 새 레이아웃 생성
- [ ] 비즈니스 로직 구현
- [ ] 브라우저에서 `__LAYOUT_REGISTRY__.list()` 확인
- [ ] 화면편집기에서 레이아웃 선택 가능 확인
- [ ] Hot Reload 동작 확인
### 시스템 관리자용:
- [ ] 모든 기존 레이아웃 새 구조로 마이그레이션
- [ ] 기존 수동 등록 코드 제거
- [ ] 개발자 가이드 업데이트
- [ ] 팀 교육 실시
## 💡 개발 팁
### 1. 브라우저 DevTools 활용
```javascript
// 개발 중 레이아웃 확인
__LAYOUT_REGISTRY__.get("myLayout");
// 카테고리별 현황 파악
__LAYOUT_REGISTRY__.categories();
```
### 2. Hot Reload 최대한 활용
- 파일 저장 시 즉시 반영
- 브라우저 새로고침 불필요
- 실시간 디버깅 가능
### 3. 타입 안전성 확보
```typescript
// 설정 타입 정의로 IDE 지원
interface MyLayoutConfig {
orientation: "vertical" | "horizontal";
spacing: number;
collapsible: boolean;
}
```
## 🎉 결론
새로운 레이아웃 시스템으로 **개발 속도 3-4배 향상**, **충돌 위험 제거**, **타입 안전성 확보**가 가능해졌습니다.
**5분이면 새 레이아웃 생성부터 화면편집기 등록까지 완료!**
---
📞 **문의사항이나 문제가 있으면 언제든 연락주세요!**

View File

@ -0,0 +1,701 @@
# 화면관리 시스템 레이아웃 기능 설계서
## 1. 개요
### 1.1 목적
화면관리 시스템에 동적 레이아웃 기능을 추가하여 다양한 화면 구조를 효율적으로 설계할 수 있도록 한다. 레이아웃은 컴포넌트들을 구조화된 영역으로 배치할 수 있는 컨테이너 역할을 하며, 동적으로 생성하고 관리할 수 있도록 설계한다.
### 1.2 범위
- 레이아웃 관리 메뉴 및 기능 개발
- 다양한 레이아웃 타입 및 설정 기능
- 레지스트리 기반 동적 레이아웃 컴포넌트 시스템
- 기존 화면관리 시스템과의 통합
## 2. 현재 시스템 분석
### 2.1 기존 데이터베이스 구조
```sql
-- 현재 화면관리 관련 테이블들
screen_definitions -- 화면 정의
screen_layouts -- 화면 레이아웃 (컴포넌트 배치)
screen_widgets -- 위젯 설정
screen_templates -- 화면 템플릿
template_standards -- 템플릿 표준
component_standards -- 컴포넌트 표준
```
### 2.2 기존 컴포넌트 타입
```typescript
type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable" | "file" | "area";
```
### 2.3 현재 레지스트리 시스템
- `ComponentRegistry`: 컴포넌트 동적 등록 및 관리
- `WebTypeRegistry`: 웹타입 동적 등록 및 관리
- `DynamicComponentRenderer`: 동적 컴포넌트 렌더링
## 3. 레이아웃 기능 설계
### 3.1 레이아웃 타입 정의
#### 3.1.1 기본 레이아웃 타입
```typescript
export type LayoutType =
| "grid" // 그리드 레이아웃 (n x m 격자)
| "flexbox" // 플렉스박스 레이아웃
| "split" // 분할 레이아웃 (수직/수평)
| "card" // 카드 레이아웃
| "tabs" // 탭 레이아웃
| "accordion" // 아코디언 레이아웃
| "sidebar" // 사이드바 레이아웃
| "header-footer" // 헤더-푸터 레이아웃
| "three-column" // 3단 레이아웃
| "dashboard" // 대시보드 레이아웃
| "form" // 폼 레이아웃
| "table" // 테이블 레이아웃
| "custom"; // 커스텀 레이아웃
```
#### 3.1.2 레이아웃 컴포넌트 인터페이스
```typescript
export interface LayoutComponent extends BaseComponent {
type: "layout";
layoutType: LayoutType;
layoutConfig: LayoutConfig;
children: ComponentData[];
zones: LayoutZone[]; // 레이아웃 영역 정의
allowedComponentTypes?: ComponentType[]; // 허용된 자식 컴포넌트 타입
dropZoneConfig?: DropZoneConfig; // 드롭존 설정
}
export interface LayoutZone {
id: string;
name: string;
position: {
row?: number;
column?: number;
x?: number;
y?: number;
};
size: {
width: number | string;
height: number | string;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
};
style?: ComponentStyle;
allowedComponents?: ComponentType[];
isResizable?: boolean;
isRequired?: boolean; // 필수 영역 여부
}
export interface LayoutConfig {
// 그리드 레이아웃 설정
grid?: {
rows: number;
columns: number;
gap: number;
rowGap?: number;
columnGap?: number;
autoRows?: string;
autoColumns?: string;
};
// 플렉스박스 설정
flexbox?: {
direction: "row" | "column" | "row-reverse" | "column-reverse";
justify: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
wrap: "nowrap" | "wrap" | "wrap-reverse";
gap: number;
};
// 분할 레이아웃 설정
split?: {
direction: "horizontal" | "vertical";
ratio: number[]; // 각 영역의 비율 [30, 70]
minSize: number[]; // 각 영역의 최소 크기
resizable: boolean; // 크기 조절 가능 여부
splitterSize: number; // 분할선 두께
};
// 탭 레이아웃 설정
tabs?: {
position: "top" | "bottom" | "left" | "right";
variant: "default" | "pills" | "underline";
size: "sm" | "md" | "lg";
defaultTab: string; // 기본 선택 탭
closable: boolean; // 탭 닫기 가능 여부
};
// 아코디언 설정
accordion?: {
multiple: boolean; // 다중 확장 허용
defaultExpanded: string[]; // 기본 확장 항목
collapsible: boolean; // 모두 닫기 허용
};
// 사이드바 설정
sidebar?: {
position: "left" | "right";
width: number | string;
collapsible: boolean;
collapsed: boolean;
overlay: boolean; // 오버레이 모드
};
// 헤더-푸터 설정
headerFooter?: {
headerHeight: number | string;
footerHeight: number | string;
stickyHeader: boolean;
stickyFooter: boolean;
};
// 대시보드 설정
dashboard?: {
columns: number;
rowHeight: number;
margin: [number, number];
padding: [number, number];
isDraggable: boolean;
isResizable: boolean;
};
// 커스텀 설정
custom?: {
cssProperties: Record<string, string>;
className: string;
template: string; // HTML 템플릿
};
}
export interface DropZoneConfig {
showDropZones: boolean;
dropZoneStyle?: ComponentStyle;
highlightOnDragOver: boolean;
allowedTypes?: ComponentType[];
}
```
### 3.2 데이터베이스 스키마 확장
#### 3.2.1 레이아웃 표준 관리 테이블
```sql
-- 레이아웃 표준 관리 테이블
CREATE TABLE layout_standards (
layout_code VARCHAR(50) PRIMARY KEY,
layout_name VARCHAR(100) NOT NULL,
layout_name_eng VARCHAR(100),
description TEXT,
layout_type VARCHAR(50) NOT NULL,
category VARCHAR(50) NOT NULL,
icon_name VARCHAR(50),
default_size JSON,
layout_config JSON NOT NULL,
zones_config JSON NOT NULL,
preview_image VARCHAR(255),
sort_order INTEGER DEFAULT 0,
is_active CHAR(1) DEFAULT 'Y',
is_public CHAR(1) DEFAULT 'Y',
company_code VARCHAR(50) NOT NULL,
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50)
);
-- 인덱스 생성
CREATE INDEX idx_layout_standards_type ON layout_standards(layout_type);
CREATE INDEX idx_layout_standards_category ON layout_standards(category);
CREATE INDEX idx_layout_standards_company ON layout_standards(company_code);
```
#### 3.2.2 기존 테이블 확장
```sql
-- screen_layouts 테이블에 레이아웃 관련 컬럼 추가
ALTER TABLE screen_layouts ADD COLUMN layout_type VARCHAR(50);
ALTER TABLE screen_layouts ADD COLUMN layout_config JSON;
ALTER TABLE screen_layouts ADD COLUMN zones_config JSON;
ALTER TABLE screen_layouts ADD COLUMN zone_id VARCHAR(100);
-- component_standards 테이블에서 레이아웃 타입 지원
-- category에 'layout' 추가
```
### 3.3 레이아웃 카테고리 및 사전 정의 레이아웃
#### 3.3.1 레이아웃 카테고리
```typescript
export const LAYOUT_CATEGORIES = {
BASIC: "basic", // 기본 레이아웃
FORM: "form", // 폼 레이아웃
TABLE: "table", // 테이블 레이아웃
DASHBOARD: "dashboard", // 대시보드 레이아웃
NAVIGATION: "navigation", // 네비게이션 레이아웃
CONTENT: "content", // 컨텐츠 레이아웃
BUSINESS: "business", // 업무용 레이아웃
};
```
#### 3.3.2 사전 정의 레이아웃 템플릿
```typescript
export const PREDEFINED_LAYOUTS = [
// 기본 레이아웃
{
code: "GRID_2X2",
name: "2x2 그리드",
type: "grid",
category: "basic",
config: {
grid: { rows: 2, columns: 2, gap: 16 },
},
zones: [
{ id: "zone1", name: "상단 좌측", position: { row: 0, column: 0 } },
{ id: "zone2", name: "상단 우측", position: { row: 0, column: 1 } },
{ id: "zone3", name: "하단 좌측", position: { row: 1, column: 0 } },
{ id: "zone4", name: "하단 우측", position: { row: 1, column: 1 } },
],
},
// 폼 레이아웃
{
code: "FORM_TWO_COLUMN",
name: "2단 폼 레이아웃",
type: "grid",
category: "form",
config: {
grid: { rows: 1, columns: 2, gap: 24 },
},
zones: [
{ id: "left", name: "좌측 입력 영역", position: { row: 0, column: 0 } },
{ id: "right", name: "우측 입력 영역", position: { row: 0, column: 1 } },
],
},
// 대시보드 레이아웃
{
code: "DASHBOARD_MAIN",
name: "메인 대시보드",
type: "grid",
category: "dashboard",
config: {
grid: { rows: 3, columns: 4, gap: 16 },
},
zones: [
{ id: "header", name: "헤더", position: { row: 0, column: 0 }, size: { width: "100%", height: "80px" } },
{ id: "sidebar", name: "사이드바", position: { row: 1, column: 0 }, size: { width: "250px", height: "100%" } },
{
id: "main",
name: "메인 컨텐츠",
position: { row: 1, column: 1 },
size: { width: "calc(100% - 250px)", height: "100%" },
},
],
},
// 테이블 레이아웃
{
code: "TABLE_WITH_FILTERS",
name: "필터가 있는 테이블",
type: "flexbox",
category: "table",
config: {
flexbox: { direction: "column", gap: 16 },
},
zones: [
{ id: "filters", name: "검색 필터", size: { width: "100%", height: "auto" } },
{ id: "table", name: "데이터 테이블", size: { width: "100%", height: "1fr" } },
],
},
// 분할 레이아웃
{
code: "SPLIT_HORIZONTAL",
name: "수평 분할",
type: "split",
category: "basic",
config: {
split: { direction: "horizontal", ratio: [50, 50], resizable: true },
},
zones: [
{ id: "left", name: "좌측 영역", isResizable: true },
{ id: "right", name: "우측 영역", isResizable: true },
],
},
// 탭 레이아웃
{
code: "TABS_HORIZONTAL",
name: "수평 탭",
type: "tabs",
category: "navigation",
config: {
tabs: { position: "top", variant: "default", defaultTab: "tab1" },
},
zones: [
{ id: "tab1", name: "첫 번째 탭" },
{ id: "tab2", name: "두 번째 탭" },
{ id: "tab3", name: "세 번째 탭" },
],
},
];
```
## 4. 구현 계획
### 4.1 Phase 1: 기본 인프라 구축
#### 4.1.1 데이터베이스 스키마 생성
- `layout_standards` 테이블 생성
- 기존 테이블 확장
- 기본 레이아웃 데이터 삽입
#### 4.1.2 타입 정의 및 인터페이스
- `frontend/types/layout.ts` 생성
- 기존 `screen.ts` 확장
#### 4.1.3 레이아웃 레지스트리 시스템
```typescript
// frontend/lib/registry/LayoutRegistry.ts
export class LayoutRegistry {
private static layouts = new Map<string, LayoutDefinition>();
static registerLayout(definition: LayoutDefinition): void;
static getLayout(layoutType: string): LayoutDefinition | undefined;
static getLayoutsByCategory(category: string): LayoutDefinition[];
static getAllLayouts(): LayoutDefinition[];
}
```
### 4.2 Phase 2: 레이아웃 관리 기능
#### 4.2.1 레이아웃 관리 메뉴
```typescript
// frontend/app/(main)/admin/layouts/page.tsx
- 레이아웃 목록 조회
- 레이아웃 생성/수정/삭제
- 레이아웃 미리보기
- 레이아웃 내보내기/가져오기
```
#### 4.2.2 레이아웃 편집기
```typescript
// frontend/components/layout/LayoutDesigner.tsx
- 드래그앤드롭 레이아웃 편집
- 실시간 미리보기
- 존 설정 편집
- 레이아웃 설정 편집
```
#### 4.2.3 백엔드 API
```typescript
// backend-node/src/routes/layoutRoutes.ts
GET /api/layouts // 레이아웃 목록 조회
GET /api/layouts/:id // 레이아웃 상세 조회
POST /api/layouts // 레이아웃 생성
PUT /api/layouts/:id // 레이아웃 수정
DELETE /api/layouts/:id // 레이아웃 삭제
POST /api/layouts/:id/duplicate // 레이아웃 복제
```
### 4.3 Phase 3: 레이아웃 컴포넌트 구현
#### 4.3.1 기본 레이아웃 컴포넌트들
```typescript
// frontend/lib/registry/layouts/
├── GridLayoutRenderer.tsx // 그리드 레이아웃
├── FlexboxLayoutRenderer.tsx // 플렉스박스 레이아웃
├── SplitLayoutRenderer.tsx // 분할 레이아웃
├── TabsLayoutRenderer.tsx // 탭 레이아웃
├── AccordionLayoutRenderer.tsx // 아코디언 레이아웃
├── SidebarLayoutRenderer.tsx // 사이드바 레이아웃
├── HeaderFooterLayoutRenderer.tsx // 헤더-푸터 레이아웃
└── CustomLayoutRenderer.tsx // 커스텀 레이아웃
```
#### 4.3.2 레이아웃 설정 패널
```typescript
// frontend/components/layout/config-panels/
├── GridConfigPanel.tsx
├── FlexboxConfigPanel.tsx
├── SplitConfigPanel.tsx
├── TabsConfigPanel.tsx
└── ...
```
### 4.4 Phase 4: 화면관리 시스템 통합
#### 4.4.1 화면 디자이너 확장
- 레이아웃 팔레트 추가
- 레이아웃 드래그앤드롭 지원
- 레이아웃 존에 컴포넌트 배치
#### 4.4.2 실시간 미리보기 지원
- 레이아웃 렌더링 지원
- 존별 컴포넌트 렌더링
- 레이아웃 상호작용 지원
## 5. 기술적 구현 세부사항
### 5.1 레이아웃 렌더러 기본 구조
```typescript
// frontend/lib/registry/layouts/BaseLayoutRenderer.tsx
interface LayoutRendererProps {
layout: LayoutComponent;
children: ComponentData[];
isDesignMode?: boolean;
onZoneClick?: (zoneId: string) => void;
onComponentDrop?: (zoneId: string, component: ComponentData) => void;
}
export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererProps> {
abstract render(): React.ReactElement;
protected renderZone(zone: LayoutZone, children: ComponentData[]): React.ReactElement {
return (
<div
className={`layout-zone ${this.props.isDesignMode ? 'design-mode' : ''}`}
data-zone-id={zone.id}
onClick={() => this.props.onZoneClick?.(zone.id)}
onDrop={this.handleDrop}
onDragOver={this.handleDragOver}
>
{children.map(child => (
<DynamicComponentRenderer key={child.id} component={child} />
))}
</div>
);
}
private handleDrop = (e: React.DragEvent) => {
// 드롭 처리 로직
};
private handleDragOver = (e: React.DragEvent) => {
// 드래그오버 처리 로직
};
}
```
### 5.2 동적 레이아웃 등록 시스템
```typescript
// frontend/lib/registry/layouts/index.ts
import { LayoutRegistry } from "../LayoutRegistry";
import GridLayoutRenderer from "./GridLayoutRenderer";
import FlexboxLayoutRenderer from "./FlexboxLayoutRenderer";
// ... 다른 레이아웃 import
// 레이아웃 컴포넌트들을 레지스트리에 등록
LayoutRegistry.registerLayout({
id: "grid",
name: "그리드 레이아웃",
component: GridLayoutRenderer,
category: "basic",
icon: "grid",
defaultConfig: {
grid: { rows: 2, columns: 2, gap: 16 },
},
});
LayoutRegistry.registerLayout({
id: "flexbox",
name: "플렉스박스 레이아웃",
component: FlexboxLayoutRenderer,
category: "basic",
icon: "flex",
defaultConfig: {
flexbox: { direction: "row", justify: "flex-start", align: "stretch" },
},
});
```
### 5.3 레이아웃 팔레트 컴포넌트
```typescript
// frontend/components/screen/panels/LayoutsPanel.tsx
export default function LayoutsPanel({ onDragStart }: LayoutsPanelProps) {
const [layouts] = useState(() => LayoutRegistry.getAllLayouts());
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const filteredLayouts = useMemo(() => {
if (selectedCategory === 'all') return layouts;
return layouts.filter(layout => layout.category === selectedCategory);
}, [layouts, selectedCategory]);
return (
<div className="layouts-panel">
<div className="category-tabs">
{LAYOUT_CATEGORIES.map(category => (
<Button
key={category.id}
variant={selectedCategory === category.id ? 'default' : 'ghost'}
onClick={() => setSelectedCategory(category.id)}
>
{category.name}
</Button>
))}
</div>
<div className="layout-grid">
{filteredLayouts.map(layout => (
<div
key={layout.id}
className="layout-item"
draggable
onDragStart={(e) => onDragStart(e, layout)}
>
<div className="layout-preview">
<layout.icon />
</div>
<div className="layout-info">
<h4>{layout.name}</h4>
<p>{layout.description}</p>
</div>
</div>
))}
</div>
</div>
);
}
```
## 6. 사용자 인터페이스 설계
### 6.1 레이아웃 관리 화면
- **레이아웃 목록**: 그리드 형태로 레이아웃 목록 표시
- **카테고리 필터**: 카테고리별 레이아웃 필터링
- **검색 기능**: 레이아웃 이름/설명으로 검색
- **미리보기**: 레이아웃 구조 미리보기
- **편집 버튼**: 레이아웃 편집 모드 진입
### 6.2 레이아웃 편집기
- **캔버스 영역**: 레이아웃 시각적 편집
- **존 편집**: 각 존의 크기/위치 조정
- **속성 패널**: 레이아웃 설정 편집
- **미리보기 모드**: 실제 렌더링 미리보기
### 6.3 화면 디자이너 확장
- **레이아웃 팔레트**: 사용 가능한 레이아웃 목록
- **드래그앤드롭**: 레이아웃을 캔버스에 배치
- **존 하이라이트**: 컴포넌트 드롭 가능한 존 표시
## 7. 보안 및 권한 관리
### 7.1 레이아웃 접근 권한
- **생성 권한**: 레이아웃 생성 권한
- **수정 권한**: 레이아웃 수정 권한
- **삭제 권한**: 레이아웃 삭제 권한
- **공개 설정**: 다른 사용자와 레이아웃 공유
### 7.2 회사별 레이아웃 관리
- **회사 코드**: 레이아웃의 회사 소속 관리
- **공개 레이아웃**: 모든 회사에서 사용 가능한 레이아웃
- **비공개 레이아웃**: 특정 회사에서만 사용 가능한 레이아웃
## 8. 성능 최적화
### 8.1 레이아웃 렌더링 최적화
- **지연 로딩**: 필요한 레이아웃 컴포넌트만 로딩
- **메모이제이션**: 레이아웃 설정 변경 시에만 리렌더링
- **가상화**: 대량의 레이아웃 목록 가상화
### 8.2 캐싱 전략
- **레이아웃 정의 캐싱**: 자주 사용되는 레이아웃 정의 캐싱
- **렌더링 결과 캐싱**: 동일한 설정의 레이아웃 렌더링 결과 캐싱
## 9. 테스트 계획
### 9.1 단위 테스트
- 레이아웃 컴포넌트 렌더링 테스트
- 레이아웃 설정 변경 테스트
- 드래그앤드롭 기능 테스트
### 9.2 통합 테스트
- 화면관리 시스템과의 통합 테스트
- 데이터베이스 연동 테스트
- API 엔드포인트 테스트
### 9.3 사용자 테스트
- 레이아웃 생성/편집 시나리오 테스트
- 다양한 브라우저 호환성 테스트
## 10. 마이그레이션 계획
### 10.1 기존 화면 마이그레이션
- 기존 컨테이너 컴포넌트를 레이아웃으로 변환
- 기존 화면 구조를 레이아웃 기반으로 재구성
### 10.2 단계별 배포
1. **Phase 1**: 레이아웃 관리 기능 배포
2. **Phase 2**: 기본 레이아웃 컴포넌트 배포
3. **Phase 3**: 화면관리 시스템 통합
4. **Phase 4**: 기존 화면 마이그레이션
## 11. 향후 확장 계획
### 11.1 고급 레이아웃 기능
- **반응형 레이아웃**: 화면 크기에 따른 레이아웃 변경
- **애니메이션**: 레이아웃 전환 애니메이션
- **테마 지원**: 레이아웃별 테마 설정
### 11.2 AI 기반 레이아웃 추천
- 데이터 타입에 따른 레이아웃 자동 추천
- 사용 패턴 분석을 통한 최적 레이아웃 제안
### 11.3 협업 기능
- **실시간 편집**: 여러 사용자가 동시에 레이아웃 편집
- **버전 관리**: 레이아웃 변경 이력 관리
- **댓글 시스템**: 레이아웃에 대한 피드백 시스템
## 12. 결론
이 설계서에 따라 레이아웃 기능을 구현하면, 화면관리 시스템의 유연성과 확장성이 크게 향상될 것입니다. 동적 레지스트리 시스템을 통해 새로운 레이아웃 타입을 쉽게 추가할 수 있으며, 사용자는 다양한 화면 구조를 효율적으로 설계할 수 있게 됩니다.
주요 장점:
- **확장성**: 새로운 레이아웃 타입 쉽게 추가
- **재사용성**: 레이아웃 템플릿 재사용으로 개발 효율성 향상
- **유연성**: 다양한 화면 요구사항에 대응 가능
- **일관성**: 표준화된 레이아웃을 통한 UI 일관성 확보

View File

@ -0,0 +1,416 @@
# 레이아웃 추가 가이드
화면관리 시스템에서 새로운 레이아웃을 추가하는 방법을 설명합니다.
## 📋 목차
1. [CLI를 이용한 자동 생성](#cli를-이용한-자동-생성)
2. [생성된 파일 구조](#생성된-파일-구조)
3. [레이아웃 커스터마이징](#레이아웃-커스터마이징)
4. [고급 설정](#고급-설정)
5. [문제 해결](#문제-해결)
---
## 🚀 CLI를 이용한 자동 생성
### 기본 사용법
```bash
# 기본 형태
node scripts/create-layout.js <레이아웃이름> [옵션]
# 예시
node scripts/create-layout.js card-grid --category=dashboard --zones=6 --description="카드 형태의 그리드 레이아웃"
```
### 📝 사용 가능한 옵션
| 옵션 | 필수 여부 | 기본값 | 설명 | 예시 |
| --------------- | --------- | ----------- | ----------------- | ----------------------------------- |
| `--category` | 선택 | `basic` | 레이아웃 카테고리 | `--category=dashboard` |
| `--zones` | 선택 | `2` | 영역 개수 | `--zones=4` |
| `--description` | 선택 | 자동 생성 | 레이아웃 설명 | `--description="사이드바 레이아웃"` |
| `--author` | 선택 | `Developer` | 작성자 이름 | `--author="김개발"` |
### 🏷️ 카테고리 종류
| 카테고리 | 설명 | 예시 레이아웃 |
| ------------ | ------------- | ------------------------ |
| `basic` | 기본 레이아웃 | 그리드, 플렉스박스, 분할 |
| `navigation` | 네비게이션 | 탭, 아코디언, 메뉴 |
| `dashboard` | 대시보드 | 카드, 위젯, 차트 |
| `content` | 콘텐츠 | 헤더-본문, 영웅 섹션 |
| `form` | 폼 | 입력 폼, 설정 패널 |
| `table` | 테이블 | 데이터 테이블, 목록 |
### 💡 이름 규칙
레이아웃 이름은 **하이픈(`-`)을 사용한 kebab-case**로 입력하면 자동으로 변환됩니다:
```bash
# 입력: hero-section
📁 디렉토리: hero-section/
🔖 ID: hero-section
📄 클래스명: HeroSection
🔧 변수명: heroSection
```
#### ✅ 올바른 이름 예시
- `card-grid`
- `side-navigation`
- `data-table`
- `hero-section`
- `my-awesome-layout`
#### ❌ 피해야 할 이름
- `CardGrid` (파스칼케이스)
- `card_grid` (스네이크케이스)
- `cardGrid` (카멜케이스)
---
## 📂 생성된 파일 구조
CLI로 레이아웃을 생성하면 다음과 같은 파일들이 자동으로 생성됩니다:
```
lib/registry/layouts/your-layout/
├── index.ts # 레이아웃 정의 및 등록
├── YourLayoutLayout.tsx # React 컴포넌트
├── YourLayoutRenderer.tsx # 렌더링 로직
├── config.ts # 기본 설정
├── types.ts # 타입 정의
└── README.md # 문서
```
### 🔧 각 파일의 역할
#### 1. `index.ts` - 레이아웃 정의
```typescript
export const YourLayoutDefinition = createLayoutDefinition({
id: "your-layout",
name: "yourLayout",
nameEng: "Your Layout",
description: "사용자 정의 레이아웃입니다",
category: "basic",
component: YourLayoutWrapper,
defaultConfig: {
/* 기본 설정 */
},
defaultZones: [
/* 기본 영역들 */
],
});
```
#### 2. `YourLayoutLayout.tsx` - React 컴포넌트
```typescript
export const YourLayoutLayout: React.FC<YourLayoutProps> = ({ layout, isDesignMode, renderer, ...props }) => {
// 레이아웃 UI 구현
};
```
#### 3. `YourLayoutRenderer.tsx` - 렌더링 로직
```typescript
export class YourLayoutRenderer extends AutoRegisteringLayoutRenderer {
static layoutDefinition = YourLayoutDefinition;
render(): React.ReactElement {
return <YourLayoutLayout {...this.props} renderer={this} />;
}
}
```
---
## 🎨 레이아웃 커스터마이징
### 1. 기본 구조 수정
생성된 `YourLayoutLayout.tsx`에서 레이아웃 구조를 정의합니다:
```typescript
export const YourLayoutLayout: React.FC<YourLayoutProps> = ({
layout,
isDesignMode = false,
renderer,
}) => {
const yourLayoutConfig = layout.layoutConfig.yourLayout;
const containerStyle = renderer.getLayoutContainerStyle();
// 레이아웃별 커스텀 스타일
const yourLayoutStyle: React.CSSProperties = {
...containerStyle,
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gridTemplateRows: "repeat(2, 200px)",
gap: "16px",
padding: "16px",
};
return (
<div style={yourLayoutStyle}>
{layout.zones.map((zone) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
return (
<div key={zone.id} className="zone-area">
{/* 존 렌더링 */}
{renderer.renderZone(zone, zoneChildren)}
</div>
);
})}
</div>
);
};
```
### 2. 설정 옵션 추가
`config.ts`에서 레이아웃별 설정을 정의합니다:
```typescript
export const YourLayoutConfig = {
defaultConfig: {
yourLayout: {
columns: 3, // 열 개수
rows: 2, // 행 개수
gap: 16, // 간격
aspectRatio: "16:9", // 비율
backgroundColor: "#ffffff",
borderRadius: "8px",
},
},
};
```
### 3. 타입 정의
`types.ts`에서 설정 타입을 정의합니다:
```typescript
export interface YourLayoutConfig {
columns?: number;
rows?: number;
gap?: number;
aspectRatio?: string;
backgroundColor?: string;
borderRadius?: string;
}
export interface YourLayoutProps extends LayoutRendererProps {
renderer: YourLayoutRenderer;
}
```
---
## 🔧 고급 설정
### 영역(Zone) 커스터마이징
영역별로 다른 스타일을 적용하려면:
```typescript
// 영역별 스타일 계산
const getZoneStyle = (zone: LayoutZone, index: number): React.CSSProperties => {
const baseStyle = {
backgroundColor: "#f8f9fa",
border: "1px solid #e9ecef",
borderRadius: "4px",
padding: "12px",
};
// 첫 번째 영역은 다른 스타일
if (index === 0) {
return {
...baseStyle,
backgroundColor: "#e3f2fd",
gridColumn: "1 / -1", // 전체 너비
};
}
return baseStyle;
};
```
### 반응형 레이아웃
미디어 쿼리를 사용한 반응형 구현:
```typescript
const getResponsiveStyle = (): React.CSSProperties => {
return {
display: "grid",
gridTemplateColumns: `repeat(auto-fit, minmax(300px, 1fr))`,
gap: "16px",
// CSS-in-JS에서는 미디어 쿼리를 직접 사용할 수 없으므로
// CSS 클래스나 컨테이너 쿼리 사용 권장
};
};
```
### 애니메이션 추가
CSS 애니메이션을 포함한 레이아웃:
```typescript
const animatedStyle: React.CSSProperties = {
transition: "all 0.3s ease-in-out",
opacity: isDesignMode ? 0.9 : 1,
transform: isDesignMode ? "scale(0.98)" : "scale(1)",
};
```
---
## 🔄 자동 등록 시스템
### Hot Reload 지원
새 레이아웃은 다음과 같이 자동으로 등록됩니다:
1. **파일 저장 시**: Hot Reload로 즉시 반영
2. **자동 등록**: `AutoRegisteringLayoutRenderer` 상속으로 자동 등록
3. **즉시 사용**: 화면편집기에서 바로 사용 가능
### 수동 등록 (필요한 경우)
`lib/registry/layouts/index.ts`에 직접 추가:
```typescript
// 새 구조 레이아웃들 (자동 등록)
import "./your-layout/YourLayoutRenderer";
```
---
## 🛠️ 문제 해결
### 자주 발생하는 오류
#### 1. "Cannot read properties of undefined" 오류
```typescript
// ❌ 문제: 설정이 없을 때 오류
const config = layout.layoutConfig.yourLayout.someProperty;
// ✅ 해결: 안전한 접근
const config = layout.layoutConfig.yourLayout?.someProperty || defaultValue;
```
#### 2. "React does not recognize prop" 경고
```typescript
// ❌ 문제: 모든 props를 DOM에 전달
<div {...props}>
// ✅ 해결: DOM props만 전달
const { layout, isDesignMode, renderer, ...domProps } = props;
<div {...domProps}>
```
#### 3. 레이아웃이 화면편집기에 나타나지 않음
1. **파일 저장 확인**: 모든 파일이 저장되었는지 확인
2. **자동 등록 확인**: `YourLayoutRenderer.registerSelf()` 호출 여부
3. **브라우저 새로고침**: 캐시 문제일 수 있음
4. **개발자 도구**: `window.__LAYOUT_REGISTRY__.list()` 로 등록 상태 확인
### 디버깅 도구
#### 브라우저 개발자 도구
```javascript
// 등록된 레이아웃 목록 확인
window.__LAYOUT_REGISTRY__.list();
// 특정 레이아웃 정보 확인
window.__LAYOUT_REGISTRY__.get("your-layout");
// 레지스트리 통계
window.__LAYOUT_REGISTRY__.stats();
```
---
## 📖 예시: 완전한 레이아웃 생성
### 1. CLI로 생성
```bash
node scripts/create-layout.js pricing-table --category=content --zones=4 --description="가격표 레이아웃" --author="개발팀"
```
### 2. 생성 결과
```
✅ 레이아웃 생성 완료!
📁 이름: pricingTable
🔖 ID: pricing-table
📂 카테고리: content
🎯 존 개수: 4
```
### 3. 커스터마이징
```typescript
// PricingTableLayout.tsx
export const PricingTableLayout: React.FC<PricingTableLayoutProps> = ({
layout,
isDesignMode,
renderer,
}) => {
const pricingTableStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
gap: "24px",
padding: "32px",
backgroundColor: "#f8f9fa",
};
return (
<div style={pricingTableStyle}>
{layout.zones.map((zone, index) => (
<div
key={zone.id}
className={`pricing-card ${index === 1 ? 'featured' : ''}`}
style={{
backgroundColor: "white",
borderRadius: "12px",
padding: "24px",
boxShadow: index === 1 ? "0 8px 32px rgba(0,0,0,0.1)" : "0 2px 8px rgba(0,0,0,0.1)",
border: index === 1 ? "2px solid #3b82f6" : "1px solid #e5e7eb",
transform: index === 1 ? "scale(1.05)" : "scale(1)",
}}
>
{renderer.renderZone(zone, renderer.getZoneChildren(zone.id))}
</div>
))}
</div>
);
};
```
### 4. 즉시 사용 가능
레이아웃이 자동으로 등록되어 화면편집기에서 바로 사용할 수 있습니다!
---
## 🎯 마무리
새로운 CLI 방식으로 레이아웃 추가가 매우 간단해졌습니다:
1. **한 줄 명령어**로 모든 파일 자동 생성
2. **타입 안전성** 보장
3. **자동 등록**으로 즉시 사용 가능
4. **Hot Reload** 지원으로 빠른 개발
더 자세한 정보가 필요하면 각 레이아웃의 `README.md` 파일을 참고하세요! 🚀

132
frontend/lib/api/layout.ts Normal file
View File

@ -0,0 +1,132 @@
import {
LayoutStandard,
CreateLayoutRequest,
UpdateLayoutRequest,
GetLayoutsResponse,
LayoutCategory,
LayoutType,
} from "@/types/layout";
import { apiClient } from "./client";
export interface GetLayoutsParams {
page?: number;
size?: number;
category?: LayoutCategory;
layoutType?: LayoutType;
searchTerm?: string;
includePublic?: boolean;
}
export interface DuplicateLayoutRequest {
newName: string;
}
class LayoutApiService {
private basePath = "/layouts";
/**
*
*/
async getLayouts(params: GetLayoutsParams = {}): Promise<GetLayoutsResponse> {
const response = await apiClient.get(this.basePath, { params });
return response.data.data;
}
/**
*
*/
async getLayoutById(layoutCode: string): Promise<LayoutStandard> {
const response = await apiClient.get(`${this.basePath}/${layoutCode}`);
return response.data.data;
}
/**
*
*/
async createLayout(data: CreateLayoutRequest): Promise<LayoutStandard> {
const response = await apiClient.post(this.basePath, data);
return response.data.data;
}
/**
*
*/
async updateLayout(layoutCode: string, data: Partial<CreateLayoutRequest>): Promise<LayoutStandard> {
const response = await apiClient.put(`${this.basePath}/${layoutCode}`, data);
return response.data.data;
}
/**
*
*/
async deleteLayout(layoutCode: string): Promise<void> {
await apiClient.delete(`${this.basePath}/${layoutCode}`);
}
/**
*
*/
async duplicateLayout(layoutCode: string, data: DuplicateLayoutRequest): Promise<LayoutStandard> {
const response = await apiClient.post(`${this.basePath}/${layoutCode}/duplicate`, data);
return response.data.data;
}
/**
*
*/
async getLayoutCountsByCategory(): Promise<Record<string, number>> {
const response = await apiClient.get(`${this.basePath}/counts-by-category`);
return response.data.data;
}
/**
*
*/
async searchLayouts(
searchTerm: string,
params: Omit<GetLayoutsParams, "searchTerm"> = {},
): Promise<GetLayoutsResponse> {
return this.getLayouts({ ...params, searchTerm });
}
/**
*
*/
async getLayoutsByCategory(
category: LayoutCategory,
params: Omit<GetLayoutsParams, "category"> = {},
): Promise<GetLayoutsResponse> {
return this.getLayouts({ ...params, category });
}
/**
*
*/
async getLayoutsByType(
layoutType: LayoutType,
params: Omit<GetLayoutsParams, "layoutType"> = {},
): Promise<GetLayoutsResponse> {
return this.getLayouts({ ...params, layoutType });
}
/**
*
*/
async getPublicLayouts(params: Omit<GetLayoutsParams, "includePublic"> = {}): Promise<GetLayoutsResponse> {
return this.getLayouts({ ...params, includePublic: true });
}
/**
*
*/
async getPrivateLayouts(params: Omit<GetLayoutsParams, "includePublic"> = {}): Promise<GetLayoutsResponse> {
return this.getLayouts({ ...params, includePublic: false });
}
}
// 인스턴스 생성 및 내보내기
export const layoutApi = new LayoutApiService();
// 기본 내보내기
export default layoutApi;

View File

@ -2,6 +2,7 @@
import React from "react";
import { ComponentData } from "@/types/screen";
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
// 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer {
@ -77,6 +78,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// component_config에서 실제 컴포넌트 타입 추출
const componentType = component.componentConfig?.type || component.type;
// 레이아웃 컴포넌트 처리
if (componentType === "layout") {
return (
<DynamicLayoutRenderer
layout={component as any}
allComponents={props.allComponents || []}
isSelected={isSelected}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...props}
/>
);
}
console.log("🎯 DynamicComponentRenderer:", {
componentId: component.id,
componentType,

View File

@ -0,0 +1,106 @@
"use client";
import React from "react";
import { LayoutComponent, ComponentData } from "@/types/screen";
import { LayoutRegistry } from "./LayoutRegistry";
export interface DynamicLayoutRendererProps {
layout: LayoutComponent;
allComponents: ComponentData[];
isDesignMode?: boolean;
isSelected?: boolean;
onClick?: (e: React.MouseEvent) => void;
onZoneClick?: (zoneId: string, e: React.MouseEvent) => void;
onComponentDrop?: (zoneId: string, component: ComponentData, e: React.DragEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
className?: string;
style?: React.CSSProperties;
}
export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
layout,
allComponents,
isDesignMode = false,
isSelected = false,
onClick,
onZoneClick,
onComponentDrop,
onDragStart,
onDragEnd,
className,
style,
}) => {
console.log("🎯 DynamicLayoutRenderer:", {
layoutId: layout.id,
layoutType: layout.layoutType,
zonesCount: layout.zones.length,
allComponentsCount: allComponents.length,
isDesignMode,
isSelected,
});
// 레지스트리에서 레이아웃 정의 조회
const layoutDefinition = LayoutRegistry.getLayout(layout.layoutType);
if (!layoutDefinition) {
console.warn(`⚠️ 등록되지 않은 레이아웃 타입: ${layout.layoutType}`);
// 폴백 렌더링 - 기본 플레이스홀더
return (
<div
className={`flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4 ${className || ""}`}
style={style}
onClick={onClick}
>
<div className="text-center">
<div className="mb-2 text-sm font-medium text-gray-600">{layout.label || `레이아웃 ${layout.id}`}</div>
<div className="text-xs text-gray-400"> : {layout.layoutType}</div>
</div>
</div>
);
}
// 레이아웃 컴포넌트 가져오기
const LayoutComponent = layoutDefinition.component;
// 레이아웃 렌더링 실행
try {
return (
<LayoutComponent
layout={layout}
allComponents={allComponents}
isDesignMode={isDesignMode}
isSelected={isSelected}
onClick={onClick}
onZoneClick={onZoneClick}
onComponentDrop={onComponentDrop}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
className={className}
style={style}
/>
);
} catch (error) {
console.error(`❌ 레이아웃 렌더링 실패 (${layout.layoutType}):`, error);
// 오류 발생 시 폴백 렌더링
return (
<div
className={`flex h-full w-full items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4 ${className || ""}`}
style={style}
onClick={onClick}
>
<div className="text-center">
<div className="mb-2 text-sm font-medium text-red-600"> </div>
<div className="text-xs text-red-400">
{layout.layoutType}: {error instanceof Error ? error.message : "알 수 없는 오류"}
</div>
</div>
</div>
);
}
};
export default DynamicLayoutRenderer;

View File

@ -0,0 +1,317 @@
"use client";
import React from "react";
import { LayoutDefinition, LayoutType, LayoutCategory } from "@/types/layout";
/**
*
* , ,
*/
export class LayoutRegistry {
private static layouts = new Map<string, LayoutDefinition>();
private static eventListeners: Array<(event: LayoutRegistryEvent) => void> = [];
/**
*
*/
static registerLayout(definition: LayoutDefinition): void {
this.layouts.set(definition.id, definition);
this.emitEvent({
type: "layout_registered",
data: definition,
timestamp: new Date(),
});
console.log(`✅ 레이아웃 등록: ${definition.id} (${definition.name})`);
// 개발자 도구 등록 (개발 모드에서만)
if (process.env.NODE_ENV === "development") {
this.registerGlobalDevTools();
}
}
/**
*
*/
static unregisterLayout(id: string): void {
const definition = this.layouts.get(id);
if (definition) {
this.layouts.delete(id);
this.emitEvent({
type: "layout_unregistered",
data: definition,
timestamp: new Date(),
});
console.log(`❌ 레이아웃 등록 해제: ${id}`);
}
}
/**
*
*/
static getLayout(layoutType: string): LayoutDefinition | undefined {
return this.layouts.get(layoutType);
}
/**
*
*/
static getLayoutsByCategory(category: LayoutCategory): LayoutDefinition[] {
return Array.from(this.layouts.values()).filter((layout) => layout.category === category);
}
/**
*
*/
static getAllLayouts(): LayoutDefinition[] {
return Array.from(this.layouts.values());
}
/**
*
*/
static getActiveLayouts(): LayoutDefinition[] {
return Array.from(this.layouts.values()).filter((layout) => layout.isActive !== false);
}
/**
*
*/
static searchLayouts(query: string): LayoutDefinition[] {
const searchTerm = query.toLowerCase();
return Array.from(this.layouts.values()).filter(
(layout) =>
layout.name.toLowerCase().includes(searchTerm) ||
layout.nameEng?.toLowerCase().includes(searchTerm) ||
layout.description?.toLowerCase().includes(searchTerm) ||
layout.tags?.some((tag) => tag.toLowerCase().includes(searchTerm)),
);
}
/**
*
*/
static hasLayout(layoutType: string): boolean {
return this.layouts.has(layoutType);
}
/**
*
*/
static getRegisteredTypes(): string[] {
return Array.from(this.layouts.keys());
}
/**
*
*/
static updateLayout(id: string, updates: Partial<LayoutDefinition>): boolean {
const existing = this.layouts.get(id);
if (existing) {
const updated = { ...existing, ...updates };
this.layouts.set(id, updated);
this.emitEvent({
type: "layout_updated",
data: updated,
timestamp: new Date(),
});
console.log(`🔄 레이아웃 수정: ${id}`);
return true;
}
return false;
}
/**
*
*/
static registerLayouts(definitions: LayoutDefinition[]): void {
definitions.forEach((definition) => {
this.registerLayout(definition);
});
}
/**
*
*/
static clear(): void {
this.layouts.clear();
this.emitEvent({
type: "registry_cleared",
data: null,
timestamp: new Date(),
});
console.log("🧹 레이아웃 레지스트리 초기화");
}
/**
*
*/
static addEventListener(listener: (event: LayoutRegistryEvent) => void): void {
this.eventListeners.push(listener);
}
/**
*
*/
static removeEventListener(listener: (event: LayoutRegistryEvent) => void): void {
const index = this.eventListeners.indexOf(listener);
if (index > -1) {
this.eventListeners.splice(index, 1);
}
}
/**
*
*/
private static emitEvent(event: LayoutRegistryEvent): void {
this.eventListeners.forEach((listener) => {
try {
listener(event);
} catch (error) {
console.error("레이아웃 레지스트리 이벤트 리스너 오류:", error);
}
});
}
/**
*
*/
static getRegistryInfo(): {
totalLayouts: number;
activeLayouts: number;
categoryCounts: Record<LayoutCategory, number>;
registeredTypes: string[];
} {
const allLayouts = this.getAllLayouts();
const activeLayouts = this.getActiveLayouts();
const categoryCounts = allLayouts.reduce(
(acc, layout) => {
acc[layout.category] = (acc[layout.category] || 0) + 1;
return acc;
},
{} as Record<LayoutCategory, number>,
);
return {
totalLayouts: allLayouts.length,
activeLayouts: activeLayouts.length,
categoryCounts,
registeredTypes: this.getRegisteredTypes(),
};
}
/**
*
*/
private static registerGlobalDevTools(): void {
if (typeof window !== "undefined") {
(window as any).__LAYOUT_REGISTRY__ = {
list: () => {
console.table(
this.getAllLayouts().map((l) => ({
ID: l.id,
Name: l.name,
Category: l.category,
Zones: l.defaultZones?.length || 0,
Tags: l.tags?.join(", ") || "none",
Version: l.metadata?.version || "1.0.0",
Active: l.isActive !== false ? "✅" : "❌",
})),
);
return this.getAllLayouts();
},
get: (id: string) => {
const layout = this.getLayout(id);
if (layout) {
console.group(`📦 Layout: ${id}`);
console.log("Definition:", layout);
console.log("Component:", layout.component);
console.log("Config:", layout.defaultConfig);
console.log("Zones:", layout.defaultZones);
console.groupEnd();
return layout;
} else {
console.warn(`❌ Layout not found: ${id}`);
console.log("Available layouts:", this.getRegisteredTypes());
return null;
}
},
stats: () => {
const info = this.getRegistryInfo();
console.group("📊 Layout Registry Statistics");
console.log(`📦 Total Layouts: ${info.totalLayouts}`);
console.log(`✅ Active Layouts: ${info.activeLayouts}`);
console.log("📂 Categories:", info.categoryCounts);
console.log("🏷️ Registered Types:", info.registeredTypes);
console.groupEnd();
return info;
},
search: (query: string) => {
const results = this.searchLayouts(query);
console.log(`🔍 Search results for "${query}":`, results);
return results;
},
categories: () => {
const byCategory = this.getAllLayouts().reduce(
(acc, layout) => {
if (!acc[layout.category]) acc[layout.category] = [];
acc[layout.category].push(layout.id);
return acc;
},
{} as Record<string, string[]>,
);
console.table(byCategory);
return byCategory;
},
clear: () => {
console.warn("🧹 Clearing layout registry...");
this.clear();
console.log("✅ Registry cleared");
},
reload: () => {
console.log("🔄 Registry reload not implemented yet");
console.log("Try refreshing the page or using HMR");
},
help: () => {
console.group("🛠️ Layout Registry DevTools Help");
console.log("__LAYOUT_REGISTRY__.list() - 모든 레이아웃 목록 표시");
console.log("__LAYOUT_REGISTRY__.get(id) - 특정 레이아웃 상세 정보");
console.log("__LAYOUT_REGISTRY__.stats() - 레지스트리 통계");
console.log("__LAYOUT_REGISTRY__.search(query) - 레이아웃 검색");
console.log("__LAYOUT_REGISTRY__.categories() - 카테고리별 레이아웃");
console.log("__LAYOUT_REGISTRY__.clear() - 레지스트리 초기화");
console.log("__LAYOUT_REGISTRY__.help() - 도움말");
console.groupEnd();
},
};
// 첫 등록 시에만 안내 메시지 출력
if (!(window as any).__LAYOUT_REGISTRY_INITIALIZED__) {
console.log("🛠️ Layout Registry DevTools initialized!");
console.log("Use __LAYOUT_REGISTRY__.help() for available commands");
(window as any).__LAYOUT_REGISTRY_INITIALIZED__ = true;
}
}
}
}
// 레지스트리 이벤트 타입
export interface LayoutRegistryEvent {
type: "layout_registered" | "layout_unregistered" | "layout_updated" | "registry_cleared";
data: LayoutDefinition | null;
timestamp: Date;
}
// 전역 레이아웃 레지스트리 인스턴스 (싱글톤)
export const layoutRegistry = LayoutRegistry;
// 기본 내보내기
export default LayoutRegistry;

View File

@ -0,0 +1,367 @@
"use client";
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
import { LayoutDefinition } from "@/types/layout";
import { LayoutRegistry } from "../LayoutRegistry";
import { ComponentData, LayoutComponent } from "@/types/screen";
import { LayoutZone } from "@/types/layout";
import React from "react";
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
/**
*
*
* :
* 1.
* 2. static layoutDefinition을
* 3. import하면
*/
export class AutoRegisteringLayoutRenderer {
protected props: LayoutRendererProps;
constructor(props: LayoutRendererProps) {
this.props = props;
}
/**
* -
*/
static readonly layoutDefinition: LayoutDefinition;
/**
* -
*/
render(): React.ReactElement {
throw new Error("render() method must be implemented by subclass");
}
/**
* .
*/
getLayoutContainerStyle(): React.CSSProperties {
const { layout, style: propStyle } = this.props;
const style: React.CSSProperties = {
width: layout.size.width,
height: layout.size.height,
position: "relative",
overflow: "hidden",
...propStyle,
};
// 레이아웃 커스텀 스타일 적용
if (layout.style) {
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
}
return style;
}
/**
* CSS .
*/
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
const cssStyle: React.CSSProperties = {};
if (componentStyle.backgroundColor) {
cssStyle.backgroundColor = componentStyle.backgroundColor;
}
if (componentStyle.borderColor) {
cssStyle.borderColor = componentStyle.borderColor;
}
if (componentStyle.borderWidth) {
cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
}
if (componentStyle.borderStyle) {
cssStyle.borderStyle = componentStyle.borderStyle;
}
if (componentStyle.borderRadius) {
cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
}
return cssStyle;
}
/**
* .
*/
getZoneChildren(zoneId: string): ComponentData[] {
return this.props.allComponents.filter((comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId);
}
/**
* .
*/
renderZone(
zone: LayoutZone,
zoneChildren: ComponentData[] = [],
additionalProps: Record<string, any> = {},
): React.ReactElement {
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
// 존 스타일 계산 - 항상 구역 경계 표시
const zoneStyle: React.CSSProperties = {
position: "relative",
// 구역 경계 시각화 - 항상 표시
border: "1px solid #e2e8f0",
borderRadius: "6px",
backgroundColor: "rgba(248, 250, 252, 0.5)",
transition: "all 0.2s ease",
...this.getZoneStyle(zone),
...additionalProps.style,
};
// 디자인 모드일 때 더 강조된 스타일
if (isDesignMode) {
zoneStyle.border = "2px dashed #cbd5e1";
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
}
// 호버 효과를 위한 추가 스타일
const dropZoneStyle: React.CSSProperties = {
minHeight: isDesignMode ? "60px" : "40px",
borderRadius: "4px",
display: "flex",
flexDirection: "column",
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
justifyContent: zoneChildren.length === 0 ? "flex-start" : "flex-start",
color: "#64748b",
fontSize: "12px",
transition: "all 0.2s ease",
padding: "8px",
position: "relative",
};
return (
<div
key={zone.id}
className={`layout-zone ${additionalProps.className || ""}`}
style={zoneStyle}
onClick={(e) => {
e.stopPropagation();
onZoneClick?.(zone.id, e);
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
const componentData = e.dataTransfer.getData("application/json");
if (componentData) {
try {
const component = JSON.parse(componentData);
onComponentDrop?.(zone.id, component, e);
} catch (error) {
console.error("컴포넌트 드롭 데이터 파싱 오류:", error);
}
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
e.currentTarget.style.boxShadow = "0 0 0 2px rgba(59, 130, 246, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(241, 245, 249, 0.8)"
: "rgba(248, 250, 252, 0.5)";
e.currentTarget.style.boxShadow = "none";
}}
{...additionalProps}
>
{/* 존 라벨 */}
<div
className="zone-label"
style={{
position: "absolute",
top: "-2px",
left: "8px",
backgroundColor: isDesignMode ? "#3b82f6" : "#64748b",
color: "white",
fontSize: "10px",
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
fontWeight: "500",
zIndex: 10,
opacity: isDesignMode ? 1 : 0.7,
}}
>
{zone.name || zone.id}
</div>
{/* 드롭존 */}
<div className="drop-zone" style={dropZoneStyle}>
{zoneChildren.length > 0 ? (
zoneChildren.map((child) => (
<DynamicComponentRenderer
key={child.id}
component={child}
allComponents={this.props.allComponents}
isDesignMode={isDesignMode}
/>
))
) : (
<div className="empty-zone-indicator" style={{ textAlign: "center", opacity: 0.6 }}>
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : ""}
</div>
)}
</div>
</div>
);
}
/**
* .
*/
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
const style: React.CSSProperties = {};
if (zone.size) {
if (zone.size.width) {
style.width = typeof zone.size.width === "number" ? `${zone.size.width}px` : zone.size.width;
}
if (zone.size.height) {
style.height = typeof zone.size.height === "number" ? `${zone.size.height}px` : zone.size.height;
}
if (zone.size.minWidth) {
style.minWidth = typeof zone.size.minWidth === "number" ? `${zone.size.minWidth}px` : zone.size.minWidth;
}
if (zone.size.minHeight) {
style.minHeight = typeof zone.size.minHeight === "number" ? `${zone.size.minHeight}px` : zone.size.minHeight;
}
}
return style;
}
/**
*
*/
private static registeredLayouts = new Set<string>();
/**
*
*/
static registerSelf(): void {
const definition = this.layoutDefinition;
if (!definition) {
console.error(`${this.name}: layoutDefinition이 정의되지 않았습니다.`);
return;
}
if (this.registeredLayouts.has(definition.id)) {
console.warn(`⚠️ ${definition.id} 레이아웃이 이미 등록되어 있습니다.`);
return;
}
try {
// 레지스트리에 등록
LayoutRegistry.registerLayout(definition);
this.registeredLayouts.add(definition.id);
console.log(`✅ 자동 등록 완료: ${definition.id} (${definition.name})`);
// 개발 모드에서 추가 정보 출력
if (process.env.NODE_ENV === "development") {
console.log(`📦 ${definition.id}:`, {
name: definition.name,
category: definition.category,
zones: definition.defaultZones?.length || 0,
tags: definition.tags?.join(", ") || "none",
});
}
} catch (error) {
console.error(`${definition.id} 레이아웃 등록 실패:`, error);
}
}
/**
* ( Hot Reload용)
*/
static unregisterSelf(): void {
const definition = this.layoutDefinition;
if (definition && this.registeredLayouts.has(definition.id)) {
LayoutRegistry.unregisterLayout(definition.id);
this.registeredLayouts.delete(definition.id);
console.log(`🗑️ 등록 해제: ${definition.id}`);
}
}
/**
* Hot Reload ( )
*/
static reloadSelf(): void {
if (process.env.NODE_ENV === "development") {
this.unregisterSelf();
this.registerSelf();
console.log(`🔄 Hot Reload: ${this.layoutDefinition?.id}`);
}
}
/**
*
*/
static getRegisteredLayouts(): string[] {
return Array.from(this.registeredLayouts);
}
/**
*
*/
static validateDefinition(): { isValid: boolean; errors: string[]; warnings: string[] } {
const definition = this.layoutDefinition;
if (!definition) {
return {
isValid: false,
errors: ["layoutDefinition이 정의되지 않았습니다."],
warnings: [],
};
}
const errors: string[] = [];
const warnings: string[] = [];
// 필수 필드 검사
if (!definition.id) errors.push("ID가 필요합니다.");
if (!definition.name) errors.push("이름이 필요합니다.");
if (!definition.component) errors.push("컴포넌트가 필요합니다.");
if (!definition.category) errors.push("카테고리가 필요합니다.");
// 권장사항 검사
if (!definition.description || definition.description.length < 10) {
warnings.push("설명은 10자 이상 권장됩니다.");
}
if (!definition.defaultZones || definition.defaultZones.length === 0) {
warnings.push("기본 존 정의가 권장됩니다.");
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
}
/**
* Hot Module Replacement
*/
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
// HMR API가 있는 경우 등록
if ((module as any).hot) {
(module as any).hot.accept();
// 글로벌 Hot Reload 함수 등록
(window as any).__reloadLayout__ = (layoutId: string) => {
const layouts = AutoRegisteringLayoutRenderer.getRegisteredLayouts();
console.log(`🔄 Available layouts for reload:`, layouts);
// TODO: 특정 레이아웃만 리로드하는 로직 구현
};
}
}

View File

@ -0,0 +1,367 @@
"use client";
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
import { LayoutDefinition } from "@/types/layout";
import { LayoutRegistry } from "../LayoutRegistry";
import { ComponentData, LayoutComponent } from "@/types/screen";
import { LayoutZone } from "@/types/layout";
import React from "react";
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
/**
*
*
* :
* 1.
* 2. static layoutDefinition을
* 3. import하면
*/
export class AutoRegisteringLayoutRenderer {
protected props: LayoutRendererProps;
constructor(props: LayoutRendererProps) {
this.props = props;
}
/**
* -
*/
static readonly layoutDefinition: LayoutDefinition;
/**
* -
*/
render(): React.ReactElement {
throw new Error("render() method must be implemented by subclass");
}
/**
* .
*/
getLayoutContainerStyle(): React.CSSProperties {
const { layout, style: propStyle } = this.props;
const style: React.CSSProperties = {
width: layout.size.width,
height: layout.size.height,
position: "relative",
overflow: "hidden",
...propStyle,
};
// 레이아웃 커스텀 스타일 적용
if (layout.style) {
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
}
return style;
}
/**
* CSS .
*/
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
const cssStyle: React.CSSProperties = {};
if (componentStyle.backgroundColor) {
cssStyle.backgroundColor = componentStyle.backgroundColor;
}
if (componentStyle.borderColor) {
cssStyle.borderColor = componentStyle.borderColor;
}
if (componentStyle.borderWidth) {
cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
}
if (componentStyle.borderStyle) {
cssStyle.borderStyle = componentStyle.borderStyle;
}
if (componentStyle.borderRadius) {
cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
}
return cssStyle;
}
/**
* .
*/
getZoneChildren(zoneId: string): ComponentData[] {
return this.props.allComponents.filter((comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId);
}
/**
* .
*/
renderZone(
zone: LayoutZone,
zoneChildren: ComponentData[] = [],
additionalProps: Record<string, any> = {},
): React.ReactElement {
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
// 존 스타일 계산 - 항상 구역 경계 표시
const zoneStyle: React.CSSProperties = {
position: "relative",
// 구역 경계 시각화 - 항상 표시
border: "1px solid #e2e8f0",
borderRadius: "6px",
backgroundColor: "rgba(248, 250, 252, 0.5)",
transition: "all 0.2s ease",
...this.getZoneStyle(zone),
...additionalProps.style,
};
// 디자인 모드일 때 더 강조된 스타일
if (isDesignMode) {
zoneStyle.border = "2px dashed #cbd5e1";
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
}
// 호버 효과를 위한 추가 스타일
const dropZoneStyle: React.CSSProperties = {
minHeight: isDesignMode ? "60px" : "40px",
borderRadius: "4px",
display: "flex",
flexDirection: "column",
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
justifyContent: zoneChildren.length === 0 ? "flex-start" : "flex-start",
color: "#64748b",
fontSize: "12px",
transition: "all 0.2s ease",
padding: "8px",
position: "relative",
};
return (
<div
key={zone.id}
className={`layout-zone ${additionalProps.className || ""}`}
style={zoneStyle}
onClick={(e) => {
e.stopPropagation();
onZoneClick?.(zone.id, e);
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
const componentData = e.dataTransfer.getData("application/json");
if (componentData) {
try {
const component = JSON.parse(componentData);
onComponentDrop?.(zone.id, component, e);
} catch (error) {
console.error("컴포넌트 드롭 데이터 파싱 오류:", error);
}
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
e.currentTarget.style.boxShadow = "0 0 0 2px rgba(59, 130, 246, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(241, 245, 249, 0.8)"
: "rgba(248, 250, 252, 0.5)";
e.currentTarget.style.boxShadow = "none";
}}
{...additionalProps}
>
{/* 존 라벨 */}
<div
className="zone-label"
style={{
position: "absolute",
top: "-2px",
left: "8px",
backgroundColor: isDesignMode ? "#3b82f6" : "#64748b",
color: "white",
fontSize: "10px",
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
fontWeight: "500",
zIndex: 10,
opacity: isDesignMode ? 1 : 0.7,
}}
>
{zone.name || zone.id}
</div>
{/* 드롭존 */}
<div className="drop-zone" style={dropZoneStyle}>
{zoneChildren.length > 0 ? (
zoneChildren.map((child) => (
<DynamicComponentRenderer
key={child.id}
component={child}
allComponents={this.props.allComponents}
isDesignMode={isDesignMode}
/>
))
) : (
<div className="empty-zone-indicator" style={{ textAlign: "center", opacity: 0.6 }}>
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : ""}
</div>
)}
</div>
</div>
);
}
/**
* .
*/
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
const style: React.CSSProperties = {};
if (zone.size) {
if (zone.size.width) {
style.width = typeof zone.size.width === "number" ? `${zone.size.width}px` : zone.size.width;
}
if (zone.size.height) {
style.height = typeof zone.size.height === "number" ? `${zone.size.height}px` : zone.size.height;
}
if (zone.size.minWidth) {
style.minWidth = typeof zone.size.minWidth === "number" ? `${zone.size.minWidth}px` : zone.size.minWidth;
}
if (zone.size.minHeight) {
style.minHeight = typeof zone.size.minHeight === "number" ? `${zone.size.minHeight}px` : zone.size.minHeight;
}
}
return style;
}
/**
*
*/
private static registeredLayouts = new Set<string>();
/**
*
*/
static registerSelf(): void {
const definition = this.layoutDefinition;
if (!definition) {
console.error(`${this.name}: layoutDefinition이 정의되지 않았습니다.`);
return;
}
if (this.registeredLayouts.has(definition.id)) {
console.warn(`⚠️ ${definition.id} 레이아웃이 이미 등록되어 있습니다.`);
return;
}
try {
// 레지스트리에 등록
LayoutRegistry.registerLayout(definition);
this.registeredLayouts.add(definition.id);
console.log(`✅ 자동 등록 완료: ${definition.id} (${definition.name})`);
// 개발 모드에서 추가 정보 출력
if (process.env.NODE_ENV === "development") {
console.log(`📦 ${definition.id}:`, {
name: definition.name,
category: definition.category,
zones: definition.defaultZones?.length || 0,
tags: definition.tags?.join(", ") || "none",
});
}
} catch (error) {
console.error(`${definition.id} 레이아웃 등록 실패:`, error);
}
}
/**
* ( Hot Reload용)
*/
static unregisterSelf(): void {
const definition = this.layoutDefinition;
if (definition && this.registeredLayouts.has(definition.id)) {
LayoutRegistry.unregisterLayout(definition.id);
this.registeredLayouts.delete(definition.id);
console.log(`🗑️ 등록 해제: ${definition.id}`);
}
}
/**
* Hot Reload ( )
*/
static reloadSelf(): void {
if (process.env.NODE_ENV === "development") {
this.unregisterSelf();
this.registerSelf();
console.log(`🔄 Hot Reload: ${this.layoutDefinition?.id}`);
}
}
/**
*
*/
static getRegisteredLayouts(): string[] {
return Array.from(this.registeredLayouts);
}
/**
*
*/
static validateDefinition(): { isValid: boolean; errors: string[]; warnings: string[] } {
const definition = this.layoutDefinition;
if (!definition) {
return {
isValid: false,
errors: ["layoutDefinition이 정의되지 않았습니다."],
warnings: [],
};
}
const errors: string[] = [];
const warnings: string[] = [];
// 필수 필드 검사
if (!definition.id) errors.push("ID가 필요합니다.");
if (!definition.name) errors.push("이름이 필요합니다.");
if (!definition.component) errors.push("컴포넌트가 필요합니다.");
if (!definition.category) errors.push("카테고리가 필요합니다.");
// 권장사항 검사
if (!definition.description || definition.description.length < 10) {
warnings.push("설명은 10자 이상 권장됩니다.");
}
if (!definition.defaultZones || definition.defaultZones.length === 0) {
warnings.push("기본 존 정의가 권장됩니다.");
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
}
/**
* Hot Module Replacement
*/
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
// HMR API가 있는 경우 등록
if ((module as any).hot) {
(module as any).hot.accept();
// 글로벌 Hot Reload 함수 등록
(window as any).__reloadLayout__ = (layoutId: string) => {
const layouts = AutoRegisteringLayoutRenderer.getRegisteredLayouts();
console.log(`🔄 Available layouts for reload:`, layouts);
// TODO: 특정 레이아웃만 리로드하는 로직 구현
};
}
}

View File

@ -0,0 +1,304 @@
"use client";
import React from "react";
import { ComponentData, LayoutComponent } from "@/types/screen";
import { LayoutZone } from "@/types/layout";
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
export interface LayoutRendererProps {
layout: LayoutComponent;
allComponents: ComponentData[];
isDesignMode?: boolean;
isSelected?: boolean;
onClick?: (e: React.MouseEvent) => void;
onZoneClick?: (zoneId: string, e: React.MouseEvent) => void;
onComponentDrop?: (zoneId: string, component: ComponentData, e: React.DragEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererProps> {
abstract render(): React.ReactElement;
/**
* .
*/
protected renderZone(
zone: LayoutZone,
zoneChildren: ComponentData[] = [],
additionalProps: Record<string, any> = {},
): React.ReactElement {
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
// 존 스타일 계산 - 항상 구역 경계 표시
const zoneStyle: React.CSSProperties = {
position: "relative",
// 구역 경계 시각화 - 항상 표시
border: "1px solid #e2e8f0",
borderRadius: "6px",
backgroundColor: "rgba(248, 250, 252, 0.5)",
transition: "all 0.2s ease",
...this.getZoneStyle(zone),
...additionalProps.style,
};
// 디자인 모드일 때 더 강조된 스타일
if (isDesignMode) {
zoneStyle.border = "2px dashed #cbd5e1";
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
}
// 호버 효과를 위한 추가 스타일
const dropZoneStyle: React.CSSProperties = {
minHeight: isDesignMode ? "60px" : "40px",
borderRadius: "4px",
display: "flex",
flexDirection: "column",
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
justifyContent: zoneChildren.length === 0 ? "center" : "flex-start",
color: "#64748b",
fontSize: "12px",
transition: "all 0.2s ease",
padding: "8px",
position: "relative",
};
return (
<div
key={zone.id}
className={`layout-zone ${isDesignMode ? "design-mode" : ""} ${additionalProps.className || ""}`}
data-zone-id={zone.id}
style={zoneStyle}
onClick={(e) => {
e.stopPropagation();
onZoneClick?.(zone.id, e);
}}
onMouseEnter={(e) => {
const element = e.currentTarget;
element.style.borderColor = "#3b82f6";
element.style.backgroundColor = "rgba(59, 130, 246, 0.02)";
element.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)";
}}
onMouseLeave={(e) => {
const element = e.currentTarget;
element.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
element.style.backgroundColor = isDesignMode ? "rgba(241, 245, 249, 0.8)" : "rgba(248, 250, 252, 0.5)";
element.style.boxShadow = "none";
}}
onDrop={this.handleDrop(zone.id)}
onDragOver={this.handleDragOver}
onDragEnter={this.handleDragEnter}
onDragLeave={this.handleDragLeave}
>
{/* 구역 라벨 - 항상 표시 */}
<div
className="zone-label"
style={{
position: "absolute",
top: "-12px",
left: "8px",
backgroundColor: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: "12px",
padding: "2px 8px",
fontSize: "10px",
fontWeight: "500",
color: "#6b7280",
zIndex: 10,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
}}
>
{zone.name || zone.id}
</div>
<div className="zone-content" style={dropZoneStyle}>
{zoneChildren.length === 0 && isDesignMode ? (
<div className="drop-placeholder">{zone.name} </div>
) : zoneChildren.length === 0 ? (
<div
className="empty-zone-indicator"
style={{
color: "#9ca3af",
fontSize: "11px",
textAlign: "center",
fontStyle: "italic",
}}
>
</div>
) : (
zoneChildren.map((child) => (
<DynamicComponentRenderer
key={child.id}
component={child}
allComponents={this.props.allComponents}
isDesignMode={isDesignMode}
/>
))
)}
</div>
</div>
);
}
/**
* .
*/
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
const style: React.CSSProperties = {};
// 크기 설정
if (typeof zone.size.width === "number") {
style.width = `${zone.size.width}px`;
} else {
style.width = zone.size.width;
}
if (typeof zone.size.height === "number") {
style.height = `${zone.size.height}px`;
} else {
style.height = zone.size.height;
}
// 최소/최대 크기
if (zone.size.minWidth) style.minWidth = `${zone.size.minWidth}px`;
if (zone.size.minHeight) style.minHeight = `${zone.size.minHeight}px`;
if (zone.size.maxWidth) style.maxWidth = `${zone.size.maxWidth}px`;
if (zone.size.maxHeight) style.maxHeight = `${zone.size.maxHeight}px`;
// 커스텀 스타일 적용
if (zone.style) {
Object.assign(style, this.convertComponentStyleToCSS(zone.style));
}
return style;
}
/**
* ComponentStyle을 CSS .
*/
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
const cssStyle: React.CSSProperties = {};
// 여백
if (componentStyle.margin) cssStyle.margin = componentStyle.margin;
if (componentStyle.padding) cssStyle.padding = componentStyle.padding;
// 테두리
if (componentStyle.borderWidth) cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
if (componentStyle.borderColor) cssStyle.borderColor = componentStyle.borderColor;
if (componentStyle.borderStyle) cssStyle.borderStyle = componentStyle.borderStyle;
if (componentStyle.borderRadius) cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
// 배경
if (componentStyle.backgroundColor) cssStyle.backgroundColor = componentStyle.backgroundColor;
// 텍스트
if (componentStyle.color) cssStyle.color = componentStyle.color;
if (componentStyle.fontSize) cssStyle.fontSize = `${componentStyle.fontSize}px`;
if (componentStyle.fontWeight) cssStyle.fontWeight = componentStyle.fontWeight;
if (componentStyle.textAlign) cssStyle.textAlign = componentStyle.textAlign;
return cssStyle;
}
/**
* .
*/
protected getLayoutContainerStyle(): React.CSSProperties {
const { layout, style: propStyle } = this.props;
const style: React.CSSProperties = {
width: layout.size.width,
height: layout.size.height,
position: "relative",
overflow: "hidden",
...propStyle,
};
// 레이아웃 커스텀 스타일 적용
if (layout.style) {
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
}
return style;
}
/**
* .
*/
protected getZoneChildren(zoneId: string): ComponentData[] {
return this.props.allComponents.filter(
(component) => component.parentId === this.props.layout.id && (component as any).zoneId === zoneId,
);
}
/**
*
*/
private handleDrop = (zoneId: string) => (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 드롭존 하이라이트 제거
this.removeDragHighlight(e.currentTarget as HTMLElement);
try {
const componentData = e.dataTransfer.getData("application/json");
if (componentData) {
const component = JSON.parse(componentData);
this.props.onComponentDrop?.(zoneId, component, e);
}
} catch (error) {
console.error("드롭 데이터 파싱 오류:", error);
}
};
private handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 드롭존 하이라이트 추가
this.addDragHighlight(e.currentTarget as HTMLElement);
};
private handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
private handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 실제로 존을 벗어났는지 확인
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
this.removeDragHighlight(e.currentTarget as HTMLElement);
}
};
/**
*
*/
private addDragHighlight(element: HTMLElement) {
element.classList.add("drag-over");
element.style.borderColor = "#3b82f6";
element.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
}
/**
*
*/
private removeDragHighlight(element: HTMLElement) {
element.classList.remove("drag-over");
element.style.borderColor = "";
element.style.backgroundColor = "";
}
}

View File

@ -0,0 +1,139 @@
"use client";
import React from "react";
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
export default class FlexboxLayoutRenderer extends BaseLayoutRenderer {
render(): React.ReactElement {
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
if (!layout.layoutConfig.flexbox) {
return <div className="error-layout"> .</div>;
}
const flexConfig = layout.layoutConfig.flexbox;
const containerStyle = this.getLayoutContainerStyle();
// 플렉스박스 스타일 설정
const flexStyle: React.CSSProperties = {
...containerStyle,
display: "flex",
flexDirection: flexConfig.direction,
justifyContent: flexConfig.justify,
alignItems: flexConfig.align,
flexWrap: flexConfig.wrap,
gap: `${flexConfig.gap}px`,
};
// 디자인 모드 스타일
if (isDesignMode) {
flexStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
flexStyle.borderRadius = "8px";
flexStyle.padding = "8px";
}
return (
<div
className={`flexbox-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
style={flexStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={this.props.onDragStart}
onDragEnd={this.props.onDragEnd}
>
{layout.zones.map((zone, index) => {
const zoneChildren = this.getZoneChildren(zone.id);
// 플렉스 아이템 스타일 설정
const zoneStyle: React.CSSProperties = {
flex: this.calculateFlexValue(zone, flexConfig.direction),
};
return this.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "flex-zone",
});
})}
{/* 존이 없을 때 안내 메시지 */}
{layout.zones.length === 0 && (
<div
className="empty-flex-container"
style={{
flex: 1,
border: isDesignMode ? "2px dashed #cbd5e1" : "1px solid #e2e8f0",
borderRadius: "8px",
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: isDesignMode ? "14px" : "12px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(148, 163, 184, 0.05)"
: "rgba(248, 250, 252, 0.5)";
}}
>
{isDesignMode ? "플렉스박스 레이아웃에 존을 추가하세요" : "빈 레이아웃"}
</div>
)}
</div>
);
}
/**
* flex .
*/
private calculateFlexValue(zone: any, direction: string): string {
// 존의 크기에 따라 flex 값 결정
if (direction === "row" || direction === "row-reverse") {
// 가로 방향: width를 기준으로 flex 값 계산
if (typeof zone.size.width === "string") {
if (zone.size.width.includes("fr")) {
return zone.size.width.replace("fr", "");
} else if (zone.size.width.includes("%")) {
const percent = parseInt(zone.size.width.replace("%", ""));
return `0 0 ${percent}%`;
} else if (zone.size.width.includes("px")) {
return `0 0 ${zone.size.width}`;
}
} else if (typeof zone.size.width === "number") {
return `0 0 ${zone.size.width}px`;
}
} else {
// 세로 방향: height를 기준으로 flex 값 계산
if (typeof zone.size.height === "string") {
if (zone.size.height.includes("fr")) {
return zone.size.height.replace("fr", "");
} else if (zone.size.height.includes("%")) {
const percent = parseInt(zone.size.height.replace("%", ""));
return `0 0 ${percent}%`;
} else if (zone.size.height.includes("px")) {
return `0 0 ${zone.size.height}`;
}
} else if (typeof zone.size.height === "number") {
return `0 0 ${zone.size.height}px`;
}
}
// 기본값: 균등 분할
return "1";
}
}
// React 컴포넌트로 래핑
export const FlexboxLayout: React.FC<LayoutRendererProps> = (props) => {
const renderer = new FlexboxLayoutRenderer(props);
return renderer.render();
};

View File

@ -0,0 +1,133 @@
"use client";
import React from "react";
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
export default class GridLayoutRenderer extends BaseLayoutRenderer {
render(): React.ReactElement {
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
if (!layout.layoutConfig.grid) {
return <div className="error-layout"> .</div>;
}
const gridConfig = layout.layoutConfig.grid;
const containerStyle = this.getLayoutContainerStyle();
// 그리드 스타일 설정
const gridStyle: React.CSSProperties = {
...containerStyle,
display: "grid",
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
gap: `${gridConfig.gap}px`,
gridRowGap: gridConfig.rowGap ? `${gridConfig.rowGap}px` : undefined,
gridColumnGap: gridConfig.columnGap ? `${gridConfig.columnGap}px` : undefined,
gridAutoRows: gridConfig.autoRows,
gridAutoColumns: gridConfig.autoColumns,
};
// 디자인 모드 스타일
if (isDesignMode) {
gridStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
gridStyle.borderRadius = "8px";
}
return (
<div
className={`grid-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
style={gridStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={this.props.onDragStart}
onDragEnd={this.props.onDragEnd}
>
{layout.zones.map((zone) => {
const zoneChildren = this.getZoneChildren(zone.id);
// 그리드 위치 설정
const zoneStyle: React.CSSProperties = {
gridRow: zone.position.row !== undefined ? zone.position.row + 1 : undefined,
gridColumn: zone.position.column !== undefined ? zone.position.column + 1 : undefined,
};
return this.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "grid-zone",
});
})}
{/* 디자인 모드에서 빈 그리드 셀 표시 */}
{isDesignMode && this.renderEmptyGridCells()}
</div>
);
}
/**
* .
*/
private renderEmptyGridCells(): React.ReactElement[] {
const { layout } = this.props;
const gridConfig = layout.layoutConfig.grid!;
const totalCells = gridConfig.rows * gridConfig.columns;
const occupiedCells = new Set(
layout.zones
.map((zone) =>
zone.position.row !== undefined && zone.position.column !== undefined
? zone.position.row * gridConfig.columns + zone.position.column
: -1,
)
.filter((index) => index >= 0),
);
const emptyCells: React.ReactElement[] = [];
for (let i = 0; i < totalCells; i++) {
if (!occupiedCells.has(i)) {
const row = Math.floor(i / gridConfig.columns);
const column = i % gridConfig.columns;
emptyCells.push(
<div
key={`empty-${i}`}
className="empty-grid-cell"
style={{
gridRow: row + 1,
gridColumn: column + 1,
border: isDesignMode ? "1px dashed #cbd5e1" : "1px solid #f1f5f9",
borderRadius: "4px",
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
color: "#94a3b8",
minHeight: "40px",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
e.currentTarget.style.borderColor = "#3b82f6";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(148, 163, 184, 0.05)"
: "rgba(248, 250, 252, 0.3)";
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#f1f5f9";
}}
>
{isDesignMode ? `${row + 1},${column + 1}` : ""}
</div>,
);
}
}
return emptyCells;
}
}
// React 컴포넌트로 래핑
export const GridLayout: React.FC<LayoutRendererProps> = (props) => {
const renderer = new GridLayoutRenderer(props);
return renderer.render();
};

View File

@ -0,0 +1,204 @@
"use client";
import React, { useState, useCallback } from "react";
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
export default class SplitLayoutRenderer extends BaseLayoutRenderer {
render(): React.ReactElement {
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
if (!layout.layoutConfig.split) {
return <div className="error-layout"> .</div>;
}
return (
<SplitLayoutComponent
layout={layout}
isDesignMode={isDesignMode}
isSelected={isSelected}
onClick={onClick}
className={className}
renderer={this}
/>
);
}
}
interface SplitLayoutComponentProps {
layout: any;
isDesignMode?: boolean;
isSelected?: boolean;
onClick?: (e: React.MouseEvent) => void;
className?: string;
renderer: SplitLayoutRenderer;
}
const SplitLayoutComponent: React.FC<SplitLayoutComponentProps> = ({
layout,
isDesignMode,
isSelected,
onClick,
className,
renderer,
}) => {
const splitConfig = layout.layoutConfig.split;
const [sizes, setSizes] = useState(splitConfig.ratio || [50, 50]);
const [isDragging, setIsDragging] = useState(false);
const containerStyle = renderer.getLayoutContainerStyle();
// 분할 컨테이너 스타일
const splitStyle: React.CSSProperties = {
...containerStyle,
display: "flex",
flexDirection: splitConfig.direction === "horizontal" ? "row" : "column",
overflow: "hidden",
};
// 디자인 모드 스타일
if (isDesignMode) {
splitStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
splitStyle.borderRadius = "8px";
}
// 스플리터 드래그 핸들러
const handleSplitterDrag = useCallback(
(e: React.MouseEvent, index: number) => {
if (!splitConfig.resizable || !isDesignMode) return;
setIsDragging(true);
const startPos = splitConfig.direction === "horizontal" ? e.clientX : e.clientY;
const startSizes = [...sizes];
const handleMouseMove = (moveEvent: MouseEvent) => {
const currentPos = splitConfig.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - startPos;
const containerSize =
splitConfig.direction === "horizontal"
? (e.currentTarget as HTMLElement).parentElement!.clientWidth
: (e.currentTarget as HTMLElement).parentElement!.clientHeight;
const deltaPercent = (delta / containerSize) * 100;
const newSizes = [...startSizes];
newSizes[index] = Math.max(splitConfig.minSize?.[index] || 10, Math.min(90, startSizes[index] + deltaPercent));
newSizes[index + 1] = Math.max(
splitConfig.minSize?.[index + 1] || 10,
Math.min(90, startSizes[index + 1] - deltaPercent),
);
setSizes(newSizes);
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[splitConfig, sizes, isDesignMode],
);
return (
<div
className={`split-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
style={splitStyle}
onClick={onClick}
draggable={isDesignMode && !isDragging}
onDragStart={(e) => !isDragging && renderer.props.onDragStart?.(e)}
onDragEnd={(e) => !isDragging && renderer.props.onDragEnd?.(e)}
>
{layout.zones.map((zone: any, index: number) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
const isHorizontal = splitConfig.direction === "horizontal";
// 패널 크기 계산
const panelSize = sizes[index] || 100 / layout.zones.length;
const panelStyle: React.CSSProperties = {
[isHorizontal ? "width" : "height"]: `${panelSize}%`,
[isHorizontal ? "height" : "width"]: "100%",
overflow: "auto",
};
return (
<React.Fragment key={zone.id}>
{/* 패널 */}
{renderer.renderZone(zone, zoneChildren, {
style: panelStyle,
className: "split-panel",
})}
{/* 스플리터 (마지막 패널 제외) */}
{index < layout.zones.length - 1 && (
<div
className={`splitter ${isHorizontal ? "horizontal" : "vertical"} ${isDragging ? "dragging" : ""}`}
style={{
[isHorizontal ? "width" : "height"]: `${splitConfig.splitterSize || 4}px`,
[isHorizontal ? "height" : "width"]: "100%",
backgroundColor: "#e2e8f0",
cursor: isHorizontal ? "col-resize" : "row-resize",
position: "relative",
zIndex: 10,
...(isDragging && {
backgroundColor: "#3b82f6",
}),
}}
onMouseDown={(e) => handleSplitterDrag(e, index)}
>
{/* 스플리터 핸들 */}
<div
className="splitter-handle"
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
[isHorizontal ? "width" : "height"]: "20px",
[isHorizontal ? "height" : "width"]: "4px",
backgroundColor: "#94a3b8",
borderRadius: "2px",
opacity: splitConfig.resizable && isDesignMode ? 1 : 0,
transition: "opacity 0.2s ease",
}}
/>
</div>
)}
</React.Fragment>
);
})}
{/* 디자인 모드에서 존이 없을 때 안내 메시지 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-split-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
</div>
)}
</div>
);
};
// React 컴포넌트로 래핑
export const SplitLayout: React.FC<LayoutRendererProps> = (props) => {
const renderer = new SplitLayoutRenderer(props);
return renderer.render();
};

View File

@ -0,0 +1,178 @@
"use client";
import React, { useState } from "react";
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { X } from "lucide-react";
export default class TabsLayoutRenderer extends BaseLayoutRenderer {
render(): React.ReactElement {
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
if (!layout.layoutConfig.tabs) {
return <div className="error-layout"> .</div>;
}
return (
<TabsLayoutComponent
layout={layout}
isDesignMode={isDesignMode}
isSelected={isSelected}
onClick={onClick}
className={className}
renderer={this}
/>
);
}
}
interface TabsLayoutComponentProps {
layout: any;
isDesignMode?: boolean;
isSelected?: boolean;
onClick?: (e: React.MouseEvent) => void;
className?: string;
renderer: TabsLayoutRenderer;
}
const TabsLayoutComponent: React.FC<TabsLayoutComponentProps> = ({
layout,
isDesignMode,
isSelected,
onClick,
className,
renderer,
}) => {
const tabsConfig = layout.layoutConfig.tabs;
const [activeTab, setActiveTab] = useState(tabsConfig.defaultTab || layout.zones[0]?.id || "");
const containerStyle = renderer.getLayoutContainerStyle();
// 탭 컨테이너 스타일
const tabsStyle: React.CSSProperties = {
...containerStyle,
display: "flex",
flexDirection: tabsConfig.position === "left" || tabsConfig.position === "right" ? "row" : "column",
};
// 디자인 모드 스타일
if (isDesignMode) {
tabsStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
tabsStyle.borderRadius = "8px";
}
// 탭 사이즈 클래스
const sizeClass = tabsConfig.size === "sm" ? "text-sm" : tabsConfig.size === "lg" ? "text-lg" : "";
return (
<div
className={`tabs-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
style={tabsStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={renderer.props.onDragStart}
onDragEnd={renderer.props.onDragEnd}
>
{layout.zones.length === 0 ? (
/* 디자인 모드에서 존이 없을 때 안내 메시지 */
isDesignMode && (
<div
className="empty-tabs-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "200px",
padding: "20px",
textAlign: "center",
}}
>
</div>
)
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation={tabsConfig.position === "left" || tabsConfig.position === "right" ? "vertical" : "horizontal"}
className="flex h-full w-full flex-col"
>
{/* 탭 목록 */}
<TabsList
className={` ${sizeClass} ${tabsConfig.position === "bottom" ? "order-2" : ""} ${tabsConfig.position === "right" ? "order-2" : ""} ${tabsConfig.variant === "pills" ? "bg-gray-100" : ""} ${tabsConfig.variant === "underline" ? "border-b" : ""} `}
style={{
flexShrink: 0,
justifyContent:
tabsConfig.position === "left" || tabsConfig.position === "right" ? "flex-start" : "center",
}}
>
{layout.zones.map((zone: any) => (
<div key={zone.id} className="flex items-center">
<TabsTrigger
value={zone.id}
className={` ${sizeClass} ${tabsConfig.variant === "pills" ? "rounded-full" : ""} ${tabsConfig.variant === "underline" ? "border-b-2 border-transparent data-[state=active]:border-blue-500" : ""} `}
>
{zone.name}
</TabsTrigger>
{/* 닫기 버튼 (설정에서 허용한 경우) */}
{tabsConfig.closable && isDesignMode && layout.zones.length > 1 && (
<button
className="ml-1 rounded p-1 hover:bg-gray-200"
onClick={(e) => {
e.stopPropagation();
// 탭 닫기 로직 (실제 구현 시 필요)
console.log("탭 닫기:", zone.id);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</TabsList>
{/* 탭 컨텐츠 */}
<div className="flex-1 overflow-auto">
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
return (
<TabsContent
key={zone.id}
value={zone.id}
className="h-full p-2"
style={{
margin: 0,
borderRadius: "6px",
backgroundColor: "rgba(248, 250, 252, 0.3)",
}}
>
{renderer.renderZone(zone, zoneChildren, {
style: {
height: "100%",
minHeight: "100px",
},
className: "tab-panel",
})}
</TabsContent>
);
})}
</div>
</Tabs>
)}
</div>
);
};
// React 컴포넌트로 래핑
export const TabsLayout: React.FC<LayoutRendererProps> = (props) => {
const renderer = new TabsLayoutRenderer(props);
return renderer.render();
};

View File

@ -0,0 +1,164 @@
"use client";
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* accordion
*/
export interface AccordionLayoutProps extends LayoutRendererProps {
renderer: any; // AccordionLayoutRenderer 타입
}
export const AccordionLayout: React.FC<AccordionLayoutProps> = ({
layout,
isDesignMode = false,
isSelected = false,
onClick,
className = "",
renderer,
...props
}) => {
if (!layout.layoutConfig.accordion) {
return (
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
<div className="text-center text-red-600">
<div className="font-medium">accordion .</div>
<div className="mt-1 text-sm">layoutConfig.accordion가 .</div>
</div>
</div>
);
}
const accordionConfig = layout.layoutConfig.accordion || { defaultExpanded: [], multiple: false };
const containerStyle = renderer.getLayoutContainerStyle();
// accordion 컨테이너 스타일
const accordionStyle: React.CSSProperties = {
...containerStyle,
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
overflow: "hidden",
};
// 디자인 모드 스타일
if (isDesignMode) {
accordionStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
accordionStyle.borderRadius = "8px";
accordionStyle.padding = "4px";
}
return (
<div
className={`accordion-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={accordionStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any, index: number) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
const isExpanded = accordionConfig.defaultExpanded?.includes(zone.id) || index === 0;
return (
<AccordionSection
key={zone.id}
zone={zone}
isExpanded={isExpanded}
isDesignMode={isDesignMode}
renderer={renderer}
zoneChildren={zoneChildren}
/>
);
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{layout.zones.length === 0 && (
<div
className="empty-accordion-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
{isDesignMode ? "아코디언에 존을 추가하세요" : "빈 아코디언"}
</div>
)}
</div>
);
};
/**
*
*/
const AccordionSection: React.FC<{
zone: any;
isExpanded: boolean;
isDesignMode: boolean;
renderer: any;
zoneChildren: any;
}> = ({ zone, isExpanded: initialExpanded, isDesignMode, renderer, zoneChildren }) => {
const [isExpanded, setIsExpanded] = React.useState(initialExpanded);
const headerStyle: React.CSSProperties = {
padding: "12px 16px",
backgroundColor: "#f8fafc",
borderBottom: "1px solid #e2e8f0",
cursor: "pointer",
userSelect: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "14px",
fontWeight: 500,
borderRadius: isDesignMode ? "4px" : "0",
margin: isDesignMode ? "2px" : "0",
};
const contentStyle: React.CSSProperties = {
padding: isExpanded ? "12px 16px" : "0 16px",
maxHeight: isExpanded ? "500px" : "0",
overflow: "hidden",
transition: "all 0.3s ease",
backgroundColor: "#ffffff",
borderRadius: isDesignMode ? "4px" : "0",
margin: isDesignMode ? "2px" : "0",
};
return (
<div className="accordion-section" style={{ borderRadius: isDesignMode ? "6px" : "0" }}>
<div className="accordion-header" style={headerStyle} onClick={() => setIsExpanded(!isExpanded)}>
<span>{zone.name}</span>
<span
style={{
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
fontSize: "12px",
}}
>
</span>
</div>
<div className="accordion-content" style={contentStyle}>
{renderer.renderZone(zone, zoneChildren, {
style: { minHeight: isExpanded ? "50px" : "0" },
className: "accordion-zone-content",
})}
</div>
</div>
);
};

View File

@ -0,0 +1,49 @@
"use client";
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { AccordionLayoutDefinition } from "./index";
import { AccordionLayout } from "./AccordionLayout";
/**
* accordion ( )
*/
export class AccordionLayoutRenderer extends AutoRegisteringLayoutRenderer {
/**
* ( )
*/
static readonly layoutDefinition = AccordionLayoutDefinition;
/**
*
*/
static {
this.registerSelf();
}
/**
*
*/
render(): React.ReactElement {
return <AccordionLayout {...this.props} renderer={this} />;
}
}
/**
* React ( )
*/
export const AccordionLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
const renderer = new AccordionLayoutRenderer(props);
return renderer.render();
};
// 개발 모드에서 Hot Reload 지원
if (process.env.NODE_ENV === 'development') {
// HMR API 등록
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.dispose(() => {
AccordionLayoutRenderer.unregisterSelf();
});
}
}

View File

@ -0,0 +1,43 @@
# accordion
accordion 레이아웃입니다.
## 사용법
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
## 구성
- `AccordionLayout.tsx`: 메인 레이아웃 컴포넌트
- `AccordionLayoutRenderer.tsx`: 렌더러 (자동 등록)
- `config.ts`: 기본 설정
- `types.ts`: 타입 정의
- `index.ts`: 진입점
## 개발
1. `AccordionLayout.tsx`에서 레이아웃 로직 구현
2. `config.ts`에서 기본 설정 조정
3. `types.ts`에서 타입 정의 추가
## 설정
```typescript
{
accordion: {
// TODO: 설정 옵션 문서화
}
}
```
## 존 구성
- **존 1** (`zone1`): 기본 영역
- **존 2** (`zone2`): 기본 영역
- **존 3** (`zone3`): 기본 영역
---
생성일: 2025. 9. 10.
버전: 1.0.0
작성자: Developer

View File

@ -0,0 +1,50 @@
/**
* accordion
*/
export const AccordionLayoutConfig = {
defaultConfig: {
accordion: {
// TODO: 레이아웃 전용 설정 정의
// 예시:
// spacing: 16,
// orientation: "vertical",
// allowResize: true,
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone3",
name: "존 3",
position: {},
size: { width: "100%", height: "100%" },
}
],
// 설정 스키마 (검증용)
configSchema: {
type: "object",
properties: {
accordion: {
type: "object",
properties: {
// TODO: 설정 스키마 정의
},
additionalProperties: false,
},
},
required: ["accordion"],
},
};

View File

@ -0,0 +1,63 @@
"use client";
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
import { AccordionLayout } from "./AccordionLayout";
import { AccordionLayoutRenderer } from "./AccordionLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import React from "react";
/**
* (DynamicLayoutRenderer용)
*/
const AccordionLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
const renderer = new AccordionLayoutRenderer(props);
return renderer.render();
};
/**
* accordion
*/
export const AccordionLayoutDefinition = createLayoutDefinition({
id: "accordion",
name: "아코디언 레이아웃",
nameEng: "Accordion Layout",
description: "접을 수 있는 아코디언 레이아웃입니다.",
category: "navigation",
icon: "accordion",
component: AccordionLayoutWrapper,
defaultConfig: {
accordion: {
multiple: false,
defaultExpanded: ["zone1"],
collapsible: true,
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone3",
name: "존 3",
position: {},
size: { width: "100%", height: "100%" },
},
],
tags: ["accordion", "navigation", "layout"],
version: "1.0.0",
author: "Developer",
documentation: "accordion 레이아웃입니다.",
});
// 자동 등록을 위한 export
export { AccordionLayout } from "./AccordionLayout";
export { AccordionLayoutRenderer } from "./AccordionLayoutRenderer";

View File

@ -0,0 +1,28 @@
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* accordion
*/
export interface AccordionConfig {
// TODO: 레이아웃 전용 설정 타입 정의
// 예시:
// spacing?: number;
// orientation?: "vertical" | "horizontal";
// allowResize?: boolean;
}
/**
* accordion Props
*/
export interface AccordionLayoutProps extends LayoutRendererProps {
renderer: any; // AccordionLayoutRenderer 타입
}
/**
* accordion
*/
export interface AccordionZone {
id: string;
name: string;
// TODO: 존별 전용 속성 정의
}

View File

@ -0,0 +1,102 @@
"use client";
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
*
* 3x2
*/
export const CardLayoutLayout: React.FC<LayoutRendererProps> = ({
layout,
children,
onUpdateLayout,
onSelectComponent,
isDesignMode = false,
}) => {
const cardConfig = layout.layoutConfig?.cardLayout || {
columns: 3,
gap: 16,
aspectRatio: "4:3",
};
// 카드 레이아웃 스타일
const containerStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${cardConfig.columns}, 1fr)`,
gridTemplateRows: "repeat(2, 300px)", // 2행 고정
gap: `${cardConfig.gap}px`,
padding: "16px",
width: "100%",
height: "100%",
background: "transparent",
};
// 카드 스타일
const cardStyle: React.CSSProperties = {
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
transition: "all 0.2s ease-in-out",
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
minHeight: "200px",
};
// 디자인 모드에서 호버 효과
const designModeCardStyle: React.CSSProperties = isDesignMode
? {
...cardStyle,
cursor: "pointer",
borderColor: "#d1d5db",
"&:hover": {
borderColor: "#3b82f6",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
},
}
: cardStyle;
return (
<div style={containerStyle}>
{layout.zones?.map((zone, index) => {
const zoneChildren = children?.filter((child) => child.props.parentId === zone.id) || [];
return (
<div
key={zone.id}
style={designModeCardStyle}
onClick={() => isDesignMode && onSelectComponent?.(zone.id)}
className={isDesignMode ? "hover:border-blue-500 hover:shadow-md" : ""}
>
{/* 카드 헤더 */}
{isDesignMode && (
<div className="absolute top-2 left-2 z-10">
<div className="rounded bg-blue-500 px-2 py-1 text-xs text-white">{zone.name}</div>
</div>
)}
{/* 카드 내용 */}
<div className="flex flex-1 flex-col">
{zoneChildren.length > 0 ? (
<div className="flex-1">{zoneChildren}</div>
) : (
isDesignMode && (
<div className="flex flex-1 items-center justify-center rounded border-2 border-dashed border-gray-200 text-gray-400">
<div className="text-center">
<div className="text-sm font-medium">{zone.name}</div>
<div className="mt-1 text-xs"> </div>
</div>
</div>
)
)}
</div>
</div>
);
})}
</div>
);
};

View File

@ -0,0 +1,109 @@
"use client";
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { CardLayoutDefinition } from "./index";
import { CardLayoutLayout } from "./CardLayoutLayout";
import React from "react";
/**
*
* AutoRegisteringLayoutRenderer를
*/
export class CardLayoutRenderer extends AutoRegisteringLayoutRenderer {
/**
* ( )
*/
static layoutDefinition = CardLayoutDefinition;
/**
*
*/
render(): React.ReactElement {
const { layout, children, onUpdateLayout, onSelectComponent, isDesignMode } = this.props;
return (
<CardLayoutLayout
layout={layout}
children={children}
onUpdateLayout={onUpdateLayout}
onSelectComponent={onSelectComponent}
isDesignMode={isDesignMode}
/>
);
}
/**
*
*/
getCardContainerStyle(): React.CSSProperties {
const cardConfig = this.props.layout.layoutConfig?.cardLayout || {
columns: 3,
gap: 16,
};
return {
display: "grid",
gridTemplateColumns: `repeat(${cardConfig.columns}, 1fr)`,
gridTemplateRows: "repeat(2, 300px)",
gap: `${cardConfig.gap}px`,
padding: "16px",
width: "100%",
height: "100%",
};
}
/**
*
*/
getCardStyle(zoneId: string): React.CSSProperties {
const baseStyle: React.CSSProperties = {
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
transition: "all 0.2s ease-in-out",
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
minHeight: "200px",
};
// 디자인 모드에서 추가 스타일
if (this.props.isDesignMode) {
return {
...baseStyle,
cursor: "pointer",
borderColor: "#d1d5db",
};
}
return baseStyle;
}
/**
*
*/
getCardHoverStyle(): React.CSSProperties {
return {
borderColor: "#3b82f6",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
transform: "translateY(-1px)",
};
}
/**
*
*/
getGridPosition(index: number): { row: number; column: number } {
const columns = this.props.layout.layoutConfig?.cardLayout?.columns || 3;
return {
row: Math.floor(index / columns),
column: index % columns,
};
}
}
// 자동 등록 실행
CardLayoutRenderer.registerSelf();

View File

@ -0,0 +1,46 @@
# card-layout
카드 형태의 대시보드 레이아웃입니다
## 사용법
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
## 구성
- `Card-layoutLayout.tsx`: 메인 레이아웃 컴포넌트
- `Card-layoutLayoutRenderer.tsx`: 렌더러 (자동 등록)
- `config.ts`: 기본 설정
- `types.ts`: 타입 정의
- `index.ts`: 진입점
## 개발
1. `Card-layoutLayout.tsx`에서 레이아웃 로직 구현
2. `config.ts`에서 기본 설정 조정
3. `types.ts`에서 타입 정의 추가
## 설정
```typescript
{
card-layout: {
// TODO: 설정 옵션 문서화
}
}
```
## 존 구성
- **존 1** (`zone1`): 기본 영역
- **존 2** (`zone2`): 기본 영역
- **존 3** (`zone3`): 기본 영역
- **존 4** (`zone4`): 기본 영역
- **존 5** (`zone5`): 기본 영역
- **존 6** (`zone6`): 기본 영역
---
생성일: 2025. 9. 10.
버전: 1.0.0
작성자: 개발자

View File

@ -0,0 +1,68 @@
/**
* card-layout
*/
export const Card-layoutLayoutConfig = {
defaultConfig: {
card-layout: {
// TODO: 레이아웃 전용 설정 정의
// 예시:
// spacing: 16,
// orientation: "vertical",
// allowResize: true,
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone3",
name: "존 3",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone4",
name: "존 4",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone5",
name: "존 5",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone6",
name: "존 6",
position: {},
size: { width: "100%", height: "100%" },
}
],
// 설정 스키마 (검증용)
configSchema: {
type: "object",
properties: {
card-layout: {
type: "object",
properties: {
// TODO: 설정 스키마 정의
},
additionalProperties: false,
},
},
required: ["card-layout"],
},
};

View File

@ -0,0 +1,79 @@
"use client";
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
import { CardLayoutRenderer } from "./CardLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import React from "react";
/**
* (DynamicLayoutRenderer용)
*/
const CardLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
const renderer = new CardLayoutRenderer(props);
return renderer.render();
};
/**
* card-layout
*/
export const CardLayoutDefinition = createLayoutDefinition({
id: "card-layout",
name: "카드 레이아웃",
nameEng: "Card Layout",
description: "카드 형태의 대시보드 레이아웃입니다",
category: "dashboard",
icon: "grid-3x3",
component: CardLayoutWrapper,
defaultConfig: {
cardLayout: {
columns: 3,
gap: 16,
aspectRatio: "4:3",
},
},
defaultZones: [
{
id: "card1",
name: "카드 1",
position: { row: 0, column: 0 },
size: { width: "33.33%", height: "300px" },
},
{
id: "card2",
name: "카드 2",
position: { row: 0, column: 1 },
size: { width: "33.33%", height: "300px" },
},
{
id: "card3",
name: "카드 3",
position: { row: 0, column: 2 },
size: { width: "33.33%", height: "300px" },
},
{
id: "card4",
name: "카드 4",
position: { row: 1, column: 0 },
size: { width: "33.33%", height: "300px" },
},
{
id: "card5",
name: "카드 5",
position: { row: 1, column: 1 },
size: { width: "33.33%", height: "300px" },
},
{
id: "card6",
name: "카드 6",
position: { row: 1, column: 2 },
size: { width: "33.33%", height: "300px" },
},
],
tags: ["card-layout", "dashboard", "layout"],
version: "1.0.0",
author: "개발자",
documentation: "카드 형태의 대시보드 레이아웃입니다",
});
// 자동 등록을 위한 export
export { CardLayoutRenderer } from "./CardLayoutRenderer";

View File

@ -0,0 +1,28 @@
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* card-layout
*/
export interface Card-layoutConfig {
// TODO: 레이아웃 전용 설정 타입 정의
// 예시:
// spacing?: number;
// orientation?: "vertical" | "horizontal";
// allowResize?: boolean;
}
/**
* card-layout Props
*/
export interface Card-layoutLayoutProps extends LayoutRendererProps {
renderer: any; // Card-layoutLayoutRenderer 타입
}
/**
* card-layout
*/
export interface Card-layoutZone {
id: string;
name: string;
// TODO: 존별 전용 속성 정의
}

View File

@ -0,0 +1,115 @@
"use client";
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* Flexbox
*/
export interface FlexboxLayoutProps extends LayoutRendererProps {
renderer: any; // FlexboxLayoutRenderer 타입
}
export const FlexboxLayout: React.FC<FlexboxLayoutProps> = ({
layout,
isDesignMode = false,
isSelected = false,
onClick,
className = "",
renderer,
...props
}) => {
if (!layout.layoutConfig.flexbox) {
return (
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
<div className="text-center text-red-600">
<div className="font-medium"> .</div>
<div className="mt-1 text-sm">layoutConfig.flexbox가 .</div>
</div>
</div>
);
}
const flexConfig = layout.layoutConfig.flexbox;
const containerStyle = renderer.getLayoutContainerStyle();
// 플렉스박스 스타일 설정
const flexStyle: React.CSSProperties = {
...containerStyle,
display: "flex",
flexDirection: flexConfig.direction,
justifyContent: flexConfig.justify,
alignItems: flexConfig.align,
flexWrap: flexConfig.wrap,
gap: `${flexConfig.gap}px`,
height: "100%",
width: "100%",
};
// 디자인 모드 스타일
if (isDesignMode) {
flexStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
flexStyle.borderRadius = "8px";
flexStyle.padding = "8px";
}
return (
<div
className={`flexbox-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={flexStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// 플렉스 아이템 스타일 설정
const zoneStyle: React.CSSProperties = {
flex: renderer.calculateFlexValue(zone, flexConfig.direction),
};
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "flex-zone",
});
})}
{/* 존이 없을 때 안내 메시지 */}
{layout.zones.length === 0 && (
<div
className="empty-flex-container"
style={{
flex: 1,
border: isDesignMode ? "2px dashed #cbd5e1" : "1px solid #e2e8f0",
borderRadius: "8px",
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: isDesignMode ? "14px" : "12px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(148, 163, 184, 0.05)"
: "rgba(248, 250, 252, 0.5)";
}}
>
{isDesignMode ? "플렉스박스 레이아웃에 존을 추가하세요" : "빈 레이아웃"}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,88 @@
"use client";
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { FlexboxLayoutDefinition } from "./index";
import { FlexboxLayout } from "./FlexboxLayout";
/**
* flexbox ( )
*/
export class FlexboxLayoutRenderer extends AutoRegisteringLayoutRenderer {
/**
* ( )
*/
static readonly layoutDefinition = FlexboxLayoutDefinition;
/**
*
*/
static {
this.registerSelf();
}
/**
*
*/
render(): React.ReactElement {
return <FlexboxLayout {...this.props} renderer={this} />;
}
/**
* flex .
*/
calculateFlexValue(zone: any, direction: string): string {
// 존의 크기에 따라 flex 값 결정
if (direction === "row" || direction === "row-reverse") {
// 가로 방향: width를 기준으로 flex 값 계산
if (typeof zone.size.width === "string") {
if (zone.size.width.includes("fr")) {
return zone.size.width.replace("fr", "");
} else if (zone.size.width.includes("%")) {
const percent = parseInt(zone.size.width.replace("%", ""));
return `0 0 ${percent}%`;
} else if (zone.size.width.includes("px")) {
return `0 0 ${zone.size.width}`;
}
} else if (typeof zone.size.width === "number") {
return `0 0 ${zone.size.width}px`;
}
} else {
// 세로 방향: height를 기준으로 flex 값 계산
if (typeof zone.size.height === "string") {
if (zone.size.height.includes("fr")) {
return zone.size.height.replace("fr", "");
} else if (zone.size.height.includes("%")) {
const percent = parseInt(zone.size.height.replace("%", ""));
return `0 0 ${percent}%`;
} else if (zone.size.height.includes("px")) {
return `0 0 ${zone.size.height}`;
}
} else if (typeof zone.size.height === "number") {
return `0 0 ${zone.size.height}px`;
}
}
// 기본값: 균등 분할
return "1";
}
}
/**
* React ( )
*/
export const FlexboxLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
const renderer = new FlexboxLayoutRenderer(props);
return renderer.render();
};
// 개발 모드에서 Hot Reload 지원
if (process.env.NODE_ENV === "development") {
// HMR API 등록
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.dispose(() => {
FlexboxLayoutRenderer.unregisterSelf();
});
}
}

View File

@ -0,0 +1,42 @@
# flexbox
유연한 박스 모델을 사용하는 레이아웃입니다.
## 사용법
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
## 구성
- `FlexboxLayout.tsx`: 메인 레이아웃 컴포넌트
- `FlexboxLayoutRenderer.tsx`: 렌더러 (자동 등록)
- `config.ts`: 기본 설정
- `types.ts`: 타입 정의
- `index.ts`: 진입점
## 개발
1. `FlexboxLayout.tsx`에서 레이아웃 로직 구현
2. `config.ts`에서 기본 설정 조정
3. `types.ts`에서 타입 정의 추가
## 설정
```typescript
{
flexbox: {
// TODO: 설정 옵션 문서화
}
}
```
## 존 구성
- **존 1** (`zone1`): 기본 영역
- **존 2** (`zone2`): 기본 영역
---
생성일: 2025. 9. 10.
버전: 1.0.0
작성자: Developer

View File

@ -0,0 +1,44 @@
/**
* flexbox
*/
export const FlexboxLayoutConfig = {
defaultConfig: {
flexbox: {
// TODO: 레이아웃 전용 설정 정의
// 예시:
// spacing: 16,
// orientation: "vertical",
// allowResize: true,
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: {},
size: { width: "100%", height: "100%" },
}
],
// 설정 스키마 (검증용)
configSchema: {
type: "object",
properties: {
flexbox: {
type: "object",
properties: {
// TODO: 설정 스키마 정의
},
additionalProperties: false,
},
},
required: ["flexbox"],
},
};

View File

@ -0,0 +1,59 @@
"use client";
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
import { FlexboxLayout } from "./FlexboxLayout";
import { FlexboxLayoutRenderer } from "./FlexboxLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import React from "react";
/**
* (DynamicLayoutRenderer용)
*/
const FlexboxLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
const renderer = new FlexboxLayoutRenderer(props);
return renderer.render();
};
/**
* flexbox
*/
export const FlexboxLayoutDefinition = createLayoutDefinition({
id: "flexbox",
name: "플렉스박스 레이아웃",
nameEng: "Flexbox Layout",
description: "유연한 박스 모델을 사용하는 레이아웃입니다.",
category: "basic",
icon: "flex",
component: FlexboxLayoutWrapper,
defaultConfig: {
flexbox: {
direction: "row",
justify: "flex-start",
align: "stretch",
wrap: "nowrap",
gap: 16,
},
},
defaultZones: [
{
id: "left",
name: "좌측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
],
tags: ["flexbox", "flex", "layout", "basic"],
version: "1.0.0",
author: "Screen Management System",
documentation: "유연한 박스 모델을 사용하는 레이아웃입니다. 수평/수직 방향과 정렬 방식을 설정할 수 있습니다.",
});
// 자동 등록을 위한 export
export { FlexboxLayout } from "./FlexboxLayout";
export { FlexboxLayoutRenderer } from "./FlexboxLayoutRenderer";

View File

@ -0,0 +1,28 @@
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* flexbox
*/
export interface FlexboxConfig {
// TODO: 레이아웃 전용 설정 타입 정의
// 예시:
// spacing?: number;
// orientation?: "vertical" | "horizontal";
// allowResize?: boolean;
}
/**
* flexbox Props
*/
export interface FlexboxLayoutProps extends LayoutRendererProps {
renderer: any; // FlexboxLayoutRenderer 타입
}
/**
* flexbox
*/
export interface FlexboxZone {
id: string;
name: string;
// TODO: 존별 전용 속성 정의
}

View File

@ -0,0 +1,160 @@
"use client";
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
*
*/
export interface GridLayoutProps extends LayoutRendererProps {
renderer: any; // GridLayoutRenderer 타입
}
export const GridLayout: React.FC<GridLayoutProps> = ({
layout,
isDesignMode = false,
isSelected = false,
onClick,
className = "",
renderer,
...props
}) => {
if (!layout.layoutConfig.grid) {
return (
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
<div className="text-center text-red-600">
<div className="font-medium"> .</div>
<div className="mt-1 text-sm">layoutConfig.grid가 .</div>
</div>
</div>
);
}
const gridConfig = layout.layoutConfig.grid;
const containerStyle = renderer.getLayoutContainerStyle();
// 그리드 컨테이너 스타일
const gridStyle: React.CSSProperties = {
...containerStyle,
display: "grid",
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
gap: `${gridConfig.gap || 16}px`,
height: "100%",
width: "100%",
};
// 디자인 모드 스타일
if (isDesignMode) {
gridStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
gridStyle.borderRadius = "8px";
}
// DOM props만 추출 (React DOM에서 인식하는 props만)
const {
children: propsChildren,
onUpdateLayout,
onSelectComponent,
isDesignMode: _isDesignMode,
allComponents,
onZoneClick,
onComponentDrop,
onDragStart,
onDragEnd,
...domProps
} = props;
return (
<div
className={`grid-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={gridStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// 그리드 위치 설정
const zoneStyle: React.CSSProperties = {
gridRow: zone.position.row !== undefined ? zone.position.row + 1 : undefined,
gridColumn: zone.position.column !== undefined ? zone.position.column + 1 : undefined,
};
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "grid-zone",
});
})}
{/* 디자인 모드에서 빈 그리드 셀 표시 */}
{isDesignMode && <GridEmptyCells gridConfig={gridConfig} layout={layout} isDesignMode={isDesignMode} />}
</div>
);
};
/**
*
*/
const GridEmptyCells: React.FC<{
gridConfig: any;
layout: any;
isDesignMode: boolean;
}> = ({ gridConfig, layout, isDesignMode }) => {
const totalCells = gridConfig.rows * gridConfig.columns;
const occupiedCells = new Set(
layout.zones
.map((zone: any) =>
zone.position.row !== undefined && zone.position.column !== undefined
? zone.position.row * gridConfig.columns + zone.position.column
: -1,
)
.filter((index: number) => index >= 0),
);
const emptyCells: React.ReactElement[] = [];
for (let i = 0; i < totalCells; i++) {
if (!occupiedCells.has(i)) {
const row = Math.floor(i / gridConfig.columns);
const column = i % gridConfig.columns;
emptyCells.push(
<div
key={`empty-${i}`}
className="empty-grid-cell"
style={{
gridRow: row + 1,
gridColumn: column + 1,
border: isDesignMode ? "1px dashed #cbd5e1" : "1px solid #f1f5f9",
borderRadius: "4px",
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
color: "#94a3b8",
minHeight: "40px",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
e.currentTarget.style.borderColor = "#3b82f6";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(148, 163, 184, 0.05)"
: "rgba(248, 250, 252, 0.3)";
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#f1f5f9";
}}
>
{isDesignMode ? `${row + 1},${column + 1}` : ""}
</div>,
);
}
}
return <>{emptyCells}</>;
};

View File

@ -0,0 +1,52 @@
"use client";
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { GridLayoutDefinition } from "./index";
import { GridLayout } from "./GridLayout";
/**
* ( )
*/
export class GridLayoutRenderer extends AutoRegisteringLayoutRenderer {
/**
* ( )
*/
static readonly layoutDefinition = GridLayoutDefinition;
/**
*
*/
static {
this.registerSelf();
}
/**
*
*/
render(): React.ReactElement {
return <GridLayout {...this.props} renderer={this} />;
}
}
/**
* React ( )
*/
export const GridLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
const renderer = new GridLayoutRenderer(props);
return renderer.render();
};
// 기존 호환성을 위한 export
export { GridLayoutComponent as GridLayout };
// 개발 모드에서 Hot Reload 지원
if (process.env.NODE_ENV === "development") {
// HMR API 등록
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.dispose(() => {
GridLayoutRenderer.unregisterSelf();
});
}
}

View File

@ -0,0 +1,69 @@
"use client";
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
import { GridLayout } from "./GridLayout";
import { GridLayoutRenderer } from "./GridLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import React from "react";
/**
* (DynamicLayoutRenderer용)
*/
const GridLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
const renderer = new GridLayoutRenderer(props);
return renderer.render();
};
/**
*
*/
export const GridLayoutDefinition = createLayoutDefinition({
id: "grid",
name: "그리드 레이아웃",
nameEng: "Grid Layout",
description: "행과 열로 구성된 격자 형태의 레이아웃입니다. 정확한 위치 제어가 가능합니다.",
category: "basic",
icon: "grid",
component: GridLayoutWrapper,
defaultConfig: {
grid: {
rows: 2,
columns: 2,
gap: 16,
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: { row: 0, column: 0 },
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: { row: 0, column: 1 },
size: { width: "100%", height: "100%" },
},
{
id: "zone3",
name: "존 3",
position: { row: 1, column: 0 },
size: { width: "100%", height: "100%" },
},
{
id: "zone4",
name: "존 4",
position: { row: 1, column: 1 },
size: { width: "100%", height: "100%" },
},
],
tags: ["grid", "layout", "basic", "structured"],
version: "1.0.0",
author: "Screen Management System",
documentation: "2x2 그리드로 구성된 기본 레이아웃입니다. 각 존은 정확한 그리드 위치에 배치됩니다.",
});
// 자동 등록을 위한 export
export { GridLayout } from "./GridLayout";
export { GridLayoutRenderer } from "./GridLayoutRenderer";

View File

@ -0,0 +1,98 @@
"use client";
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* heroSection
*/
export interface HeroSectionLayoutProps extends LayoutRendererProps {
renderer: any; // HeroSectionLayoutRenderer 타입
}
export const HeroSectionLayout: React.FC<HeroSectionLayoutProps> = ({
layout,
isDesignMode = false,
isSelected = false,
onClick,
className = "",
renderer,
...props
}) => {
if (!layout.layoutConfig.heroSection) {
return (
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
<div className="text-center text-red-600">
<div className="font-medium">heroSection .</div>
<div className="text-sm mt-1">layoutConfig.heroSection가 .</div>
</div>
</div>
);
}
const heroSectionConfig = layout.layoutConfig.heroSection;
const containerStyle = renderer.getLayoutContainerStyle();
// heroSection 컨테이너 스타일
const heroSectionStyle: React.CSSProperties = {
...containerStyle,
// TODO: 레이아웃 전용 스타일 정의
height: "100%",
width: "100%",
};
// 디자인 모드 스타일
if (isDesignMode) {
heroSectionStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
heroSectionStyle.borderRadius = "8px";
}
return (
<div
className={`hero-section-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={heroSectionStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// TODO: 존별 스타일 정의
const zoneStyle: React.CSSProperties = {
// 레이아웃별 존 스타일 구현
};
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "hero-section-zone",
});
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-hero-section-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
heroSection에
</div>
)}
</div>
);
};

View File

@ -0,0 +1,49 @@
"use client";
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { HeroSectionLayoutDefinition } from "./index";
import { HeroSectionLayout } from "./HeroSectionLayout";
/**
* heroSection ( )
*/
export class HeroSectionLayoutRenderer extends AutoRegisteringLayoutRenderer {
/**
* ( )
*/
static readonly layoutDefinition = HeroSectionLayoutDefinition;
/**
*
*/
static {
this.registerSelf();
}
/**
*
*/
render(): React.ReactElement {
return <HeroSectionLayout {...this.props} renderer={this} />;
}
}
/**
* React ( )
*/
export const HeroSectionLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
const renderer = new HeroSectionLayoutRenderer(props);
return renderer.render();
};
// 개발 모드에서 Hot Reload 지원
if (process.env.NODE_ENV === 'development') {
// HMR API 등록
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.dispose(() => {
HeroSectionLayoutRenderer.unregisterSelf();
});
}
}

View File

@ -0,0 +1,43 @@
# heroSection
영웅 섹션 레이아웃입니다
## 사용법
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
## 구성
- `HeroSectionLayout.tsx`: 메인 레이아웃 컴포넌트
- `HeroSectionLayoutRenderer.tsx`: 렌더러 (자동 등록)
- `config.ts`: 기본 설정
- `types.ts`: 타입 정의
- `index.ts`: 진입점
## 개발
1. `HeroSectionLayout.tsx`에서 레이아웃 로직 구현
2. `config.ts`에서 기본 설정 조정
3. `types.ts`에서 타입 정의 추가
## 설정
```typescript
{
hero-section: {
// TODO: 설정 옵션 문서화
}
}
```
## 존 구성
- **존 1** (`zone1`): 기본 영역
- **존 2** (`zone2`): 기본 영역
- **존 3** (`zone3`): 기본 영역
---
생성일: 2025. 9. 10.
버전: 1.0.0
작성자: 개발자

View File

@ -0,0 +1,50 @@
/**
* heroSection
*/
export const HeroSectionLayoutConfig = {
defaultConfig: {
hero-section: {
// TODO: 레이아웃 전용 설정 정의
// 예시:
// spacing: 16,
// orientation: "vertical",
// allowResize: true,
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone3",
name: "존 3",
position: {},
size: { width: "100%", height: "100%" },
}
],
// 설정 스키마 (검증용)
configSchema: {
type: "object",
properties: {
hero-section: {
type: "object",
properties: {
// TODO: 설정 스키마 정의
},
additionalProperties: false,
},
},
required: ["hero-section"],
},
};

View File

@ -0,0 +1,60 @@
"use client";
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
import { HeroSectionLayout } from "./HeroSectionLayout";
import { HeroSectionLayoutRenderer } from "./HeroSectionLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import React from "react";
/**
* heroSection (DynamicLayoutRenderer용)
*/
const HeroSectionLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
const renderer = new HeroSectionLayoutRenderer(props);
return renderer.render();
};
/**
* heroSection
*/
export const HeroSectionLayoutDefinition = createLayoutDefinition({
id: "hero-section",
name: "heroSection",
nameEng: "HeroSection Layout",
description: "영웅 섹션 레이아웃입니다",
category: "content",
icon: "hero-section",
component: HeroSectionLayoutWrapper,
defaultConfig: {
heroSection: {
// TODO: 레이아웃별 설정 정의
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone3",
name: "존 3",
position: {},
size: { width: "100%", height: "100%" },
}
],
tags: ["hero-section", "content", "layout"],
version: "1.0.0",
author: "개발자",
documentation: "영웅 섹션 레이아웃입니다",
});
// 자동 등록을 위한 export
export { HeroSectionLayoutRenderer } from "./HeroSectionLayoutRenderer";

View File

@ -0,0 +1,28 @@
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* heroSection
*/
export interface HeroSectionConfig {
// TODO: 레이아웃 전용 설정 타입 정의
// 예시:
// spacing?: number;
// orientation?: "vertical" | "horizontal";
// allowResize?: boolean;
}
/**
* heroSection Props
*/
export interface HeroSectionLayoutProps extends LayoutRendererProps {
renderer: any; // HeroSectionLayoutRenderer 타입
}
/**
* heroSection
*/
export interface HeroSectionZone {
id: string;
name: string;
// TODO: 존별 전용 속성 정의
}

View File

@ -0,0 +1,109 @@
"use client";
import { LayoutRegistry } from "../LayoutRegistry";
import { discoverLayouts } from "../utils/autoDiscovery";
// 기존 레이아웃들 (호환성을 위해 유지)
import { SplitLayout } from "./SplitLayoutRenderer";
import { TabsLayout } from "./TabsLayoutRenderer";
// 새 구조 레이아웃들 (자동 등록)
import "./grid/GridLayoutRenderer";
import "./flexbox/FlexboxLayoutRenderer";
import "./accordion/AccordionLayoutRenderer";
import "./split/SplitLayoutRenderer";
import "./card-layout/CardLayoutRenderer";
import "./hero-section/HeroSectionLayoutRenderer";
// 레이아웃 초기화 함수 (새 구조 + 기존 구조 하이브리드)
export async function initializeLayouts() {
console.log("🚀 레이아웃 시스템 초기화 시작...");
try {
// 1. 자동 디스커버리 실행 (새 구조)
const discoveryResult = await discoverLayouts({
pattern: "./**/*LayoutRenderer.tsx",
verbose: true,
continueOnError: true,
});
console.log(`✅ 자동 디스커버리 완료: ${discoveryResult.successfullyLoaded}개 레이아웃 로드`);
// 2. 기존 구조 레이아웃들 수동 등록 (마이그레이션 완료 시까지)
await initializeLegacyLayouts();
const totalLayouts = LayoutRegistry.getAllLayouts().length;
console.log(`🎉 레이아웃 시스템 초기화 완료: 총 ${totalLayouts}개 레이아웃 등록`);
return {
success: true,
autoDiscovered: discoveryResult.successfullyLoaded,
legacy: totalLayouts - discoveryResult.successfullyLoaded,
total: totalLayouts,
};
} catch (error) {
console.error("❌ 레이아웃 시스템 초기화 실패:", error);
throw error;
}
}
// 기존 구조 레이아웃들 등록 (임시)
async function initializeLegacyLayouts() {
console.log("🔄 기존 구조 레이아웃 로드 중...");
// 플렉스박스 레이아웃 (새 구조로 마이그레이션됨 - 스킵)
// 분할 레이아웃 (새 구조로 마이그레이션됨 - 스킵)
// 탭 레이아웃 (기존 구조 - 임시)
LayoutRegistry.registerLayout({
id: "tabs",
name: "탭 레이아웃",
nameEng: "Tabs Layout",
description: "탭으로 구성된 다중 패널 레이아웃입니다.",
category: "navigation",
icon: "tabs",
component: TabsLayout,
defaultConfig: {
tabs: {
position: "top",
variant: "default",
size: "default",
closable: false,
defaultTab: "tab1",
},
},
defaultZones: [
{
id: "tab1",
name: "탭 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab2",
name: "탭 2",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab3",
name: "탭 3",
position: {},
size: { width: "100%", height: "100%" },
},
],
tags: ["tabs", "navigation", "multi-panel"],
isActive: true,
});
console.log("✅ 기존 구조 레이아웃 로드 완료");
}
// 레지스트리에서 레이아웃 조회하는 헬퍼 함수들
export const getLayoutComponent = (layoutType: string) => LayoutRegistry.getLayout(layoutType)?.component;
export const getAllLayoutDefinitions = () => LayoutRegistry.getAllLayouts();
// 앱 시작 시 자동 실행
console.log("📦 레이아웃 모듈 로드됨");

View File

@ -0,0 +1,42 @@
# split
크기 조절이 가능한 분할된 영역의 레이아웃입니다.
## 사용법
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
## 구성
- `SplitLayout.tsx`: 메인 레이아웃 컴포넌트
- `SplitLayoutRenderer.tsx`: 렌더러 (자동 등록)
- `config.ts`: 기본 설정
- `types.ts`: 타입 정의
- `index.ts`: 진입점
## 개발
1. `SplitLayout.tsx`에서 레이아웃 로직 구현
2. `config.ts`에서 기본 설정 조정
3. `types.ts`에서 타입 정의 추가
## 설정
```typescript
{
split: {
// TODO: 설정 옵션 문서화
}
}
```
## 존 구성
- **존 1** (`zone1`): 기본 영역
- **존 2** (`zone2`): 기본 영역
---
생성일: 2025. 9. 10.
버전: 1.0.0
작성자: Developer

View File

@ -0,0 +1,98 @@
"use client";
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* split
*/
export interface SplitLayoutProps extends LayoutRendererProps {
renderer: any; // SplitLayoutRenderer 타입
}
export const SplitLayout: React.FC<SplitLayoutProps> = ({
layout,
isDesignMode = false,
isSelected = false,
onClick,
className = "",
renderer,
...props
}) => {
if (!layout.layoutConfig.split) {
return (
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
<div className="text-center text-red-600">
<div className="font-medium">split .</div>
<div className="text-sm mt-1">layoutConfig.split가 .</div>
</div>
</div>
);
}
const splitConfig = layout.layoutConfig.split;
const containerStyle = renderer.getLayoutContainerStyle();
// split 컨테이너 스타일
const splitStyle: React.CSSProperties = {
...containerStyle,
// TODO: 레이아웃 전용 스타일 정의
height: "100%",
width: "100%",
};
// 디자인 모드 스타일
if (isDesignMode) {
splitStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
splitStyle.borderRadius = "8px";
}
return (
<div
className={`split-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={splitStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// TODO: 존별 스타일 정의
const zoneStyle: React.CSSProperties = {
// 레이아웃별 존 스타일 구현
};
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "split-zone",
});
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-split-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
split에
</div>
)}
</div>
);
};

View File

@ -0,0 +1,49 @@
"use client";
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { SplitLayoutDefinition } from "./index";
import { SplitLayout } from "./SplitLayout";
/**
* split ( )
*/
export class SplitLayoutRenderer extends AutoRegisteringLayoutRenderer {
/**
* ( )
*/
static readonly layoutDefinition = SplitLayoutDefinition;
/**
*
*/
static {
this.registerSelf();
}
/**
*
*/
render(): React.ReactElement {
return <SplitLayout {...this.props} renderer={this} />;
}
}
/**
* React ( )
*/
export const SplitLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
const renderer = new SplitLayoutRenderer(props);
return renderer.render();
};
// 개발 모드에서 Hot Reload 지원
if (process.env.NODE_ENV === 'development') {
// HMR API 등록
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.dispose(() => {
SplitLayoutRenderer.unregisterSelf();
});
}
}

View File

@ -0,0 +1,44 @@
/**
* split
*/
export const SplitLayoutConfig = {
defaultConfig: {
split: {
// TODO: 레이아웃 전용 설정 정의
// 예시:
// spacing: 16,
// orientation: "vertical",
// allowResize: true,
},
},
defaultZones: [
{
id: "zone1",
name: "존 1",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "zone2",
name: "존 2",
position: {},
size: { width: "100%", height: "100%" },
}
],
// 설정 스키마 (검증용)
configSchema: {
type: "object",
properties: {
split: {
type: "object",
properties: {
// TODO: 설정 스키마 정의
},
additionalProperties: false,
},
},
required: ["split"],
},
};

View File

@ -0,0 +1,61 @@
"use client";
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
import { SplitLayout } from "./SplitLayout";
import { SplitLayoutRenderer } from "./SplitLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import React from "react";
/**
* (DynamicLayoutRenderer용)
*/
const SplitLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
const renderer = new SplitLayoutRenderer(props);
return renderer.render();
};
/**
* split
*/
export const SplitLayoutDefinition = createLayoutDefinition({
id: "split",
name: "분할 레이아웃",
nameEng: "Split Layout",
description: "크기 조절이 가능한 분할된 영역의 레이아웃입니다.",
category: "basic",
icon: "split",
component: SplitLayoutWrapper,
defaultConfig: {
split: {
direction: "horizontal",
ratio: [50, 50],
minSize: [200, 200],
resizable: true,
splitterSize: 4,
},
},
defaultZones: [
{
id: "left",
name: "좌측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
{
id: "right",
name: "우측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
],
tags: ["split", "basic", "layout"],
version: "1.0.0",
author: "Developer",
documentation: "크기 조절이 가능한 분할된 영역의 레이아웃입니다.",
});
// 자동 등록을 위한 export
export { SplitLayout } from "./SplitLayout";
export { SplitLayoutRenderer } from "./SplitLayoutRenderer";

View File

@ -0,0 +1,28 @@
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* split
*/
export interface SplitConfig {
// TODO: 레이아웃 전용 설정 타입 정의
// 예시:
// spacing?: number;
// orientation?: "vertical" | "horizontal";
// allowResize?: boolean;
}
/**
* split Props
*/
export interface SplitLayoutProps extends LayoutRendererProps {
renderer: any; // SplitLayoutRenderer 타입
}
/**
* split
*/
export interface SplitZone {
id: string;
name: string;
// TODO: 존별 전용 속성 정의
}

View File

@ -0,0 +1,288 @@
"use client";
import { LayoutRegistry } from "../LayoutRegistry";
/**
*
*/
export interface AutoDiscoveryOptions {
/** 스캔할 디렉토리 패턴 */
pattern?: string;
/** 개발 모드에서 상세 로그 출력 */
verbose?: boolean;
/** 에러 시 계속 진행할지 여부 */
continueOnError?: boolean;
/** 최대 대기 시간 (ms) */
timeout?: number;
}
/**
*
*/
export interface LayoutModuleInfo {
path: string;
id: string;
name: string;
loaded: boolean;
error?: Error;
timestamp: number;
}
/**
*
*/
export interface DiscoveryResult {
success: boolean;
totalFound: number;
successfullyLoaded: number;
failed: number;
modules: LayoutModuleInfo[];
errors: Error[];
duration: number;
}
/**
*
*/
export class LayoutAutoDiscovery {
private static instance: LayoutAutoDiscovery;
private discoveryResults: DiscoveryResult[] = [];
private isDiscovering = false;
static getInstance(): LayoutAutoDiscovery {
if (!this.instance) {
this.instance = new LayoutAutoDiscovery();
}
return this.instance;
}
/**
*
*/
async discover(options: AutoDiscoveryOptions = {}): Promise<DiscoveryResult> {
const startTime = Date.now();
const {
pattern = "./**/*LayoutRenderer.tsx",
verbose = process.env.NODE_ENV === "development",
continueOnError = true,
timeout = 10000,
} = options;
if (this.isDiscovering) {
throw new Error("디스커버리가 이미 진행 중입니다.");
}
this.isDiscovering = true;
try {
if (verbose) {
console.log("🔍 레이아웃 자동 디스커버리 시작...");
console.log(`📁 스캔 패턴: ${pattern}`);
}
const modules: LayoutModuleInfo[] = [];
const errors: Error[] = [];
// 현재는 정적 import를 사용 (Vite/Next.js 환경에서)
const layoutModules = await this.discoverLayoutModules(pattern);
for (const [path, moduleFactory] of Object.entries(layoutModules)) {
const moduleInfo: LayoutModuleInfo = {
path,
id: this.extractLayoutId(path),
name: this.extractLayoutName(path),
loaded: false,
timestamp: Date.now(),
};
try {
// 모듈 로드 시도
await this.loadLayoutModule(moduleFactory, moduleInfo);
moduleInfo.loaded = true;
if (verbose) {
console.log(`✅ 로드 성공: ${moduleInfo.name} (${moduleInfo.id})`);
}
} catch (error) {
const err = error as Error;
moduleInfo.error = err;
errors.push(err);
if (verbose) {
console.error(`❌ 로드 실패: ${moduleInfo.name}`, err);
}
if (!continueOnError) {
throw err;
}
}
modules.push(moduleInfo);
}
const result: DiscoveryResult = {
success: errors.length === 0,
totalFound: modules.length,
successfullyLoaded: modules.filter((m) => m.loaded).length,
failed: errors.length,
modules,
errors,
duration: Date.now() - startTime,
};
this.discoveryResults.push(result);
if (verbose) {
this.logDiscoveryResult(result);
}
return result;
} finally {
this.isDiscovering = false;
}
}
/**
*
*/
private async discoverLayoutModules(pattern: string): Promise<Record<string, () => Promise<any>>> {
try {
// Vite의 import.meta.glob 사용
if (typeof import.meta !== "undefined" && import.meta.glob) {
return import.meta.glob(pattern, { eager: false });
}
// Next.js의 경우 또는 fallback
return await this.discoverModulesViaWebpack(pattern);
} catch (error) {
console.warn("자동 디스커버리 실패, 수동 import로 전환:", error);
return {};
}
}
/**
* Webpack (Next.js용)
*/
private async discoverModulesViaWebpack(pattern: string): Promise<Record<string, () => Promise<any>>> {
// Next.js 환경에서는 require.context 사용
if (typeof require !== "undefined" && require.context) {
const context = require.context("../layouts", true, /.*LayoutRenderer\.tsx$/);
const modules: Record<string, () => Promise<any>> = {};
context.keys().forEach((key: string) => {
modules[key] = () => Promise.resolve(context(key));
});
return modules;
}
return {};
}
/**
*
*/
private async loadLayoutModule(moduleFactory: () => Promise<any>, moduleInfo: LayoutModuleInfo): Promise<void> {
const module = await moduleFactory();
// default export가 있는 경우
if (module.default && typeof module.default.registerSelf === "function") {
module.default.registerSelf();
return;
}
// named export 중에 레이아웃 렌더러가 있는 경우
for (const [exportName, exportValue] of Object.entries(module)) {
if (exportValue && typeof (exportValue as any).registerSelf === "function") {
(exportValue as any).registerSelf();
return;
}
}
throw new Error(`레이아웃 렌더러를 찾을 수 없습니다: ${moduleInfo.path}`);
}
/**
* ID
*/
private extractLayoutId(path: string): string {
const match = path.match(/\/([^/]+)LayoutRenderer\.tsx$/);
return match ? match[1].toLowerCase() : "unknown";
}
/**
*
*/
private extractLayoutName(path: string): string {
const id = this.extractLayoutId(path);
return id.charAt(0).toUpperCase() + id.slice(1) + " Layout";
}
/**
*
*/
private logDiscoveryResult(result: DiscoveryResult): void {
console.group("📊 레이아웃 디스커버리 결과");
console.log(`⏱️ 소요 시간: ${result.duration}ms`);
console.log(`📦 발견된 모듈: ${result.totalFound}`);
console.log(`✅ 성공적으로 로드: ${result.successfullyLoaded}`);
console.log(`❌ 실패: ${result.failed}`);
if (result.modules.length > 0) {
console.table(
result.modules.map((m) => ({
ID: m.id,
Name: m.name,
Loaded: m.loaded ? "✅" : "❌",
Path: m.path,
})),
);
}
if (result.errors.length > 0) {
console.group("❌ 오류 상세:");
result.errors.forEach((error) => console.error(error));
console.groupEnd();
}
console.groupEnd();
}
/**
*
*/
getDiscoveryHistory(): DiscoveryResult[] {
return [...this.discoveryResults];
}
/**
*
*/
getStats(): { totalAttempts: number; successRate: number; avgDuration: number } {
const attempts = this.discoveryResults.length;
const successful = this.discoveryResults.filter((r) => r.success).length;
const avgDuration = attempts > 0 ? this.discoveryResults.reduce((sum, r) => sum + r.duration, 0) / attempts : 0;
return {
totalAttempts: attempts,
successRate: attempts > 0 ? (successful / attempts) * 100 : 0,
avgDuration: Math.round(avgDuration),
};
}
}
/**
* 함수: 레이아웃
*/
export async function discoverLayouts(options?: AutoDiscoveryOptions): Promise<DiscoveryResult> {
const discovery = LayoutAutoDiscovery.getInstance();
return discovery.discover(options);
}
/**
* 함수: 통계
*/
export function getDiscoveryStats() {
const discovery = LayoutAutoDiscovery.getInstance();
return discovery.getStats();
}

View File

@ -0,0 +1,174 @@
"use client";
import { LayoutDefinition, LayoutType, LayoutCategory } from "@/types/layout";
import React from "react";
/**
*
* .
*/
export interface CreateLayoutDefinitionOptions {
id: string;
name: string;
nameEng?: string;
description: string;
category: LayoutCategory;
icon?: string;
component: React.ComponentType<any>;
defaultConfig?: Record<string, any>;
defaultZones?: Array<{
id: string;
name: string;
position?: Record<string, any>;
size?: {
width: number | string;
height: number | string;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
};
[key: string]: any;
}>;
tags?: string[];
isActive?: boolean;
version?: string;
author?: string;
documentation?: string;
}
/**
* .
*/
export function createLayoutDefinition(options: CreateLayoutDefinitionOptions): LayoutDefinition {
const {
id,
name,
nameEng = name,
description,
category,
icon = "layout",
component,
defaultConfig = {},
defaultZones = [],
tags = [],
isActive = true,
version = "1.0.0",
author = "Developer",
documentation = "",
} = options;
// ID 유효성 검사
if (!id || typeof id !== "string" || !/^[a-z][a-z0-9-]*$/.test(id)) {
throw new Error(
`Invalid layout ID: "${id}". ID must start with a letter and contain only lowercase letters, numbers, and hyphens.`,
);
}
// 필수 필드 검사
if (!name.trim()) {
throw new Error("Layout name is required");
}
if (!description.trim()) {
throw new Error("Layout description is required");
}
if (!component) {
throw new Error("Layout component is required");
}
// 기본 태그 자동 추가
const autoTags = [id, category, "layout"];
const allTags = [...new Set([...autoTags, ...tags])];
const definition: LayoutDefinition = {
id: id as LayoutType,
name,
nameEng,
description,
category,
icon,
component,
defaultConfig,
defaultZones,
tags: allTags,
isActive,
metadata: {
version,
author,
documentation,
createdAt: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
},
};
console.log(`📦 레이아웃 정의 생성: ${id} (${name})`);
return definition;
}
/**
* .
*/
export function validateLayoutDefinition(definition: LayoutDefinition): {
isValid: boolean;
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// 필수 필드 검사
if (!definition.id) errors.push("ID is required");
if (!definition.name) errors.push("Name is required");
if (!definition.description) errors.push("Description is required");
if (!definition.category) errors.push("Category is required");
if (!definition.component) errors.push("Component is required");
// ID 형식 검사
if (definition.id && !/^[a-z][a-z0-9-]*$/.test(definition.id)) {
errors.push("ID must start with a letter and contain only lowercase letters, numbers, and hyphens");
}
// defaultZones 검사
if (definition.defaultZones) {
definition.defaultZones.forEach((zone, index) => {
if (!zone.id) errors.push(`Zone ${index}: ID is required`);
if (!zone.name) errors.push(`Zone ${index}: Name is required`);
});
}
// 경고사항 검사
if (!definition.nameEng) warnings.push("English name is recommended");
if (!definition.icon || definition.icon === "layout") warnings.push("Custom icon is recommended");
if (!definition.defaultZones || definition.defaultZones.length === 0) {
warnings.push("Default zones are recommended for better UX");
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
* .
*/
export function debugLayoutDefinition(definition: LayoutDefinition): void {
console.group(`🔍 Layout Debug: ${definition.id}`);
console.log("📋 Definition:", definition);
const validation = validateLayoutDefinition(definition);
if (validation.errors.length > 0) {
console.error("❌ Errors:", validation.errors);
}
if (validation.warnings.length > 0) {
console.warn("⚠️ Warnings:", validation.warnings);
}
if (validation.isValid) {
console.log("✅ Definition is valid");
}
console.groupEnd();
}

View File

@ -10,7 +10,8 @@
"lint": "next lint",
"lint:fix": "next lint --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
"format:check": "prettier --check .",
"create-layout": "node scripts/create-layout.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@ -0,0 +1,524 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
/**
* 레이아웃 스캐폴딩 CLI 도구
*
* 사용법:
* npm run create-layout <layoutName> [options]
*
* 예시:
* npm run create-layout accordion
* npm run create-layout sidebar --category=navigation --zones=3
*/
// 명령행 인자 파싱
const args = process.argv.slice(2);
const layoutName = args[0];
if (!layoutName) {
console.error("❌ 레이아웃 이름이 필요합니다.");
console.log("사용법: npm run create-layout <layoutName> [options]");
console.log("예시: npm run create-layout accordion --category=navigation");
process.exit(1);
}
// 옵션 파싱
const options = {};
args.slice(1).forEach((arg) => {
if (arg.startsWith("--")) {
const [key, value] = arg.slice(2).split("=");
options[key] = value || true;
}
});
/**
* 하이픈을 카멜케이스로 변환
*/
function toCamelCase(str) {
return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
}
/**
* 하이픈을 파스칼케이스로 변환
*/
function toPascalCase(str) {
const camelCase = toCamelCase(str);
return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
}
/**
* 안전한 식별자명 생성 (하이픈 제거)
*/
function toSafeId(str) {
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
}
// 안전한 이름들 생성
const safeLayoutName = toCamelCase(layoutName);
const pascalLayoutName = toPascalCase(layoutName);
const safeId = layoutName.toLowerCase(); // kebab-case는 id로 유지
// 기본 옵션
const config = {
name: safeLayoutName,
id: safeId,
className: pascalLayoutName,
category: options.category || "basic",
zones: parseInt(options.zones) || 2,
description: options.description || `${safeLayoutName} 레이아웃입니다.`,
author: options.author || "Developer",
...options,
};
// 검증
if (!/^[a-z][a-z0-9-]*$/.test(config.id)) {
console.error("❌ 레이아웃 이름은 소문자로 시작하고 소문자, 숫자, 하이픈만 포함해야 합니다.");
process.exit(1);
}
const layoutDir = path.join(__dirname, "../lib/registry/layouts", config.id);
// 디렉토리 존재 확인
if (fs.existsSync(layoutDir)) {
console.error(`❌ 레이아웃 디렉토리가 이미 존재합니다: ${layoutDir}`);
process.exit(1);
}
console.log("🚀 새 레이아웃 생성 중...");
console.log(`📁 이름: ${config.name}`);
console.log(`🔖 ID: ${config.id}`);
console.log(`📂 카테고리: ${config.category}`);
console.log(`🎯 존 개수: ${config.zones}`);
// 디렉토리 생성
fs.mkdirSync(layoutDir, { recursive: true });
// 템플릿 파일들 생성
createIndexFile();
createLayoutComponent();
createLayoutRenderer();
createConfigFile();
createTypesFile();
createReadme();
// package.json 스크립트 업데이트
updatePackageScripts();
console.log("");
console.log("✅ 레이아웃 생성 완료!");
console.log("");
console.log("📝 다음 단계:");
console.log(`1. ${layoutDir}/${config.className}Layout.tsx 에서 비즈니스 로직 구현`);
console.log("2. 파일을 저장하면 자동으로 화면편집기에서 사용 가능");
console.log("3. 필요에 따라 config.ts에서 기본 설정 조정");
console.log("");
console.log("🔧 개발 팁:");
console.log("- 브라우저 개발자 도구에서 window.__LAYOUT_REGISTRY__.list() 로 등록 확인");
console.log("- Hot Reload 지원으로 파일 수정 시 자동 업데이트");
/**
* index.ts 파일 생성
*/
function createIndexFile() {
const content = `"use client";
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
import { ${config.className}Layout } from "./${config.className}Layout";
import { ${config.className}LayoutRenderer } from "./${config.className}LayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import React from "react";
/**
* ${config.name} 래퍼 컴포넌트 (DynamicLayoutRenderer용)
*/
const ${config.className}LayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
const renderer = new ${config.className}LayoutRenderer(props);
return renderer.render();
};
/**
* ${config.name} 레이아웃 정의
*/
export const ${config.className}LayoutDefinition = createLayoutDefinition({
id: "${config.id}",
name: "${config.name}",
nameEng: "${config.className} Layout",
description: "${config.description}",
category: "${config.category}",
icon: "${config.icon || config.id}",
component: ${config.className}LayoutWrapper,
defaultConfig: {
${toCamelCase(config.id)}: {
// TODO: 레이아웃별 설정 정의
},
},
defaultZones: [${generateDefaultZones()}
],
tags: ["${config.id}", "${config.category}", "layout"],
version: "1.0.0",
author: "${config.author}",
documentation: "${config.description}",
});
// 자동 등록을 위한 export
export { ${config.className}LayoutRenderer } from "./${config.className}LayoutRenderer";
`;
fs.writeFileSync(path.join(layoutDir, "index.ts"), content);
console.log("✅ index.ts 생성됨");
}
/**
* 레이아웃 컴포넌트 파일 생성
*/
function createLayoutComponent() {
const content = `"use client";
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* ${config.name} 컴포넌트
*/
export interface ${config.className}LayoutProps extends LayoutRendererProps {
renderer: any; // ${config.className}LayoutRenderer 타입
}
export const ${config.className}Layout: React.FC<${config.className}LayoutProps> = ({
layout,
isDesignMode = false,
isSelected = false,
onClick,
className = "",
renderer,
...props
}) => {
if (!layout.layoutConfig.${toCamelCase(config.id)}) {
return (
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
<div className="text-center text-red-600">
<div className="font-medium">${config.name} 설정이 없습니다.</div>
<div className="text-sm mt-1">layoutConfig.${toCamelCase(config.id)} 필요합니다.</div>
</div>
</div>
);
}
const ${config.name}Config = layout.layoutConfig.${toCamelCase(config.id)};
const containerStyle = renderer.getLayoutContainerStyle();
// ${config.name} 컨테이너 스타일
const ${config.name}Style: React.CSSProperties = {
...containerStyle,
// TODO: 레이아웃 전용 스타일 정의
height: "100%",
width: "100%",
};
// 디자인 모드 스타일
if (isDesignMode) {
${config.name}Style.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
${config.name}Style.borderRadius = "8px";
}
return (
<div
className={\`${config.id}-layout \${isDesignMode ? "design-mode" : ""} \${className}\`}
style={${config.name}Style}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// TODO: 존별 스타일 정의
const zoneStyle: React.CSSProperties = {
// 레이아웃별 존 스타일 구현
};
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "${config.id}-zone",
});
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-${config.id}-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
${config.name} 존을 추가하세요
</div>
)}
</div>
);
};
`;
fs.writeFileSync(path.join(layoutDir, `${config.className}Layout.tsx`), content);
console.log(`${config.className}Layout.tsx 생성됨`);
}
/**
* 레이아웃 렌더러 파일 생성
*/
function createLayoutRenderer() {
const content = `"use client";
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { ${config.className}LayoutDefinition } from "./index";
import { ${config.className}Layout } from "./${config.className}Layout";
/**
* ${config.name} 렌더러 ( 구조)
*/
export class ${config.className}LayoutRenderer extends AutoRegisteringLayoutRenderer {
/**
* 레이아웃 정의 (자동 등록용)
*/
static readonly layoutDefinition = ${config.className}LayoutDefinition;
/**
* 클래스 로드 자동 등록 실행
*/
static {
this.registerSelf();
}
/**
* 렌더링 실행
*/
render(): React.ReactElement {
return <${config.className}Layout {...this.props} renderer={this} />;
}
}
/**
* React 함수 컴포넌트로 래핑 (외부 사용용)
*/
export const ${config.className}LayoutComponent: React.FC<LayoutRendererProps> = (props) => {
const renderer = new ${config.className}LayoutRenderer(props);
return renderer.render();
};
// 개발 모드에서 Hot Reload 지원
if (process.env.NODE_ENV === 'development') {
// HMR API 등록
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.dispose(() => {
${config.className}LayoutRenderer.unregisterSelf();
});
}
}
`;
fs.writeFileSync(path.join(layoutDir, `${config.className}LayoutRenderer.tsx`), content);
console.log(`${config.className}LayoutRenderer.tsx 생성됨`);
}
/**
* 설정 파일 생성
*/
function createConfigFile() {
const content = `/**
* ${config.name} 기본 설정
*/
export const ${config.className}LayoutConfig = {
defaultConfig: {
${config.id}: {
// TODO: 레이아웃 전용 설정 정의
// 예시:
// spacing: 16,
// orientation: "vertical",
// allowResize: true,
},
},
defaultZones: [${generateDefaultZones()}
],
// 설정 스키마 (검증용)
configSchema: {
type: "object",
properties: {
${config.id}: {
type: "object",
properties: {
// TODO: 설정 스키마 정의
},
additionalProperties: false,
},
},
required: ["${config.id}"],
},
};
`;
fs.writeFileSync(path.join(layoutDir, "config.ts"), content);
console.log("✅ config.ts 생성됨");
}
/**
* 타입 정의 파일 생성
*/
function createTypesFile() {
const content = `import { LayoutRendererProps } from "../BaseLayoutRenderer";
/**
* ${config.name} 설정 타입
*/
export interface ${config.className}Config {
// TODO: 레이아웃 전용 설정 타입 정의
// 예시:
// spacing?: number;
// orientation?: "vertical" | "horizontal";
// allowResize?: boolean;
}
/**
* ${config.name} Props 타입
*/
export interface ${config.className}LayoutProps extends LayoutRendererProps {
renderer: any; // ${config.className}LayoutRenderer 타입
}
/**
* ${config.name} 타입
*/
export interface ${config.className}Zone {
id: string;
name: string;
// TODO: 존별 전용 속성 정의
}
`;
fs.writeFileSync(path.join(layoutDir, "types.ts"), content);
console.log("✅ types.ts 생성됨");
}
/**
* README 파일 생성
*/
function createReadme() {
const content = `# ${config.name}
${config.description}
## 사용법
레이아웃은 자동으로 등록되어 화면편집기에서 사용할 있습니다.
## 구성
- \`${config.className}Layout.tsx\`: 메인 레이아웃 컴포넌트
- \`${config.className}LayoutRenderer.tsx\`: 렌더러 (자동 등록)
- \`config.ts\`: 기본 설정
- \`types.ts\`: 타입 정의
- \`index.ts\`: 진입점
## 개발
1. \`${config.className}Layout.tsx\`에서 레이아웃 로직 구현
2. \`config.ts\`에서 기본 설정 조정
3. \`types.ts\`에서 타입 정의 추가
## 설정
\`\`\`typescript
{
${config.id}: {
// TODO: 설정 옵션 문서화
}
}
\`\`\`
## 구성
${generateZoneDocumentation()}
---
생성일: ${new Date().toLocaleDateString()}
버전: 1.0.0
작성자: ${config.author}
`;
fs.writeFileSync(path.join(layoutDir, "README.md"), content);
console.log("✅ README.md 생성됨");
}
/**
* 기본 생성
*/
function generateDefaultZones() {
const zones = [];
for (let i = 1; i <= config.zones; i++) {
zones.push(`
{
id: "zone${i}",
name: "존 ${i}",
position: {},
size: { width: "100%", height: "100%" },
}`);
}
return zones.join(",");
}
/**
* 문서화
*/
function generateZoneDocumentation() {
const docs = [];
for (let i = 1; i <= config.zones; i++) {
docs.push(`- **존 ${i}** (\`zone${i}\`): 기본 영역`);
}
return docs.join("\n");
}
/**
* package.json 스크립트 업데이트
*/
function updatePackageScripts() {
const packagePath = path.join(__dirname, "../package.json");
if (fs.existsSync(packagePath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
if (!packageJson.scripts) {
packageJson.scripts = {};
}
if (!packageJson.scripts["create-layout"]) {
packageJson.scripts["create-layout"] = "node scripts/create-layout.js";
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
console.log("✅ package.json 스크립트 추가됨");
}
} catch (error) {
console.warn("⚠️ package.json 업데이트 실패:", error.message);
}
}
}

429
frontend/types/layout.ts Normal file
View File

@ -0,0 +1,429 @@
// 레이아웃 기능 타입 정의
import { ComponentStyle, ComponentData, ComponentType } from "./screen";
// 레이아웃 타입 정의
export type LayoutType =
| "grid" // 그리드 레이아웃 (n x m 격자)
| "flexbox" // 플렉스박스 레이아웃
| "split" // 분할 레이아웃 (수직/수평)
| "card" // 카드 레이아웃
| "tabs" // 탭 레이아웃
| "accordion" // 아코디언 레이아웃
| "sidebar" // 사이드바 레이아웃
| "header-footer" // 헤더-푸터 레이아웃
| "three-column" // 3단 레이아웃
| "dashboard" // 대시보드 레이아웃
| "form" // 폼 레이아웃
| "table" // 테이블 레이아웃
| "custom"; // 커스텀 레이아웃
// 레이아웃 카테고리
export const LAYOUT_CATEGORIES = {
BASIC: "basic", // 기본 레이아웃
FORM: "form", // 폼 레이아웃
TABLE: "table", // 테이블 레이아웃
DASHBOARD: "dashboard", // 대시보드 레이아웃
NAVIGATION: "navigation", // 네비게이션 레이아웃
CONTENT: "content", // 컨텐츠 레이아웃
BUSINESS: "business", // 업무용 레이아웃
} as const;
export type LayoutCategory = (typeof LAYOUT_CATEGORIES)[keyof typeof LAYOUT_CATEGORIES];
// 레이아웃 존 정의
export interface LayoutZone {
id: string;
name: string;
position: {
row?: number;
column?: number;
x?: number;
y?: number;
};
size: {
width: number | string;
height: number | string;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
};
style?: ComponentStyle;
allowedComponents?: ComponentType[];
isResizable?: boolean;
isRequired?: boolean; // 필수 영역 여부
}
// 레이아웃 설정
export interface LayoutConfig {
// 그리드 레이아웃 설정
grid?: {
rows: number;
columns: number;
gap: number;
rowGap?: number;
columnGap?: number;
autoRows?: string;
autoColumns?: string;
};
// 플렉스박스 설정
flexbox?: {
direction: "row" | "column" | "row-reverse" | "column-reverse";
justify: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
wrap: "nowrap" | "wrap" | "wrap-reverse";
gap: number;
};
// 분할 레이아웃 설정
split?: {
direction: "horizontal" | "vertical";
ratio: number[]; // 각 영역의 비율 [30, 70]
minSize: number[]; // 각 영역의 최소 크기
resizable: boolean; // 크기 조절 가능 여부
splitterSize: number; // 분할선 두께
};
// 탭 레이아웃 설정
tabs?: {
position: "top" | "bottom" | "left" | "right";
variant: "default" | "pills" | "underline";
size: "sm" | "md" | "lg";
defaultTab: string; // 기본 선택 탭
closable: boolean; // 탭 닫기 가능 여부
};
// 아코디언 설정
accordion?: {
multiple: boolean; // 다중 확장 허용
defaultExpanded: string[]; // 기본 확장 항목
collapsible: boolean; // 모두 닫기 허용
};
// 사이드바 설정
sidebar?: {
position: "left" | "right";
width: number | string;
collapsible: boolean;
collapsed: boolean;
overlay: boolean; // 오버레이 모드
};
// 헤더-푸터 설정
headerFooter?: {
headerHeight: number | string;
footerHeight: number | string;
stickyHeader: boolean;
stickyFooter: boolean;
};
// 대시보드 설정
dashboard?: {
columns: number;
rowHeight: number;
margin: [number, number];
padding: [number, number];
isDraggable: boolean;
isResizable: boolean;
};
// 커스텀 설정
custom?: {
cssProperties: Record<string, string>;
className: string;
template: string; // HTML 템플릿
};
}
// 드롭존 설정
export interface DropZoneConfig {
showDropZones: boolean;
dropZoneStyle?: ComponentStyle;
highlightOnDragOver: boolean;
allowedTypes?: ComponentType[];
}
// 레이아웃 컴포넌트 인터페이스 (기존 screen.ts에 추가될 예정)
export interface LayoutComponent {
id: string;
type: "layout";
layoutType: LayoutType;
layoutConfig: LayoutConfig;
children: ComponentData[];
zones: LayoutZone[]; // 레이아웃 영역 정의
allowedComponentTypes?: ComponentType[]; // 허용된 자식 컴포넌트 타입
dropZoneConfig?: DropZoneConfig; // 드롭존 설정
position: { x: number; y: number };
size: { width: number; height: number };
parentId?: string;
style?: ComponentStyle;
tableName?: string;
label?: string;
}
// 레이아웃 표준 정의 (데이터베이스에서 조회)
export interface LayoutStandard {
layoutCode: string;
layoutName: string;
layoutNameEng?: string;
description?: string;
layoutType: LayoutType;
category: LayoutCategory;
iconName?: string;
defaultSize?: { width: number; height: number };
layoutConfig: LayoutConfig;
zonesConfig: LayoutZone[];
previewImage?: string;
sortOrder?: number;
isActive?: string;
isPublic?: string;
companyCode: string;
createdDate?: Date;
createdBy?: string;
updatedDate?: Date;
updatedBy?: string;
}
// 레이아웃 생성 요청
export interface CreateLayoutRequest {
layoutName: string;
layoutNameEng?: string;
description?: string;
layoutType: LayoutType;
category: LayoutCategory;
iconName?: string;
defaultSize?: { width: number; height: number };
layoutConfig: LayoutConfig;
zonesConfig: LayoutZone[];
isPublic?: boolean;
}
// 레이아웃 수정 요청
export interface UpdateLayoutRequest extends Partial<CreateLayoutRequest> {
layoutCode: string;
}
// 레이아웃 정의 (레지스트리에서 사용)
export interface LayoutDefinition {
id: string;
name: string;
nameEng?: string;
description?: string;
category: LayoutCategory;
icon?: string;
component: React.ComponentType<any>;
defaultConfig: LayoutConfig;
defaultZones: LayoutZone[];
tags?: string[];
isActive?: boolean;
metadata?: {
version?: string;
author?: string;
documentation?: string;
createdAt?: string;
lastUpdated?: string;
[key: string]: any;
};
}
// 사전 정의 레이아웃 템플릿
export const PREDEFINED_LAYOUTS: Omit<
LayoutStandard,
"layoutCode" | "companyCode" | "createdDate" | "createdBy" | "updatedDate" | "updatedBy"
>[] = [
// 기본 레이아웃
{
layoutName: "2x2 그리드",
layoutNameEng: "2x2 Grid",
layoutType: "grid",
category: "basic",
iconName: "grid",
defaultSize: { width: 800, height: 600 },
layoutConfig: {
grid: { rows: 2, columns: 2, gap: 16 },
},
zonesConfig: [
{
id: "zone1",
name: "상단 좌측",
position: { row: 0, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone2",
name: "상단 우측",
position: { row: 0, column: 1 },
size: { width: "50%", height: "50%" },
},
{
id: "zone3",
name: "하단 좌측",
position: { row: 1, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone4",
name: "하단 우측",
position: { row: 1, column: 1 },
size: { width: "50%", height: "50%" },
},
],
sortOrder: 1,
},
// 폼 레이아웃
{
layoutName: "2단 폼 레이아웃",
layoutNameEng: "Two Column Form",
layoutType: "grid",
category: "form",
iconName: "columns",
defaultSize: { width: 800, height: 400 },
layoutConfig: {
grid: { rows: 1, columns: 2, gap: 24 },
},
zonesConfig: [
{
id: "left",
name: "좌측 입력 영역",
position: { row: 0, column: 0 },
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 입력 영역",
position: { row: 0, column: 1 },
size: { width: "50%", height: "100%" },
},
],
sortOrder: 2,
},
// 대시보드 레이아웃
{
layoutName: "메인 대시보드",
layoutNameEng: "Main Dashboard",
layoutType: "grid",
category: "dashboard",
iconName: "layout-dashboard",
defaultSize: { width: 1200, height: 800 },
layoutConfig: {
grid: { rows: 2, columns: 4, gap: 16 },
},
zonesConfig: [
{
id: "header",
name: "헤더",
position: { row: 0, column: 0 },
size: { width: "100%", height: "80px" },
},
{
id: "sidebar",
name: "사이드바",
position: { row: 1, column: 0 },
size: { width: "250px", height: "100%" },
},
{
id: "main",
name: "메인 컨텐츠",
position: { row: 1, column: 1 },
size: { width: "calc(100% - 250px)", height: "100%" },
},
],
sortOrder: 3,
},
// 테이블 레이아웃
{
layoutName: "필터가 있는 테이블",
layoutNameEng: "Table with Filters",
layoutType: "flexbox",
category: "table",
iconName: "table",
defaultSize: { width: 1000, height: 600 },
layoutConfig: {
flexbox: { direction: "column", justify: "flex-start", align: "stretch", wrap: "nowrap", gap: 16 },
},
zonesConfig: [
{
id: "filters",
name: "검색 필터",
position: {},
size: { width: "100%", height: "auto" },
},
{
id: "table",
name: "데이터 테이블",
position: {},
size: { width: "100%", height: "1fr" },
},
],
sortOrder: 4,
},
// 분할 레이아웃
{
layoutName: "수평 분할",
layoutNameEng: "Horizontal Split",
layoutType: "split",
category: "basic",
iconName: "separator-horizontal",
defaultSize: { width: 800, height: 400 },
layoutConfig: {
split: { direction: "horizontal", ratio: [50, 50], minSize: [200, 200], resizable: true, splitterSize: 4 },
},
zonesConfig: [
{
id: "left",
name: "좌측 영역",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
{
id: "right",
name: "우측 영역",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
],
sortOrder: 5,
},
// 탭 레이아웃
{
layoutName: "수평 탭",
layoutNameEng: "Horizontal Tabs",
layoutType: "tabs",
category: "navigation",
iconName: "tabs",
defaultSize: { width: 800, height: 500 },
layoutConfig: {
tabs: { position: "top", variant: "default", size: "md", defaultTab: "tab1", closable: false },
},
zonesConfig: [
{
id: "tab1",
name: "첫 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab2",
name: "두 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab3",
name: "세 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
],
sortOrder: 6,
},
];

View File

@ -1,7 +1,16 @@
// 화면관리 시스템 타입 정의
// 기본 컴포넌트 타입
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable" | "file" | "area";
export type ComponentType =
| "container"
| "row"
| "column"
| "widget"
| "group"
| "datatable"
| "file"
| "area"
| "layout";
// 웹 타입 정의
export type WebType =
@ -488,7 +497,8 @@ export type ComponentData =
| AreaComponent
| WidgetComponent
| DataTableComponent
| FileComponent;
| FileComponent
| LayoutComponent;
// 레이아웃 데이터
export interface LayoutData {
@ -541,6 +551,17 @@ export interface CreateScreenRequest {
createdBy?: string;
}
// 레이아웃 컴포넌트 (layout.ts에서 import)
export interface LayoutComponent extends BaseComponent {
type: "layout";
layoutType: import("./layout").LayoutType;
layoutConfig: import("./layout").LayoutConfig;
children: ComponentData[];
zones: import("./layout").LayoutZone[];
allowedComponentTypes?: ComponentType[];
dropZoneConfig?: import("./layout").DropZoneConfig;
}
// 화면 수정 요청
export interface UpdateScreenRequest {
screenName?: string;