Merge pull request 'feature/screen-management' (#28) from feature/screen-management into dev

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/28
This commit is contained in:
kjs 2025-09-12 16:49:57 +09:00
commit 9e3746bdad
252 changed files with 33302 additions and 2046 deletions

View File

@ -5020,6 +5020,10 @@ model screen_layouts {
height Int height Int
properties Json? properties Json?
display_order Int @default(0) 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) created_date DateTime @default(now()) @db.Timestamp(6)
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
widgets screen_widgets[] widgets screen_widgets[]
@ -5255,6 +5259,33 @@ model component_standards {
@@index([category], map: "idx_component_standards_category") @@index([category], map: "idx_component_standards_category")
@@index([company_code], map: "idx_component_standards_company") @@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")
}
model table_relationships { model table_relationships {
relationship_id Int @id @default(autoincrement()) relationship_id Int @id @default(autoincrement())
diagram_id Int // 관계도 그룹 식별자 diagram_id Int // 관계도 그룹 식별자

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

@ -0,0 +1,46 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function getComponents() {
try {
const components = await prisma.component_standards.findMany({
where: { is_active: "Y" },
select: {
component_code: true,
component_name: true,
category: true,
component_config: true,
},
orderBy: [{ category: "asc" }, { sort_order: "asc" }],
});
console.log("📋 데이터베이스 컴포넌트 목록:");
console.log("=".repeat(60));
const grouped = components.reduce((acc, comp) => {
if (!acc[comp.category]) {
acc[comp.category] = [];
}
acc[comp.category].push(comp);
return acc;
}, {});
Object.entries(grouped).forEach(([category, comps]) => {
console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`);
comps.forEach((comp) => {
const type = comp.component_config?.type || "unknown";
console.log(
` - ${comp.component_code}: ${comp.component_name} (type: ${type})`
);
});
});
console.log(`\n${components.length}개 컴포넌트 발견`);
} catch (error) {
console.error("Error:", error);
} finally {
await prisma.$disconnect();
}
}
getComponents();

View File

@ -26,6 +26,8 @@ import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
import screenStandardRoutes from "./routes/screenStandardRoutes"; import screenStandardRoutes from "./routes/screenStandardRoutes";
import templateStandardRoutes from "./routes/templateStandardRoutes"; import templateStandardRoutes from "./routes/templateStandardRoutes";
import componentStandardRoutes from "./routes/componentStandardRoutes"; import componentStandardRoutes from "./routes/componentStandardRoutes";
import layoutRoutes from "./routes/layoutRoutes";
import dataRoutes from "./routes/dataRoutes";
// import userRoutes from './routes/userRoutes'; // import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes'; // import menuRoutes from './routes/menuRoutes';
@ -114,7 +116,9 @@ app.use("/api/admin/web-types", webTypeStandardRoutes);
app.use("/api/admin/button-actions", buttonActionStandardRoutes); app.use("/api/admin/button-actions", buttonActionStandardRoutes);
app.use("/api/admin/template-standards", templateStandardRoutes); app.use("/api/admin/template-standards", templateStandardRoutes);
app.use("/api/admin/component-standards", componentStandardRoutes); app.use("/api/admin/component-standards", componentStandardRoutes);
app.use("/api/layouts", layoutRoutes);
app.use("/api/screen", screenStandardRoutes); app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes); // app.use('/api/menus', menuRoutes);

View File

@ -204,7 +204,15 @@ class ComponentStandardController {
}); });
return; return;
} catch (error) { } catch (error) {
console.error("컴포넌트 수정 실패:", error); const { component_code } = req.params;
const updateData = req.body;
console.error("컴포넌트 수정 실패 [상세]:", {
component_code,
updateData,
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined,
});
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: "컴포넌트 수정에 실패했습니다.", message: "컴포넌트 수정에 실패했습니다.",
@ -382,6 +390,52 @@ class ComponentStandardController {
return; return;
} }
} }
/**
*
*/
async checkDuplicate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { component_code } = req.params;
if (!component_code) {
res.status(400).json({
success: false,
message: "컴포넌트 코드가 필요합니다.",
});
return;
}
const isDuplicate = await componentStandardService.checkDuplicate(
component_code,
req.user?.companyCode
);
console.log(
`🔍 중복 체크 결과: component_code=${component_code}, company_code=${req.user?.companyCode}, isDuplicate=${isDuplicate}`
);
res.status(200).json({
success: true,
data: { isDuplicate, component_code },
message: isDuplicate
? "이미 사용 중인 컴포넌트 코드입니다."
: "사용 가능한 컴포넌트 코드입니다.",
});
return;
} catch (error) {
console.error("컴포넌트 코드 중복 체크 실패:", error);
res.status(500).json({
success: false,
message: "컴포넌트 코드 중복 체크에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
return;
}
}
} }
export default new ComponentStandardController(); export default new ComponentStandardController();

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

@ -1,8 +1,14 @@
import { Response } from "express"; import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { templateStandardService } from "../services/templateStandardService"; import { templateStandardService } from "../services/templateStandardService";
import { handleError } from "../utils/errorHandler";
import { checkMissingFields } from "../utils/validation"; interface AuthenticatedRequest extends Request {
user?: {
userId: string;
companyCode: string;
company_code?: string;
[key: string]: any;
};
}
/** /**
* 릿 * 릿
@ -11,26 +17,26 @@ export class TemplateStandardController {
/** /**
* 릿 * 릿
*/ */
async getTemplates(req: AuthenticatedRequest, res: Response) { async getTemplates(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const { const {
active = "Y", active = "Y",
category, category,
search, search,
companyCode, company_code,
is_public = "Y", is_public = "Y",
page = "1", page = "1",
limit = "50", limit = "50",
} = req.query; } = req.query;
const user = req.user; const user = req.user;
const userCompanyCode = user?.companyCode || "DEFAULT"; const userCompanyCode = user?.company_code || "DEFAULT";
const result = await templateStandardService.getTemplates({ const result = await templateStandardService.getTemplates({
active: active as string, active: active as string,
category: category as string, category: category as string,
search: search as string, search: search as string,
company_code: (companyCode as string) || userCompanyCode, company_code: (company_code as string) || userCompanyCode,
is_public: is_public as string, is_public: is_public as string,
page: parseInt(page as string), page: parseInt(page as string),
limit: parseInt(limit as string), limit: parseInt(limit as string),
@ -47,23 +53,24 @@ export class TemplateStandardController {
}, },
}); });
} catch (error) { } catch (error) {
return handleError( console.error("템플릿 목록 조회 중 오류:", error);
res, res.status(500).json({
error, success: false,
"템플릿 목록 조회 중 오류가 발생했습니다." message: "템플릿 목록 조회 중 오류가 발생했습니다.",
); error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 * 릿
*/ */
async getTemplate(req: AuthenticatedRequest, res: Response) { async getTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const { templateCode } = req.params; const { templateCode } = req.params;
if (!templateCode) { if (!templateCode) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "템플릿 코드가 필요합니다.", error: "템플릿 코드가 필요합니다.",
}); });
@ -72,7 +79,7 @@ export class TemplateStandardController {
const template = await templateStandardService.getTemplate(templateCode); const template = await templateStandardService.getTemplate(templateCode);
if (!template) { if (!template) {
return res.status(404).json({ res.status(404).json({
success: false, success: false,
error: "템플릿을 찾을 수 없습니다.", error: "템플릿을 찾을 수 없습니다.",
}); });
@ -83,40 +90,46 @@ export class TemplateStandardController {
data: template, data: template,
}); });
} catch (error) { } catch (error) {
return handleError(res, error, "템플릿 조회 중 오류가 발생했습니다."); console.error("템플릿 조회 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 * 릿
*/ */
async createTemplate(req: AuthenticatedRequest, res: Response) { async createTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try { try {
const user = req.user; const user = req.user;
const templateData = req.body; const templateData = req.body;
// 필수 필드 검증 // 필수 필드 검증
const requiredFields = [ if (
"template_code", !templateData.template_code ||
"template_name", !templateData.template_name ||
"category", !templateData.category ||
"layout_config", !templateData.layout_config
]; ) {
const missingFields = checkMissingFields(templateData, requiredFields); res.status(400).json({
if (missingFields.length > 0) {
return res.status(400).json({
success: false, success: false,
error: `필수 필드가 누락되었습니다: ${missingFields.join(", ")}`, message:
"필수 필드가 누락되었습니다. (template_code, template_name, category, layout_config)",
}); });
} }
// 회사 코드와 생성자 정보 추가 // 회사 코드와 생성자 정보 추가
const templateWithMeta = { const templateWithMeta = {
...templateData, ...templateData,
company_code: user?.companyCode || "DEFAULT", company_code: user?.company_code || "DEFAULT",
created_by: user?.userId || "system", created_by: user?.user_id || "system",
updated_by: user?.userId || "system", updated_by: user?.user_id || "system",
}; };
const newTemplate = const newTemplate =
@ -128,21 +141,29 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 생성되었습니다.", message: "템플릿이 성공적으로 생성되었습니다.",
}); });
} catch (error) { } catch (error) {
return handleError(res, error, "템플릿 생성 중 오류가 발생했습니다."); console.error("템플릿 생성 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 * 릿
*/ */
async updateTemplate(req: AuthenticatedRequest, res: Response) { async updateTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try { try {
const { templateCode } = req.params; const { templateCode } = req.params;
const templateData = req.body; const templateData = req.body;
const user = req.user; const user = req.user;
if (!templateCode) { if (!templateCode) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "템플릿 코드가 필요합니다.", error: "템플릿 코드가 필요합니다.",
}); });
@ -151,7 +172,7 @@ export class TemplateStandardController {
// 수정자 정보 추가 // 수정자 정보 추가
const templateWithMeta = { const templateWithMeta = {
...templateData, ...templateData,
updated_by: user?.userId || "system", updated_by: user?.user_id || "system",
}; };
const updatedTemplate = await templateStandardService.updateTemplate( const updatedTemplate = await templateStandardService.updateTemplate(
@ -160,7 +181,7 @@ export class TemplateStandardController {
); );
if (!updatedTemplate) { if (!updatedTemplate) {
return res.status(404).json({ res.status(404).json({
success: false, success: false,
error: "템플릿을 찾을 수 없습니다.", error: "템플릿을 찾을 수 없습니다.",
}); });
@ -172,19 +193,27 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 수정되었습니다.", message: "템플릿이 성공적으로 수정되었습니다.",
}); });
} catch (error) { } catch (error) {
return handleError(res, error, "템플릿 수정 중 오류가 발생했습니다."); console.error("템플릿 수정 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 * 릿
*/ */
async deleteTemplate(req: AuthenticatedRequest, res: Response) { async deleteTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try { try {
const { templateCode } = req.params; const { templateCode } = req.params;
if (!templateCode) { if (!templateCode) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "템플릿 코드가 필요합니다.", error: "템플릿 코드가 필요합니다.",
}); });
@ -194,7 +223,7 @@ export class TemplateStandardController {
await templateStandardService.deleteTemplate(templateCode); await templateStandardService.deleteTemplate(templateCode);
if (!deleted) { if (!deleted) {
return res.status(404).json({ res.status(404).json({
success: false, success: false,
error: "템플릿을 찾을 수 없습니다.", error: "템플릿을 찾을 수 없습니다.",
}); });
@ -205,19 +234,27 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 삭제되었습니다.", message: "템플릿이 성공적으로 삭제되었습니다.",
}); });
} catch (error) { } catch (error) {
return handleError(res, error, "템플릿 삭제 중 오류가 발생했습니다."); console.error("템플릿 삭제 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 * 릿
*/ */
async updateSortOrder(req: AuthenticatedRequest, res: Response) { async updateSortOrder(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try { try {
const { templates } = req.body; const { templates } = req.body;
if (!Array.isArray(templates)) { if (!Array.isArray(templates)) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "templates는 배열이어야 합니다.", error: "templates는 배열이어야 합니다.",
}); });
@ -230,25 +267,29 @@ export class TemplateStandardController {
message: "템플릿 정렬 순서가 성공적으로 업데이트되었습니다.", message: "템플릿 정렬 순서가 성공적으로 업데이트되었습니다.",
}); });
} catch (error) { } catch (error) {
return handleError( console.error("템플릿 정렬 순서 업데이트 중 오류:", error);
res, res.status(500).json({
error, success: false,
"템플릿 정렬 순서 업데이트 중 오류가 발생했습니다." message: "템플릿 정렬 순서 업데이트 중 오류가 발생했습니다.",
); error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 * 릿
*/ */
async duplicateTemplate(req: AuthenticatedRequest, res: Response) { async duplicateTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try { try {
const { templateCode } = req.params; const { templateCode } = req.params;
const { new_template_code, new_template_name } = req.body; const { new_template_code, new_template_name } = req.body;
const user = req.user; const user = req.user;
if (!templateCode || !new_template_code || !new_template_name) { if (!templateCode || !new_template_code || !new_template_name) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "필수 필드가 누락되었습니다.", error: "필수 필드가 누락되었습니다.",
}); });
@ -259,8 +300,8 @@ export class TemplateStandardController {
originalCode: templateCode, originalCode: templateCode,
newCode: new_template_code, newCode: new_template_code,
newName: new_template_name, newName: new_template_name,
company_code: user?.companyCode || "DEFAULT", company_code: user?.company_code || "DEFAULT",
created_by: user?.userId || "system", created_by: user?.user_id || "system",
}); });
res.status(201).json({ res.status(201).json({
@ -269,17 +310,22 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 복제되었습니다.", message: "템플릿이 성공적으로 복제되었습니다.",
}); });
} catch (error) { } catch (error) {
return handleError(res, error, "템플릿 복제 중 오류가 발생했습니다."); console.error("템플릿 복제 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 복제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 * 릿
*/ */
async getCategories(req: AuthenticatedRequest, res: Response) { async getCategories(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const user = req.user; const user = req.user;
const companyCode = user?.companyCode || "DEFAULT"; const companyCode = user?.company_code || "DEFAULT";
const categories = const categories =
await templateStandardService.getCategories(companyCode); await templateStandardService.getCategories(companyCode);
@ -289,24 +335,28 @@ export class TemplateStandardController {
data: categories, data: categories,
}); });
} catch (error) { } catch (error) {
return handleError( console.error("템플릿 카테고리 조회 중 오류:", error);
res, res.status(500).json({
error, success: false,
"템플릿 카테고리 조회 중 오류가 발생했습니다." message: "템플릿 카테고리 조회 중 오류가 발생했습니다.",
); error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 (JSON ) * 릿 (JSON )
*/ */
async importTemplate(req: AuthenticatedRequest, res: Response) { async importTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try { try {
const user = req.user; const user = req.user;
const templateData = req.body; const templateData = req.body;
if (!templateData.layout_config) { if (!templateData.layout_config) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "유효한 템플릿 데이터가 아닙니다.", error: "유효한 템플릿 데이터가 아닙니다.",
}); });
@ -315,9 +365,9 @@ export class TemplateStandardController {
// 회사 코드와 생성자 정보 추가 // 회사 코드와 생성자 정보 추가
const templateWithMeta = { const templateWithMeta = {
...templateData, ...templateData,
company_code: user?.companyCode || "DEFAULT", company_code: user?.company_code || "DEFAULT",
created_by: user?.userId || "system", created_by: user?.user_id || "system",
updated_by: user?.userId || "system", updated_by: user?.user_id || "system",
}; };
const importedTemplate = const importedTemplate =
@ -329,31 +379,41 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 가져왔습니다.", message: "템플릿이 성공적으로 가져왔습니다.",
}); });
} catch (error) { } catch (error) {
return handleError(res, error, "템플릿 가져오기 중 오류가 발생했습니다."); console.error("템플릿 가져오기 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 가져오기 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
/** /**
* 릿 (JSON ) * 릿 (JSON )
*/ */
async exportTemplate(req: AuthenticatedRequest, res: Response) { async exportTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try { try {
const { templateCode } = req.params; const { templateCode } = req.params;
if (!templateCode) { if (!templateCode) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "템플릿 코드가 필요합니다.", error: "템플릿 코드가 필요합니다.",
}); });
return;
} }
const template = await templateStandardService.getTemplate(templateCode); const template = await templateStandardService.getTemplate(templateCode);
if (!template) { if (!template) {
return res.status(404).json({ res.status(404).json({
success: false, success: false,
error: "템플릿을 찾을 수 없습니다.", error: "템플릿을 찾을 수 없습니다.",
}); });
return;
} }
// 내보내기용 데이터 (메타데이터 제외) // 내보내기용 데이터 (메타데이터 제외)
@ -373,7 +433,12 @@ export class TemplateStandardController {
data: exportData, data: exportData,
}); });
} catch (error) { } catch (error) {
return handleError(res, error, "템플릿 내보내기 중 오류가 발생했습니다."); console.error("템플릿 내보내기 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 내보내기 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
} }
} }
} }

View File

@ -25,6 +25,12 @@ router.get(
componentStandardController.getStatistics.bind(componentStandardController) componentStandardController.getStatistics.bind(componentStandardController)
); );
// 컴포넌트 코드 중복 체크
router.get(
"/check-duplicate/:component_code",
componentStandardController.checkDuplicate.bind(componentStandardController)
);
// 컴포넌트 상세 조회 // 컴포넌트 상세 조회
router.get( router.get(
"/:component_code", "/:component_code",

View File

@ -0,0 +1,130 @@
import express from "express";
import { dataService } from "../services/dataService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
const router = express.Router();
/**
* API
* GET /api/data/{tableName}
*/
router.get(
"/:tableName",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName } = req.params;
const { limit = "10", offset = "0", orderBy, ...filters } = req.query;
// 입력값 검증
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
error: "INVALID_TABLE_NAME",
});
}
// SQL 인젝션 방지를 위한 테이블명 검증
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
error: "INVALID_TABLE_NAME",
});
}
console.log(`📊 데이터 조회 요청: ${tableName}`, {
limit: parseInt(limit as string),
offset: parseInt(offset as string),
orderBy: orderBy as string,
filters,
user: req.user?.userId,
});
// 데이터 조회
const result = await dataService.getTableData({
tableName,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
orderBy: orderBy as string,
filters: filters as Record<string, string>,
userCompany: req.user?.companyCode,
});
if (!result.success) {
return res.status(400).json(result);
}
console.log(
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`
);
return res.json(result.data);
} catch (error) {
console.error("데이터 조회 오류:", error);
return res.status(500).json({
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
);
/**
* API
* GET /api/data/{tableName}/columns
*/
router.get(
"/:tableName/columns",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName } = req.params;
// 입력값 검증
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
error: "INVALID_TABLE_NAME",
});
}
// SQL 인젝션 방지를 위한 테이블명 검증
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
error: "INVALID_TABLE_NAME",
});
}
console.log(`📋 컬럼 정보 조회: ${tableName}`);
// 컬럼 정보 조회
const result = await dataService.getTableColumns(tableName);
if (!result.success) {
return res.status(400).json(result);
}
console.log(
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`
);
return res.json(result);
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
return res.status(500).json({
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
);
export default router;

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

@ -131,9 +131,16 @@ class ComponentStandardService {
); );
} }
// 'active' 필드를 'is_active'로 변환
const createData = { ...data };
if ("active" in createData) {
createData.is_active = (createData as any).active;
delete (createData as any).active;
}
const component = await prisma.component_standards.create({ const component = await prisma.component_standards.create({
data: { data: {
...data, ...createData,
created_date: new Date(), created_date: new Date(),
updated_date: new Date(), updated_date: new Date(),
}, },
@ -151,10 +158,17 @@ class ComponentStandardService {
) { ) {
const existing = await this.getComponent(component_code); const existing = await this.getComponent(component_code);
// 'active' 필드를 'is_active'로 변환
const updateData = { ...data };
if ("active" in updateData) {
updateData.is_active = (updateData as any).active;
delete (updateData as any).active;
}
const component = await prisma.component_standards.update({ const component = await prisma.component_standards.update({
where: { component_code }, where: { component_code },
data: { data: {
...data, ...updateData,
updated_date: new Date(), updated_date: new Date(),
}, },
}); });
@ -216,21 +230,19 @@ class ComponentStandardService {
data: { data: {
component_code: new_code, component_code: new_code,
component_name: new_name, component_name: new_name,
component_name_eng: source.component_name_eng, component_name_eng: source?.component_name_eng,
description: source.description, description: source?.description,
category: source.category, category: source?.category,
icon_name: source.icon_name, icon_name: source?.icon_name,
default_size: source.default_size as any, default_size: source?.default_size as any,
component_config: source.component_config as any, component_config: source?.component_config as any,
preview_image: source.preview_image, preview_image: source?.preview_image,
sort_order: source.sort_order, sort_order: source?.sort_order,
is_active: source.is_active, is_active: source?.is_active,
is_public: source.is_public, is_public: source?.is_public,
company_code: source.company_code, company_code: source?.company_code || "DEFAULT",
created_date: new Date(), created_date: new Date(),
created_by: source.created_by,
updated_date: new Date(), updated_date: new Date(),
updated_by: source.updated_by,
}, },
}); });
@ -297,6 +309,27 @@ class ComponentStandardService {
})), })),
}; };
} }
/**
*
*/
async checkDuplicate(
component_code: string,
company_code?: string
): Promise<boolean> {
const whereClause: any = { component_code };
// 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가
if (company_code && company_code !== "*") {
whereClause.company_code = company_code;
}
const existingComponent = await prisma.component_standards.findFirst({
where: whereClause,
});
return !!existingComponent;
}
} }
export default new ComponentStandardService(); export default new ComponentStandardService();

View File

@ -0,0 +1,328 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
interface GetTableDataParams {
tableName: string;
limit?: number;
offset?: number;
orderBy?: string;
filters?: Record<string, string>;
userCompany?: string;
}
interface ServiceResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
/**
* ()
* SQL
*/
const ALLOWED_TABLES = [
"company_mng",
"user_info",
"dept_info",
"code_info",
"code_category",
"menu_info",
"approval",
"approval_kind",
"board",
"comm_code",
"product_mng",
"part_mng",
"material_mng",
"order_mng_master",
"inventory_mng",
"contract_mgmt",
"project_mgmt",
"screen_definitions",
"screen_layouts",
"layout_standards",
"component_standards",
"web_type_standards",
"button_action_standards",
"template_standards",
"grid_standards",
"style_templates",
"multi_lang_key_master",
"multi_lang_text",
"language_master",
"table_labels",
"column_labels",
"dynamic_form_data",
];
/**
*
*/
const COMPANY_FILTERED_TABLES = [
"company_mng",
"user_info",
"dept_info",
"approval",
"board",
"product_mng",
"part_mng",
"material_mng",
"order_mng_master",
"inventory_mng",
"contract_mgmt",
"project_mgmt",
];
class DataService {
/**
*
*/
async getTableData(
params: GetTableDataParams
): Promise<ServiceResponse<any[]>> {
const {
tableName,
limit = 10,
offset = 0,
orderBy,
filters = {},
userCompany,
} = params;
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
}
// 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
return {
success: false,
message: `테이블을 찾을 수 없습니다: ${tableName}`,
error: "TABLE_NOT_FOUND",
};
}
// 동적 SQL 쿼리 생성
let query = `SELECT * FROM "${tableName}"`;
const queryParams: any[] = [];
let paramIndex = 1;
// WHERE 조건 생성
const whereConditions: string[] = [];
// 회사별 필터링 추가
if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) {
// 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용
if (userCompany !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(userCompany);
paramIndex++;
}
}
// 사용자 정의 필터 추가
for (const [key, value] of Object.entries(filters)) {
if (
value &&
key !== "limit" &&
key !== "offset" &&
key !== "orderBy" &&
key !== "userLang"
) {
// 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
continue; // 유효하지 않은 컬럼명은 무시
}
whereConditions.push(`"${key}" ILIKE $${paramIndex}`);
queryParams.push(`%${value}%`);
paramIndex++;
}
}
// WHERE 절 추가
if (whereConditions.length > 0) {
query += ` WHERE ${whereConditions.join(" AND ")}`;
}
// ORDER BY 절 추가
if (orderBy) {
// ORDER BY 검증 (SQL 인젝션 방지)
const orderParts = orderBy.split(" ");
const columnName = orderParts[0];
const direction = orderParts[1]?.toUpperCase();
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
const validDirection = direction === "DESC" ? "DESC" : "ASC";
query += ` ORDER BY "${columnName}" ${validDirection}`;
}
} else {
// 기본 정렬: 최신순 (가능한 컬럼 시도)
const dateColumns = [
"created_date",
"regdate",
"reg_date",
"updated_date",
"upd_date",
];
const tableColumns = await this.getTableColumnsSimple(tableName);
const availableDateColumn = dateColumns.find((col) =>
tableColumns.some((tableCol) => tableCol.column_name === col)
);
if (availableDateColumn) {
query += ` ORDER BY "${availableDateColumn}" DESC`;
}
}
// LIMIT과 OFFSET 추가
query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
queryParams.push(limit, offset);
console.log("🔍 실행할 쿼리:", query);
console.log("📊 쿼리 파라미터:", queryParams);
// 쿼리 실행
const result = await prisma.$queryRawUnsafe(query, ...queryParams);
return {
success: true,
data: result as any[],
};
} catch (error) {
console.error(`데이터 조회 오류 (${tableName}):`, error);
return {
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
*
*/
async getTableColumns(tableName: string): Promise<ServiceResponse<any[]>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
}
const columns = await this.getTableColumnsSimple(tableName);
// 컬럼 라벨 정보 추가
const columnsWithLabels = await Promise.all(
columns.map(async (column) => {
const label = await this.getColumnLabel(
tableName,
column.column_name
);
return {
columnName: column.column_name,
columnLabel: label || column.column_name,
dataType: column.data_type,
isNullable: column.is_nullable === "YES",
defaultValue: column.column_default,
};
})
);
return {
success: true,
data: columnsWithLabels,
};
} catch (error) {
console.error(`컬럼 정보 조회 오류 (${tableName}):`, error);
return {
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
*
*/
private async checkTableExists(tableName: string): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
);
`,
tableName
);
return (result as any)[0]?.exists || false;
} catch (error) {
console.error("테이블 존재 확인 오류:", error);
return false;
}
}
/**
* ( )
*/
private async getTableColumnsSimple(tableName: string): Promise<any[]> {
const result = await prisma.$queryRawUnsafe(
`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position;
`,
tableName
);
return result as any[];
}
/**
*
*/
private async getColumnLabel(
tableName: string,
columnName: string
): Promise<string | null> {
try {
// column_labels 테이블에서 라벨 조회
const result = await prisma.$queryRawUnsafe(
`
SELECT label_ko
FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1;
`,
tableName,
columnName
);
const labelResult = result as any[];
return labelResult[0]?.label_ko || null;
} catch (error) {
// column_labels 테이블이 없거나 오류가 발생하면 null 반환
return null;
}
}
}
export const dataService = new DataService();

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

@ -1,69 +0,0 @@
import { Response } from "express";
import { logger } from "./logger";
/**
*
*/
export const handleError = (
res: Response,
error: any,
message: string = "서버 오류가 발생했습니다."
) => {
logger.error(`Error: ${message}`, error);
res.status(500).json({
success: false,
error: {
code: "SERVER_ERROR",
details: message,
},
});
};
/**
*
*/
export const handleBadRequest = (
res: Response,
message: string = "잘못된 요청입니다."
) => {
res.status(400).json({
success: false,
error: {
code: "BAD_REQUEST",
details: message,
},
});
};
/**
*
*/
export const handleNotFound = (
res: Response,
message: string = "요청한 리소스를 찾을 수 없습니다."
) => {
res.status(404).json({
success: false,
error: {
code: "NOT_FOUND",
details: message,
},
});
};
/**
*
*/
export const handleUnauthorized = (
res: Response,
message: string = "권한이 없습니다."
) => {
res.status(403).json({
success: false,
error: {
code: "UNAUTHORIZED",
details: message,
},
});
};

View File

@ -1,101 +0,0 @@
/**
*
*/
/**
*
*/
export const validateRequired = (value: any, fieldName: string): void => {
if (value === null || value === undefined || value === "") {
throw new Error(`${fieldName}은(는) 필수 입력값입니다.`);
}
};
/**
*
*/
export const validateRequiredFields = (
data: Record<string, any>,
requiredFields: string[]
): void => {
for (const field of requiredFields) {
validateRequired(data[field], field);
}
};
/**
*
*/
export const validateStringLength = (
value: string,
fieldName: string,
minLength?: number,
maxLength?: number
): void => {
if (minLength !== undefined && value.length < minLength) {
throw new Error(
`${fieldName}은(는) 최소 ${minLength}자 이상이어야 합니다.`
);
}
if (maxLength !== undefined && value.length > maxLength) {
throw new Error(`${fieldName}은(는) 최대 ${maxLength}자 이하여야 합니다.`);
}
};
/**
*
*/
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
*
*/
export const validateNumberRange = (
value: number,
fieldName: string,
min?: number,
max?: number
): void => {
if (min !== undefined && value < min) {
throw new Error(`${fieldName}은(는) ${min} 이상이어야 합니다.`);
}
if (max !== undefined && value > max) {
throw new Error(`${fieldName}은(는) ${max} 이하여야 합니다.`);
}
};
/**
*
*/
export const validateNonEmptyArray = (
array: any[],
fieldName: string
): void => {
if (!Array.isArray(array) || array.length === 0) {
throw new Error(`${fieldName}은(는) 비어있을 수 없습니다.`);
}
};
/**
*
*/
export const checkMissingFields = (
data: Record<string, any>,
requiredFields: string[]
): string[] => {
const missingFields: string[] = [];
for (const field of requiredFields) {
const value = data[field];
if (value === null || value === undefined || value === "") {
missingFields.push(field);
}
}
return missingFields;
};

View File

@ -0,0 +1,4 @@
회사 코드: COMPANY_2
생성일: 2025-09-11T02:07:40.033Z
폴더 구조: YYYY/MM/DD/파일명
관리자: 시스템 자동 생성

View File

@ -0,0 +1,4 @@
회사 코드: COMPANY_3
생성일: 2025-09-11T02:08:06.303Z
폴더 구조: YYYY/MM/DD/파일명
관리자: 시스템 자동 생성

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter, Download, Upload } from "lucide-react"; import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -14,7 +14,10 @@ import {
useComponentCategories, useComponentCategories,
useComponentStatistics, useComponentStatistics,
useDeleteComponent, useDeleteComponent,
useCreateComponent,
useUpdateComponent,
} from "@/hooks/admin/useComponents"; } from "@/hooks/admin/useComponents";
import { ComponentFormModal } from "@/components/admin/ComponentFormModal";
// 컴포넌트 카테고리 정의 // 컴포넌트 카테고리 정의
const COMPONENT_CATEGORIES = [ const COMPONENT_CATEGORIES = [
@ -32,6 +35,8 @@ export default function ComponentManagementPage() {
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedComponent, setSelectedComponent] = useState<any>(null); const [selectedComponent, setSelectedComponent] = useState<any>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showNewComponentModal, setShowNewComponentModal] = useState(false);
const [showEditComponentModal, setShowEditComponentModal] = useState(false);
// 컴포넌트 데이터 가져오기 // 컴포넌트 데이터 가져오기
const { const {
@ -51,8 +56,10 @@ export default function ComponentManagementPage() {
const { data: categories } = useComponentCategories(); const { data: categories } = useComponentCategories();
const { data: statistics } = useComponentStatistics(); const { data: statistics } = useComponentStatistics();
// 삭제 뮤테이션 // 뮤테이션
const deleteComponentMutation = useDeleteComponent(); const deleteComponentMutation = useDeleteComponent();
const createComponentMutation = useCreateComponent();
const updateComponentMutation = useUpdateComponent();
// 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태) // 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태)
const components = componentsData?.components || []; const components = componentsData?.components || [];
@ -88,6 +95,23 @@ export default function ComponentManagementPage() {
} }
}; };
// 컴포넌트 생성 처리
const handleCreate = async (data: any) => {
await createComponentMutation.mutateAsync(data);
setShowNewComponentModal(false);
};
// 컴포넌트 수정 처리
const handleUpdate = async (data: any) => {
if (!selectedComponent) return;
await updateComponentMutation.mutateAsync({
component_code: selectedComponent.component_code,
data,
});
setShowEditComponentModal(false);
setSelectedComponent(null);
};
if (loading) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -124,15 +148,7 @@ export default function ComponentManagementPage() {
<p className="text-sm text-gray-500"> </p> <p className="text-sm text-gray-500"> </p>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button variant="outline" size="sm"> <Button size="sm" onClick={() => setShowNewComponentModal(true)}>
<Upload className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
</Button>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
@ -279,7 +295,14 @@ export default function ComponentManagementPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Button variant="ghost" size="sm"> <Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedComponent(component);
setShowEditComponentModal(true);
}}
>
<Edit className="h-3 w-3" /> <Edit className="h-3 w-3" />
</Button> </Button>
<Button <Button
@ -313,6 +336,26 @@ export default function ComponentManagementPage() {
message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`} message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`}
confirmText="삭제" confirmText="삭제"
/> />
{/* 새 컴포넌트 추가 모달 */}
<ComponentFormModal
isOpen={showNewComponentModal}
onClose={() => setShowNewComponentModal(false)}
onSubmit={handleCreate}
mode="create"
/>
{/* 컴포넌트 편집 모달 */}
<ComponentFormModal
isOpen={showEditComponentModal}
onClose={() => {
setShowEditComponentModal(false);
setSelectedComponent(null);
}}
onSubmit={handleUpdate}
initialData={selectedComponent}
mode="edit"
/>
</div> </div>
); );
} }

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

@ -7,8 +7,11 @@ import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen"; import { ScreenDefinition, LayoutData } from "@/types/screen";
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic"; import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
export default function ScreenViewPage() { export default function ScreenViewPage() {
const params = useParams(); const params = useParams();
@ -21,6 +24,20 @@ export default function ScreenViewPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
useEffect(() => {
const initComponents = async () => {
try {
console.log("🚀 할당된 화면에서 컴포넌트 시스템 초기화 시작...");
await initializeComponents();
console.log("✅ 할당된 화면에서 컴포넌트 시스템 초기화 완료");
} catch (error) {
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
}
};
initComponents();
}, []);
useEffect(() => { useEffect(() => {
const loadScreen = async () => { const loadScreen = async () => {
try { try {
@ -93,12 +110,13 @@ export default function ScreenViewPage() {
{layout && layout.components.length > 0 ? ( {layout && layout.components.length > 0 ? (
// 캔버스 컴포넌트들을 정확한 해상도로 표시 // 캔버스 컴포넌트들을 정확한 해상도로 표시
<div <div
className="relative mx-auto bg-white" className="relative bg-white"
style={{ style={{
width: `${screenWidth}px`, width: `${screenWidth}px`,
height: `${screenHeight}px`, height: `${screenHeight}px`,
minWidth: `${screenWidth}px`, minWidth: `${screenWidth}px`,
minHeight: `${screenHeight}px`, minHeight: `${screenHeight}px`,
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
}} }}
> >
{layout.components {layout.components
@ -147,10 +165,19 @@ export default function ScreenViewPage() {
allComponents={layout.components} allComponents={layout.components}
formData={formData} formData={formData}
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ console.log("📝 폼 데이터 변경:", { fieldName, value });
...prev, setFormData((prev) => {
[fieldName]: value, const newFormData = {
})); ...prev,
[fieldName]: value,
};
console.log("📊 전체 폼 데이터:", newFormData);
return newFormData;
});
}}
screenInfo={{
id: screenId,
tableName: screen?.tableName,
}} }}
/> />
</div> </div>
@ -202,27 +229,58 @@ export default function ScreenViewPage() {
position: "absolute", position: "absolute",
left: `${component.position.x}px`, left: `${component.position.x}px`,
top: `${component.position.y}px`, top: `${component.position.y}px`,
width: component.style?.width || `${component.size.width}px`, width: `${component.size.width}px`,
height: component.style?.height || `${component.size.height}px`, height: `${component.size.height}px`,
zIndex: component.position.z || 1, zIndex: component.position.z || 1,
}} }}
onMouseEnter={() => {
console.log("🎯 할당된 화면 컴포넌트:", {
id: component.id,
type: component.type,
position: component.position,
size: component.size,
styleWidth: component.style?.width,
styleHeight: component.style?.height,
finalWidth: `${component.size.width}px`,
finalHeight: `${component.size.height}px`,
});
}}
> >
<InteractiveScreenViewer {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
component={component} {component.type !== "widget" ? (
allComponents={layout.components} <DynamicComponentRenderer
formData={formData} component={component}
onFormDataChange={(fieldName, value) => { isInteractive={true}
setFormData((prev) => ({ formData={formData}
...prev, onFormDataChange={(fieldName, value) => {
[fieldName]: value, setFormData((prev) => ({
})); ...prev,
}} [fieldName]: value,
hideLabel={true} // 라벨 숨김 플래그 전달 }));
screenInfo={{ }}
id: screenId, screenId={screenId}
tableName: screen?.tableName, tableName={screen?.tableName}
}} onRefresh={() => {
/> console.log("화면 새로고침 요청");
}}
onClose={() => {
console.log("화면 닫기 요청");
}}
/>
) : (
<DynamicWebTypeRenderer
webType={component.webType || "text"}
config={component.webTypeConfig}
isInteractive={true}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
/>
)}
</div> </div>
</div> </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

@ -3,6 +3,8 @@ import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { QueryProvider } from "@/providers/QueryProvider"; import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider"; import { RegistryProvider } from "./registry-provider";
import { Toaster } from "sonner";
import ScreenModal from "@/components/common/ScreenModal";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -44,6 +46,8 @@ export default function RootLayout({
<QueryProvider> <QueryProvider>
<RegistryProvider>{children}</RegistryProvider> <RegistryProvider>{children}</RegistryProvider>
</QueryProvider> </QueryProvider>
<Toaster position="top-right" richColors />
<ScreenModal />
</div> </div>
</body> </body>
</html> </html>

View File

@ -0,0 +1,565 @@
"use client";
import React, { useState, useEffect } 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 { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, Save, RotateCcw, AlertTriangle, CheckCircle } from "lucide-react";
import { toast } from "sonner";
import { useComponentDuplicateCheck } from "@/hooks/admin/useComponentDuplicateCheck";
import { Alert, AlertDescription } from "@/components/ui/alert";
// 컴포넌트 카테고리 정의
const COMPONENT_CATEGORIES = [
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
{ id: "action", name: "액션", description: "사용자 액션을 처리하는 컴포넌트" },
{ id: "display", name: "표시", description: "정보를 표시하는 컴포넌트" },
{ id: "layout", name: "레이아웃", description: "레이아웃을 구성하는 컴포넌트" },
{ id: "other", name: "기타", description: "기타 컴포넌트" },
];
// 컴포넌트 타입 정의
const COMPONENT_TYPES = [
{ id: "widget", name: "위젯", description: "입력 양식 위젯" },
{ id: "button", name: "버튼", description: "액션 버튼" },
{ id: "card", name: "카드", description: "카드 컨테이너" },
{ id: "container", name: "컨테이너", description: "일반 컨테이너" },
{ id: "dashboard", name: "대시보드", description: "대시보드 그리드" },
{ id: "alert", name: "알림", description: "알림 메시지" },
{ id: "badge", name: "배지", description: "상태 배지" },
{ id: "progress", name: "진행률", description: "진행률 표시" },
{ id: "chart", name: "차트", description: "데이터 차트" },
];
// 웹타입 정의 (위젯인 경우만)
const WEB_TYPES = [
"text",
"number",
"decimal",
"date",
"datetime",
"select",
"dropdown",
"textarea",
"boolean",
"checkbox",
"radio",
"code",
"entity",
"file",
"email",
"tel",
"color",
"range",
"time",
"week",
"month",
];
interface ComponentFormData {
component_code: string;
component_name: string;
description: string;
category: string;
component_config: {
type: string;
webType?: string;
config_panel?: string;
};
default_size: {
width: number;
height: number;
};
icon_name: string;
active: string;
sort_order: number;
}
interface ComponentFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: ComponentFormData) => Promise<void>;
initialData?: any;
mode?: "create" | "edit";
}
export const ComponentFormModal: React.FC<ComponentFormModalProps> = ({
isOpen,
onClose,
onSubmit,
initialData,
mode = "create",
}) => {
const [formData, setFormData] = useState<ComponentFormData>({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [shouldCheckDuplicate, setShouldCheckDuplicate] = useState(false);
// 중복 체크 쿼리 (생성 모드에서만 활성화)
const duplicateCheck = useComponentDuplicateCheck(
formData.component_code,
mode === "create" && shouldCheckDuplicate && formData.component_code.length > 0,
);
// 초기 데이터 설정
useEffect(() => {
if (isOpen) {
if (mode === "edit" && initialData) {
setFormData({
component_code: initialData.component_code || "",
component_name: initialData.component_name || "",
description: initialData.description || "",
category: initialData.category || "other",
component_config: initialData.component_config || { type: "widget" },
default_size: initialData.default_size || { width: 200, height: 40 },
icon_name: initialData.icon_name || "",
is_active: initialData.is_active || "Y",
sort_order: initialData.sort_order || 100,
});
} else {
// 새 컴포넌트 생성 시 초기값
setFormData({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
}
}
}, [isOpen, mode, initialData]);
// 컴포넌트 코드 자동 생성
const generateComponentCode = (name: string, type: string) => {
if (!name) return "";
// 한글을 영문으로 매핑
const koreanToEnglish: { [key: string]: string } = {
: "help",
: "tooltip",
: "guide",
: "alert",
: "button",
: "card",
: "dashboard",
: "panel",
: "input",
: "text",
: "select",
: "check",
: "radio",
: "file",
: "image",
: "table",
: "list",
: "form",
};
// 한글을 영문으로 변환
let englishName = name;
Object.entries(koreanToEnglish).forEach(([korean, english]) => {
englishName = englishName.replace(new RegExp(korean, "g"), english);
});
const cleanName = englishName
.toLowerCase()
.replace(/[^a-z0-9\s]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// 빈 문자열이거나 숫자로 시작하는 경우 기본값 설정
const finalName = cleanName || "component";
const validName = /^[0-9]/.test(finalName) ? `comp-${finalName}` : finalName;
return type === "widget" ? validName : `${validName}-${type}`;
};
// 폼 필드 변경 처리
const handleChange = (field: string, value: any) => {
setFormData((prev) => {
const newData = { ...prev };
if (field.includes(".")) {
const [parent, child] = field.split(".");
newData[parent as keyof ComponentFormData] = {
...(newData[parent as keyof ComponentFormData] as any),
[child]: value,
};
} else {
(newData as any)[field] = value;
}
// 컴포넌트 이름이 변경되면 코드 자동 생성
if (field === "component_name" || field === "component_config.type") {
const name = field === "component_name" ? value : newData.component_name;
const type = field === "component_config.type" ? value : newData.component_config.type;
if (name && mode === "create") {
newData.component_code = generateComponentCode(name, type);
// 자동 생성된 코드에 대해서도 중복 체크 활성화
setShouldCheckDuplicate(true);
}
}
// 컴포넌트 코드가 직접 변경되면 중복 체크 활성화
if (field === "component_code" && mode === "create") {
setShouldCheckDuplicate(true);
}
return newData;
});
};
// 폼 제출
const handleSubmit = async () => {
// 유효성 검사
if (!formData.component_code || !formData.component_name) {
toast.error("컴포넌트 코드와 이름은 필수입니다.");
return;
}
if (!formData.component_config.type) {
toast.error("컴포넌트 타입을 선택해주세요.");
return;
}
// 생성 모드에서 중복 체크
if (mode === "create" && duplicateCheck.data?.isDuplicate) {
toast.error("이미 사용 중인 컴포넌트 코드입니다. 다른 코드를 사용해주세요.");
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
toast.success(mode === "create" ? "컴포넌트가 생성되었습니다." : "컴포넌트가 수정되었습니다.");
onClose();
} catch (error) {
toast.error(mode === "create" ? "컴포넌트 생성에 실패했습니다." : "컴포넌트 수정에 실패했습니다.");
} finally {
setIsSubmitting(false);
}
};
// 폼 초기화
const handleReset = () => {
if (mode === "edit" && initialData) {
setFormData({
component_code: initialData.component_code || "",
component_name: initialData.component_name || "",
description: initialData.description || "",
category: initialData.category || "other",
component_config: initialData.component_config || { type: "widget" },
default_size: initialData.default_size || { width: 200, height: 40 },
icon_name: initialData.icon_name || "",
is_active: initialData.is_active || "Y",
sort_order: initialData.sort_order || 100,
});
} else {
setFormData({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{mode === "create" ? "새 컴포넌트 추가" : "컴포넌트 편집"}</DialogTitle>
<DialogDescription>
{mode === "create"
? "화면 설계에 사용할 새로운 컴포넌트를 추가합니다."
: "선택한 컴포넌트의 정보를 수정합니다."}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="component_name"> *</Label>
<Input
id="component_name"
value={formData.component_name}
onChange={(e) => handleChange("component_name", e.target.value)}
placeholder="예: 정보 알림"
/>
</div>
<div>
<Label htmlFor="component_code"> *</Label>
<div className="relative">
<Input
id="component_code"
value={formData.component_code}
onChange={(e) => handleChange("component_code", e.target.value)}
placeholder="예: alert-info"
disabled={mode === "edit"}
className={
mode === "create" && duplicateCheck.data?.isDuplicate
? "border-red-500 pr-10"
: mode === "create" && duplicateCheck.data && !duplicateCheck.data.isDuplicate
? "border-green-500 pr-10"
: ""
}
/>
{mode === "create" && formData.component_code && duplicateCheck.data && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
{duplicateCheck.data.isDuplicate ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</div>
)}
</div>
{mode === "create" && formData.component_code && duplicateCheck.data && (
<Alert
className={`mt-2 ${duplicateCheck.data.isDuplicate ? "border-red-200 bg-red-50" : "border-green-200 bg-green-50"}`}
>
<AlertDescription className={duplicateCheck.data.isDuplicate ? "text-red-700" : "text-green-700"}>
{duplicateCheck.data.isDuplicate
? "⚠️ 이미 사용 중인 컴포넌트 코드입니다."
: "✅ 사용 가능한 컴포넌트 코드입니다."}
</AlertDescription>
</Alert>
)}
</div>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="컴포넌트에 대한 설명을 입력하세요"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Select value={formData.category} onValueChange={(value) => handleChange("category", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPONENT_CATEGORIES.map((category) => (
<SelectItem key={category.id} value={category.id}>
<div>
<div className="font-medium">{category.name}</div>
<div className="text-xs text-gray-500">{category.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="icon_name"> </Label>
<Input
id="icon_name"
value={formData.icon_name}
onChange={(e) => handleChange("icon_name", e.target.value)}
placeholder="예: info, alert-triangle"
/>
</div>
</div>
</CardContent>
</Card>
{/* 컴포넌트 설정 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Select
value={formData.component_config.type}
onValueChange={(value) => handleChange("component_config.type", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPONENT_TYPES.map((type) => (
<SelectItem key={type.id} value={type.id}>
<div>
<div className="font-medium">{type.name}</div>
<div className="text-xs text-gray-500">{type.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 위젯인 경우 웹타입 선택 */}
{formData.component_config.type === "widget" && (
<div>
<Label></Label>
<Select
value={formData.component_config.webType || ""}
onValueChange={(value) => handleChange("component_config.webType", value)}
>
<SelectTrigger>
<SelectValue placeholder="웹타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{WEB_TYPES.map((webType) => (
<SelectItem key={webType} value={webType}>
{webType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="config_panel"> </Label>
<Input
id="config_panel"
value={formData.component_config.config_panel || ""}
onChange={(e) => handleChange("component_config.config_panel", e.target.value)}
placeholder="예: AlertConfigPanel"
/>
</div>
</CardContent>
</Card>
{/* 기본 크기 및 기타 설정 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="width"> (px)</Label>
<Input
id="width"
type="number"
value={formData.default_size.width}
onChange={(e) => handleChange("default_size.width", parseInt(e.target.value))}
min="1"
/>
</div>
<div>
<Label htmlFor="height"> (px)</Label>
<Input
id="height"
type="number"
value={formData.default_size.height}
onChange={(e) => handleChange("default_size.height", parseInt(e.target.value))}
min="1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order}
onChange={(e) => handleChange("sort_order", parseInt(e.target.value))}
min="0"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleChange("is_active", checked ? "Y" : "N")}
/>
<Label htmlFor="active"></Label>
</div>
</div>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleReset} disabled={isSubmitting}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || (mode === "create" && duplicateCheck.data?.isDuplicate)}
>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? "저장 중..." : mode === "create" ? "생성" : "수정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

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

@ -0,0 +1,224 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
interface ScreenModalState {
isOpen: boolean;
screenId: number | null;
title: string;
size: "sm" | "md" | "lg" | "xl";
}
interface ScreenModalProps {
className?: string;
}
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false,
screenId: null,
title: "",
size: "md",
});
const [screenData, setScreenData] = useState<{
components: ComponentData[];
screenInfo: any;
} | null>(null);
const [loading, setLoading] = useState(false);
const [screenDimensions, setScreenDimensions] = useState<{
width: number;
height: number;
} | null>(null);
// 화면의 실제 크기 계산 함수
const calculateScreenDimensions = (components: ComponentData[]) => {
let maxWidth = 800; // 최소 너비
let maxHeight = 600; // 최소 높이
components.forEach((component) => {
const x = parseFloat(component.style?.positionX || "0");
const y = parseFloat(component.style?.positionY || "0");
const width = parseFloat(component.style?.width || "100");
const height = parseFloat(component.style?.height || "40");
// 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산
const rightEdge = x + width;
const bottomEdge = y + height;
maxWidth = Math.max(maxWidth, rightEdge + 50); // 여백 추가
maxHeight = Math.max(maxHeight, bottomEdge + 50); // 여백 추가
});
return {
width: Math.min(maxWidth, window.innerWidth * 0.9), // 화면의 90%를 넘지 않도록
height: Math.min(maxHeight, window.innerHeight * 0.8), // 화면의 80%를 넘지 않도록
};
};
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, size } = event.detail;
setModalState({
isOpen: true,
screenId,
title,
size,
});
};
window.addEventListener("openScreenModal", handleOpenModal as EventListener);
return () => {
window.removeEventListener("openScreenModal", handleOpenModal as EventListener);
};
}, []);
// 화면 데이터 로딩
useEffect(() => {
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
}
}, [modalState.isOpen, modalState.screenId]);
const loadScreenData = async (screenId: number) => {
try {
setLoading(true);
console.log("화면 데이터 로딩 시작:", screenId);
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
console.log("API 응답:", { screenInfo, layoutData });
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
if (screenInfo && layoutData) {
const components = layoutData.components || [];
// 화면의 실제 크기 계산
const dimensions = calculateScreenDimensions(components);
setScreenDimensions(dimensions);
setScreenData({
components,
screenInfo: screenInfo,
});
console.log("화면 데이터 설정 완료:", {
componentsCount: components.length,
dimensions,
screenInfo,
});
} else {
throw new Error("화면 데이터가 없습니다");
}
} catch (error) {
console.error("화면 데이터 로딩 오류:", error);
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
handleClose();
} finally {
setLoading(false);
}
};
const handleClose = () => {
setModalState({
isOpen: false,
screenId: null,
title: "",
size: "md",
});
setScreenData(null);
};
// 모달 크기 설정 - 화면 내용에 맞게 동적 조정
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[80vh] overflow-hidden",
style: {}
};
}
// 헤더 높이와 패딩을 고려한 전체 높이 계산
const headerHeight = 60; // DialogHeader + 패딩
const totalHeight = screenDimensions.height + headerHeight;
return {
className: "overflow-hidden p-0",
style: {
width: `${screenDimensions.width + 48}px`, // 헤더 패딩과 여백 고려
height: `${Math.min(totalHeight, window.innerHeight * 0.8)}px`,
maxWidth: '90vw',
maxHeight: '80vh'
}
};
};
const modalStyle = getModalStyle();
return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent
className={`${modalStyle.className} ${className || ""}`}
style={modalStyle.style}
>
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>{modalState.title}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden p-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600"> ...</p>
</div>
</div>
) : screenData ? (
<div
className="relative bg-white overflow-hidden"
style={{
width: (screenDimensions?.width || 800),
height: (screenDimensions?.height || 600),
}}
>
{screenData.components.map((component) => (
<InteractiveScreenViewerDynamic
key={component.id}
component={component}
allComponents={screenData.components}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
/>
))}
</div>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-gray-600"> .</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default ScreenModal;

View File

@ -17,6 +17,7 @@ import {
Cog, Cog,
Layout, Layout,
Monitor, Monitor,
Square,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -33,6 +34,8 @@ interface DesignerToolbarProps {
canUndo: boolean; canUndo: boolean;
canRedo: boolean; canRedo: boolean;
isSaving?: boolean; isSaving?: boolean;
showZoneBorders?: boolean;
onToggleZoneBorders?: () => void;
} }
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
@ -48,6 +51,8 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
canUndo, canUndo,
canRedo, canRedo,
isSaving = false, isSaving = false,
showZoneBorders = true,
onToggleZoneBorders,
}) => { }) => {
return ( return (
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm"> <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> </Badge>
</Button> </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 <Button
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"} variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
size="sm" size="sm"

View File

@ -10,6 +10,13 @@ import { toast } from "sonner";
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen"; import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable"; import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry"; import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
import "@/lib/registry/components/CardRenderer";
import "@/lib/registry/components/DashboardRenderer";
import "@/lib/registry/components/WidgetRenderer";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
@ -152,9 +159,40 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return renderFileComponent(comp as FileComponent); return renderFileComponent(comp as FileComponent);
} }
// 위젯 컴포넌트가 아닌 경우 // 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용
if (comp.type !== "widget") { if (comp.type !== "widget") {
return <div className="text-sm text-gray-500"> </div>; console.log("🎯 InteractiveScreenViewer - DynamicComponentRenderer 사용:", {
componentId: comp.id,
componentType: comp.type,
componentConfig: comp.componentConfig,
style: comp.style,
size: comp.size,
position: comp.position,
});
return (
<DynamicComponentRenderer
component={comp}
isInteractive={true}
formData={formData}
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
onRefresh={() => {
// 화면 새로고침 로직 (필요시 구현)
console.log("화면 새로고침 요청");
}}
onClose={() => {
// 화면 닫기 로직 (필요시 구현)
console.log("화면 닫기 요청");
}}
style={{
width: "100%",
height: "100%",
...comp.style,
}}
/>
);
} }
const widget = comp as WidgetComponent; const widget = comp as WidgetComponent;
@ -492,5 +530,3 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
export { InteractiveScreenViewerDynamic as InteractiveScreenViewer }; export { InteractiveScreenViewerDynamic as InteractiveScreenViewer };
InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic"; InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic";

View File

@ -1,17 +1,8 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen"; import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { Input } from "@/components/ui/input"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth";
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
import { import {
Database, Database,
Type, Type,
@ -24,210 +15,86 @@ import {
Code, Code,
Building, Building,
File, File,
Group,
ChevronDown,
ChevronRight,
Search,
RotateCcw,
Plus,
Edit,
Trash2,
Upload,
Square,
CreditCard,
Layout,
Grid3x3,
Columns,
Rows,
SidebarOpen,
Folder,
ChevronUp,
} from "lucide-react"; } from "lucide-react";
// 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components";
interface RealtimePreviewProps { interface RealtimePreviewProps {
component: ComponentData; component: ComponentData;
isSelected?: boolean; isSelected?: boolean;
isDesignMode?: boolean; // 편집 모드 여부
onClick?: (e?: React.MouseEvent) => void; onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void; onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void; onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기 onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
selectedScreen?: any;
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
} }
// 영역 레이아웃에 따른 아이콘 반환 // 동적 위젯 타입 아이콘 (레지스트리에서 조회)
const getAreaIcon = (layoutType: AreaLayoutType) => { const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
switch (layoutType) { if (!widgetType) return <Type className="h-3 w-3" />;
case "flex":
return <Layout className="h-4 w-4 text-blue-600" />;
case "grid":
return <Grid3x3 className="h-4 w-4 text-green-600" />;
case "columns":
return <Columns className="h-4 w-4 text-purple-600" />;
case "rows":
return <Rows className="h-4 w-4 text-orange-600" />;
case "sidebar":
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
case "tabs":
return <Folder className="h-4 w-4 text-pink-600" />;
default:
return <Square className="h-4 w-4 text-gray-500" />;
}
};
// 영역 렌더링 const iconMap: Record<string, React.ReactNode> = {
const renderArea = (component: ComponentData, children?: React.ReactNode) => { text: <span className="text-xs">Aa</span>,
const area = component as AreaComponent; number: <Hash className="h-3 w-3" />,
const { areaType, label } = area; decimal: <Hash className="h-3 w-3" />,
date: <Calendar className="h-3 w-3" />,
const renderPlaceholder = () => ( datetime: <Calendar className="h-3 w-3" />,
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50"> select: <List className="h-3 w-3" />,
<div className="text-center"> dropdown: <List className="h-3 w-3" />,
{getAreaIcon(areaType)} textarea: <AlignLeft className="h-3 w-3" />,
<p className="mt-2 text-sm text-gray-600">{label || `${areaType} 영역`}</p> boolean: <CheckSquare className="h-3 w-3" />,
<p className="text-xs text-gray-400"> </p> checkbox: <CheckSquare className="h-3 w-3" />,
</div> radio: <Radio className="h-3 w-3" />,
</div> code: <Code className="h-3 w-3" />,
); entity: <Building className="h-3 w-3" />,
file: <File className="h-3 w-3" />,
return ( email: <span className="text-xs">@</span>,
<div className="relative h-full w-full"> tel: <span className="text-xs"></span>,
<div className="absolute inset-0 h-full w-full"> button: <span className="text-xs">BTN</span>,
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
</div>
</div>
);
};
// 동적 웹 타입 위젯 렌더링
const renderWidget = (component: ComponentData) => {
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (component.type !== "widget") {
return <div className="text-xs text-gray-500"> </div>;
}
const widget = component as WidgetComponent;
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
// 디버깅: 실제 widgetType 값 확인
console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
const borderClass = hasCustomBorder ? "!border-0" : "";
const commonProps = {
placeholder: placeholder || "입력하세요...",
disabled: readonly,
required: required,
className: `w-full h-full ${borderClass}`,
}; };
// 동적 웹타입 렌더링 사용 return iconMap[widgetType] || <Type className="h-3 w-3" />;
if (widgetType) {
try {
return (
<DynamicWebTypeRenderer
webType={widgetType}
props={{
...commonProps,
component: widget,
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
}}
config={widget.webTypeConfig}
/>
);
} catch (error) {
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
// 오류 발생 시 폴백으로 기본 input 렌더링
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
}
}
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
return <Input type="text" {...commonProps} />;
};
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
const getWidgetIcon = (widgetType: WebType | undefined) => {
if (!widgetType) {
return <Type className="h-4 w-4 text-gray-500" />;
}
// 레지스트리에서 웹타입 정의 조회
const webTypeDefinition = WebTypeRegistry.getWebType(widgetType);
if (webTypeDefinition && webTypeDefinition.icon) {
const IconComponent = webTypeDefinition.icon;
return <IconComponent className="h-4 w-4" />;
}
// 기본 아이콘 매핑 (하위 호환성)
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="h-4 w-4 text-blue-600" />;
case "number":
case "decimal":
return <Hash className="h-4 w-4 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-4 w-4 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-4 w-4 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="h-4 w-4 text-blue-600" />;
case "radio":
return <Radio className="h-4 w-4 text-blue-600" />;
case "code":
return <Code className="h-4 w-4 text-gray-600" />;
case "entity":
return <Building className="h-4 w-4 text-cyan-600" />;
case "file":
return <File className="h-4 w-4 text-yellow-600" />;
default:
return <Type className="h-4 w-4 text-gray-500" />;
}
}; };
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
component, component,
isSelected = false, isSelected = false,
isDesignMode = true, // 기본값은 편집 모드
onClick, onClick,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
onGroupToggle, onGroupToggle,
children, children,
selectedScreen,
onZoneComponentDrop,
onZoneClick,
}) => { }) => {
const { user } = useAuth(); const { id, type, position, size, style: componentStyle } = component;
const { type, id, position, size, style = {} } = component;
// 컴포넌트 스타일 계산 // 선택 상태에 따른 스타일
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
top: position?.y || 0,
width: size?.width || 200,
height: size?.height || 40,
zIndex: position?.z || 1,
...style,
};
// 선택된 컴포넌트 스타일
const selectionStyle = isSelected const selectionStyle = isSelected
? { ? {
outline: "2px solid #3b82f6", outline: "2px solid #3b82f6",
outlineOffset: "2px", outlineOffset: "2px",
zIndex: 1000,
} }
: {}; : {};
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size?.width || 100}px`,
height: `${size?.height || 36}px`,
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
...componentStyle,
};
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onClick?.(e); onClick?.(e);
@ -246,166 +113,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
<div <div
id={`component-${id}`} id={`component-${id}`}
className="absolute cursor-pointer" className="absolute cursor-pointer"
style={{ ...componentStyle, ...selectionStyle }} style={{ ...baseStyle, ...selectionStyle }}
onClick={handleClick} onClick={handleClick}
draggable draggable
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* 컴포넌트 타입별 렌더링 */} {/* 동적 컴포넌트 렌더링 */}
<div className="h-full w-full"> <div className="h-full w-full">
{/* 영역 타입 */} <DynamicComponentRenderer
{type === "area" && renderArea(component, children)} component={component}
isSelected={isSelected}
{/* 데이터 테이블 타입 */} isDesignMode={isDesignMode}
{type === "datatable" && isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
(() => { onClick={onClick}
const dataTableComponent = component as any; // DataTableComponent 타입 onDragStart={onDragStart}
onDragEnd={onDragEnd}
// 메모이제이션을 위한 계산 최적화 children={children}
const visibleColumns = React.useMemo( selectedScreen={selectedScreen}
() => dataTableComponent.columns?.filter((col: any) => col.visible) || [], onZoneComponentDrop={onZoneComponentDrop}
[dataTableComponent.columns], onZoneClick={onZoneClick}
); />
const filters = React.useMemo(() => dataTableComponent.filters || [], [dataTableComponent.filters]);
return (
<div className="flex h-full w-full flex-col overflow-hidden rounded border bg-white">
{/* 테이블 제목 */}
{dataTableComponent.title && (
<div className="border-b bg-gray-50 px-4 py-2">
<h3 className="text-sm font-medium">{dataTableComponent.title}</h3>
</div>
)}
{/* 검색 및 필터 영역 */}
{(dataTableComponent.showSearchButton || filters.length > 0) && (
<div className="border-b bg-gray-50 px-4 py-2">
<div className="flex items-center space-x-2">
{dataTableComponent.showSearchButton && (
<div className="flex items-center space-x-2">
<Input placeholder="검색..." className="h-8 w-48" />
<Button size="sm" variant="outline">
{dataTableComponent.searchButtonText || "검색"}
</Button>
</div>
)}
{filters.length > 0 && (
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">:</span>
{filters.slice(0, 2).map((filter: any, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{filter.label || filter.columnName}
</Badge>
))}
{filters.length > 2 && (
<Badge variant="secondary" className="text-xs">
+{filters.length - 2}
</Badge>
)}
</div>
)}
</div>
</div>
)}
{/* 테이블 본체 */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{visibleColumns.length > 0 ? (
visibleColumns.map((col: any, index: number) => (
<TableHead key={col.id || index} className="text-xs">
{col.label || col.columnName}
{col.sortable && <span className="ml-1 text-gray-400"></span>}
</TableHead>
))
) : (
<>
<TableHead className="text-xs"> 1</TableHead>
<TableHead className="text-xs"> 2</TableHead>
<TableHead className="text-xs"> 3</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{/* 샘플 데이터 행들 */}
{[1, 2, 3].map((rowIndex) => (
<TableRow key={rowIndex}>
{visibleColumns.length > 0 ? (
visibleColumns.map((col: any, colIndex: number) => (
<TableCell key={col.id || colIndex} className="text-xs">
{col.widgetType === "checkbox" ? (
<input type="checkbox" className="h-3 w-3" />
) : col.widgetType === "select" ? (
`옵션 ${rowIndex}`
) : col.widgetType === "date" ? (
"2024-01-01"
) : col.widgetType === "number" ? (
`${rowIndex * 100}`
) : (
`데이터 ${rowIndex}-${colIndex + 1}`
)}
</TableCell>
))
) : (
<>
<TableCell className="text-xs"> {rowIndex}-1</TableCell>
<TableCell className="text-xs"> {rowIndex}-2</TableCell>
<TableCell className="text-xs"> {rowIndex}-3</TableCell>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{dataTableComponent.pagination && (
<div className="border-t bg-gray-50 px-4 py-2">
<div className="flex items-center justify-between text-xs text-gray-600">
<span> 3 </span>
<div className="flex items-center space-x-2">
<Button size="sm" variant="outline" disabled>
</Button>
<span>1 / 1</span>
<Button size="sm" variant="outline" disabled>
</Button>
</div>
</div>
</div>
)}
</div>
);
})()}
{/* 그룹 타입 */}
{type === "group" && (
<div className="relative h-full w-full">
<div className="absolute inset-0">{children}</div>
</div>
)}
{/* 위젯 타입 - 동적 렌더링 */}
{type === "widget" && (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
</div>
)}
{/* 파일 타입 */}
{type === "file" && (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">
<FileUpload disabled placeholder="파일 업로드 미리보기" />
</div>
</div>
)}
</div> </div>
{/* 선택된 컴포넌트 정보 표시 */} {/* 선택된 컴포넌트 정보 표시 */}
@ -417,7 +145,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{(component as WidgetComponent).widgetType || "widget"} {(component as WidgetComponent).widgetType || "widget"}
</div> </div>
)} )}
{type !== "widget" && type} {type !== "widget" && (
<div className="flex items-center gap-1">
<span>{component.componentConfig?.type || type}</span>
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -426,5 +158,4 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 기존 RealtimePreview와의 호환성을 위한 export // 기존 RealtimePreview와의 호환성을 위한 export
export { RealtimePreviewDynamic as RealtimePreview }; export { RealtimePreviewDynamic as RealtimePreview };
export default RealtimePreviewDynamic;
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";

View File

@ -15,6 +15,7 @@ import {
SCREEN_RESOLUTIONS, SCREEN_RESOLUTIONS,
} from "@/types/screen"; } from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId"; import { generateComponentId } from "@/lib/utils/generateId";
import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping";
import { import {
createGroupComponent, createGroupComponent,
calculateBoundingBox, calculateBoundingBox,
@ -38,20 +39,30 @@ import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { toast } from "sonner"; import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { initializeComponents } from "@/lib/registry/components";
import StyleEditor from "./StyleEditor"; import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview"; import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel"; import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar"; import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel"; import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import ComponentsPanel from "./panels/ComponentsPanel"; import ComponentsPanel from "./panels/ComponentsPanel";
import LayoutsPanel from "./panels/LayoutsPanel";
import PropertiesPanel from "./panels/PropertiesPanel"; import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel"; import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel"; import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
// 레이아웃 초기화
import "@/lib/registry/layouts";
// 컴포넌트 초기화 (새 시스템)
import "@/lib/registry/components";
// 성능 최적화 도구 초기화 (필요시 사용)
import "@/lib/registry/utils/performanceOptimizer";
interface ScreenDesignerProps { interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null; selectedScreen: ScreenDefinition | null;
onBackToList: () => void; onBackToList: () => void;
@ -75,6 +86,14 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "m", // template의 m shortcutKey: "m", // template의 m
}, },
{
id: "layouts",
title: "레이아웃",
defaultPosition: "left",
defaultWidth: 380,
defaultHeight: 700,
shortcutKey: "l", // layout의 l
},
{ {
id: "properties", id: "properties",
title: "속성 편집", title: "속성 편집",
@ -294,9 +313,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
const targetComponent = layout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout";
// 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동
let positionDelta = { x: 0, y: 0 };
if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) {
const oldPosition = targetComponent.position;
let newPosition = { ...oldPosition };
if (path === "position.x") {
newPosition.x = value;
positionDelta.x = value - oldPosition.x;
} else if (path === "position.y") {
newPosition.y = value;
positionDelta.y = value - oldPosition.y;
} else if (path === "position") {
newPosition = value;
positionDelta.x = value.x - oldPosition.x;
positionDelta.y = value.y - oldPosition.y;
}
console.log("📐 레이아웃 이동 감지:", {
layoutId: componentId,
oldPosition,
newPosition,
positionDelta,
});
}
const pathParts = path.split("."); const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => { const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp; if (comp.id !== componentId) {
// 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동
if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) {
// 이 레이아웃의 존에 속한 컴포넌트인지 확인
const isInLayoutZone = comp.parentId === componentId && comp.zoneId;
if (isInLayoutZone) {
console.log("🔄 존 컴포넌트 함께 이동:", {
componentId: comp.id,
zoneId: comp.zoneId,
oldPosition: comp.position,
delta: positionDelta,
});
return {
...comp,
position: {
...comp.position,
x: comp.position.x + positionDelta.x,
y: comp.position.y + positionDelta.y,
},
};
}
}
return comp;
}
const newComp = { ...comp }; const newComp = { ...comp };
let current: any = newComp; let current: any = newComp;
@ -559,6 +631,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
[layout, gridInfo, saveToHistory], [layout, gridInfo, saveToHistory],
); );
// 컴포넌트 시스템 초기화
useEffect(() => {
const initComponents = async () => {
try {
console.log("🚀 컴포넌트 시스템 초기화 시작...");
await initializeComponents();
console.log("✅ 컴포넌트 시스템 초기화 완료");
} catch (error) {
console.error("❌ 컴포넌트 시스템 초기화 실패:", error);
}
};
initComponents();
}, []);
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회) // 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
useEffect(() => { useEffect(() => {
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) { if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
@ -1212,9 +1299,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel], [layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
); );
// 컴포넌트 드래그 처리 // 레이아웃 드래그 처리
const handleComponentDrop = useCallback( const handleLayoutDrop = useCallback(
(e: React.DragEvent, component: any) => { (e: React.DragEvent, layoutData: any) => {
const rect = canvasRef.current?.getBoundingClientRect(); const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
@ -1237,71 +1324,212 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: dropX, y: dropY, z: 1 }; : { x: dropX, y: dropY, z: 1 };
console.log("🧩 컴포넌트 드롭:", { console.log("🏗️ 레이아웃 드롭:", {
componentName: component.name, layoutType: layoutData.layoutType,
webType: component.webType, zonesCount: layoutData.zones.length,
dropPosition: { x: dropX, y: dropY }, dropPosition: { x: dropX, y: dropY },
snappedPosition, snappedPosition,
}); });
// 웹타입별 기본 설정 생성 // 레이아웃 컴포넌트 생성
const getDefaultWebTypeConfig = (webType: string) => { const newLayoutComponent: ComponentData = {
switch (webType) { id: layoutData.id,
case "button": type: "layout",
return { layoutType: layoutData.layoutType,
actionType: "custom", layoutConfig: layoutData.layoutConfig,
variant: "default", zones: layoutData.zones.map((zone: any) => ({
confirmationMessage: "", ...zone,
popupTitle: "", id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가
popupContent: "", })),
navigateUrl: "", children: [],
}; position: snappedPosition,
case "date": size: layoutData.size,
return { label: layoutData.label,
format: "YYYY-MM-DD", allowedComponentTypes: layoutData.allowedComponentTypes,
showTime: false, dropZoneConfig: layoutData.dropZoneConfig,
placeholder: "날짜를 선택하세요", } as ComponentData;
};
case "number": // 레이아웃에 새 컴포넌트 추가
return { const newLayout = {
format: "integer", ...layout,
placeholder: "숫자를 입력하세요", components: [...layout.components, newLayoutComponent],
};
case "select":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "file":
return {
accept: ["*/*"],
maxSize: 10485760, // 10MB
multiple: false,
showPreview: true,
autoUpload: false,
};
default:
return {};
}
}; };
// 새 컴포넌트 생성 setLayout(newLayout);
saveToHistory(newLayout);
// 레이아웃 컴포넌트 선택
setSelectedComponent(newLayoutComponent);
openPanel("properties");
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
);
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
// 존 클릭 핸들러
const handleZoneClick = useCallback((zoneId: string) => {
console.log("🎯 존 클릭:", zoneId);
// 필요시 존 선택 로직 추가
}, []);
// 웹타입별 기본 설정 생성 함수를 상위로 이동
const getDefaultWebTypeConfig = useCallback((webType: string) => {
switch (webType) {
case "button":
return {
actionType: "custom",
variant: "default",
confirmationMessage: "",
popupTitle: "",
popupContent: "",
navigateUrl: "",
};
case "date":
return {
format: "YYYY-MM-DD",
showTime: false,
placeholder: "날짜를 선택하세요",
};
case "number":
return {
format: "integer",
placeholder: "숫자를 입력하세요",
};
case "select":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "file":
return {
accept: ["*/*"],
maxSize: 10485760, // 10MB
multiple: false,
showPreview: true,
autoUpload: false,
};
default:
return {};
}
}, []);
// 컴포넌트 드래그 처리 (캔버스 레벨 드롭)
const handleComponentDrop = useCallback(
(e: React.DragEvent, component?: any, zoneId?: string, layoutId?: string) => {
// 존별 드롭인 경우 dragData에서 컴포넌트 정보 추출
if (!component) {
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
try {
const parsedData = JSON.parse(dragData);
if (parsedData.type === "component") {
component = parsedData.component;
} else {
return;
}
} catch (error) {
console.error("드래그 데이터 파싱 오류:", error);
return;
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
// 컴포넌트 크기 정보
const componentWidth = component.defaultSize?.width || 120;
const componentHeight = component.defaultSize?.height || 36;
// 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
const dropX_centered = e.clientX - rect.left - componentWidth / 2;
const dropY_centered = e.clientY - rect.top - componentHeight / 2;
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
const dropX_topleft = e.clientX - rect.left;
const dropY_topleft = e.clientY - rect.top;
// 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록
const dropX = dropX_topleft;
const dropY = dropY_topleft;
console.log("🎯 위치 계산 디버깅:", {
"1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
"2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
"3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
"4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
"5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
"5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
"6. 선택된 방식": { dropX, dropY },
"7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
"8. 마우스와 중심 일치 확인": {
match:
Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
},
});
// 현재 해상도에 맞는 격자 정보 계산
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 boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: boundedX, y: boundedY, z: 1 };
console.log("🧩 컴포넌트 드롭:", {
componentName: component.name,
webType: component.webType,
rawPosition: { x: dropX, y: dropY },
boundedPosition: { x: boundedX, y: boundedY },
snappedPosition,
});
// 새 컴포넌트 생성 (새 컴포넌트 시스템 지원)
console.log("🔍 ScreenDesigner handleComponentDrop:", {
componentName: component.name,
componentId: component.id,
webType: component.webType,
category: component.category,
defaultConfig: component.defaultConfig,
});
const newComponent: ComponentData = { const newComponent: ComponentData = {
id: generateComponentId(), id: generateComponentId(),
type: component.webType === "button" ? "button" : "widget", type: "component", // ✅ 새 컴포넌트 시스템 사용
label: component.name, label: component.name,
widgetType: component.webType, widgetType: component.webType,
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
position: snappedPosition, position: snappedPosition,
size: component.defaultSize, size: component.defaultSize,
componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용
webType: component.webType, // 웹타입 정보 추가
...component.defaultConfig,
},
webTypeConfig: getDefaultWebTypeConfig(component.webType), webTypeConfig: getDefaultWebTypeConfig(component.webType),
style: { style: {
labelDisplay: true, labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#374151",
labelFontWeight: "500", labelFontWeight: "500",
@ -1337,10 +1565,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
e.preventDefault(); e.preventDefault();
const dragData = e.dataTransfer.getData("application/json"); const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return; console.log("🎯 드롭 이벤트:", { dragData });
if (!dragData) {
console.log("❌ 드래그 데이터가 없습니다");
return;
}
try { try {
const parsedData = JSON.parse(dragData); const parsedData = JSON.parse(dragData);
console.log("📋 파싱된 데이터:", parsedData);
// 템플릿 드래그인 경우 // 템플릿 드래그인 경우
if (parsedData.type === "template") { if (parsedData.type === "template") {
@ -1348,6 +1581,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return; return;
} }
// 레이아웃 드래그인 경우
if (parsedData.type === "layout") {
handleLayoutDrop(e, parsedData.layout);
return;
}
// 컴포넌트 드래그인 경우 // 컴포넌트 드래그인 경우
if (parsedData.type === "component") { if (parsedData.type === "component") {
handleComponentDrop(e, parsedData.component); handleComponentDrop(e, parsedData.component);
@ -1387,6 +1626,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}, },
}; };
} else if (type === "column") { } else if (type === "column") {
console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산 // 현재 해상도에 맞는 격자 정보로 기본 크기 계산
const currentGridInfo = layout.gridSettings const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, { ? calculateGridInfo(screenResolution.width, screenResolution.height, {
@ -1551,52 +1791,68 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const relativeX = e.clientX - containerRect.left; const relativeX = e.clientX - containerRect.left;
const relativeY = e.clientY - containerRect.top; const relativeY = e.clientY - containerRect.top;
// 웹타입을 새로운 컴포넌트 ID로 매핑
const componentId = getComponentIdFromWebType(column.widgetType);
console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType}${componentId}`);
newComponent = { newComponent = {
id: generateComponentId(), id: generateComponentId(),
type: "widget", type: "component", // ✅ 새로운 컴포넌트 시스템 사용
label: column.columnName, label: column.columnLabel || column.columnName,
tableName: table.tableName, tableName: table.tableName,
columnName: column.columnName, columnName: column.columnName,
widgetType: column.widgetType,
required: column.required, required: column.required,
readonly: false, readonly: false,
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x: relativeX, y: relativeY, z: 1 } as Position, position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: defaultWidth, height: 40 }, size: { width: defaultWidth, height: 40 },
gridColumns: 1,
style: { style: {
labelDisplay: true, labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px", labelFontSize: "12px",
labelColor: "#374151", labelColor: "#374151",
labelFontWeight: "500", labelFontWeight: "500",
labelMarginBottom: "6px", labelMarginBottom: "6px",
}, },
webTypeConfig: getDefaultWebTypeConfig(column.widgetType), componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
...getDefaultWebTypeConfig(column.widgetType),
},
}; };
} else { } else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
} }
} else { } else {
// 일반 캔버스에 드롭한 경우 (기존 로직) // 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
const componentId = getComponentIdFromWebType(column.widgetType);
console.log(`🔄 캔버스 드롭: ${column.widgetType}${componentId}`);
newComponent = { newComponent = {
id: generateComponentId(), id: generateComponentId(),
type: "widget", type: "component", // ✅ 새로운 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName, tableName: table.tableName,
columnName: column.columnName, columnName: column.columnName,
widgetType: column.widgetType,
required: column.required, required: column.required,
readonly: false, readonly: false,
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x, y, z: 1 } as Position, position: { x, y, z: 1 } as Position,
size: { width: defaultWidth, height: 40 }, size: { width: defaultWidth, height: 40 },
gridColumns: 1, gridColumns: 1,
style: { style: {
labelDisplay: true, labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px", labelFontSize: "12px",
labelColor: "#374151", labelColor: "#374151",
labelFontWeight: "500", labelFontWeight: "500",
labelMarginBottom: "6px", labelMarginBottom: "6px",
}, },
webTypeConfig: getDefaultWebTypeConfig(column.widgetType), componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
...getDefaultWebTypeConfig(column.widgetType),
},
}; };
} }
} else { } else {
@ -1744,10 +2000,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 다중 선택된 컴포넌트들 확인 // 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
const componentsToMove = isDraggedComponentSelected let componentsToMove = isDraggedComponentSelected
? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)) ? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))
: [component]; : [component];
// 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동
if (component.type === "layout") {
const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId);
console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", {
layoutId: component.id,
zoneComponentsCount: zoneComponents.length,
zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })),
});
// 중복 제거하여 추가
const allComponentIds = new Set(componentsToMove.map((c) => c.id));
const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id));
componentsToMove = [...componentsToMove, ...additionalComponents];
}
console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length); console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
console.log("마우스 위치:", { console.log("마우스 위치:", {
clientX: event.clientX, clientX: event.clientX,
@ -2854,8 +3126,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
startSelectionDrag(e); startSelectionDrag(e);
} }
}} }}
onDrop={handleDrop} onDragOver={(e) => {
onDragOver={handleDragOver} e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
console.log("🎯 캔버스 드롭 이벤트 발생");
handleDrop(e);
}}
> >
{/* 격자 라인 */} {/* 격자 라인 */}
{gridLines.map((line, index) => ( {gridLines.map((line, index) => (
@ -2937,11 +3216,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isSelected={ isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id) selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
} }
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(component, e)} onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)} onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag} onDragEnd={endDrag}
selectedScreen={selectedScreen}
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
> >
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 */} {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" || component.type === "container" || component.type === "area") && {(component.type === "group" || component.type === "container" || component.type === "area") &&
layout.components layout.components
.filter((child) => child.parentId === component.id) .filter((child) => child.parentId === component.id)
@ -3013,9 +3296,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isSelected={ isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id) selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
} }
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(child, e)} onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)} onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag} onDragEnd={endDrag}
selectedScreen={selectedScreen}
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
/> />
); );
})} })}
@ -3078,11 +3365,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => { onDragStart={(e, table, column) => {
console.log("🚀 드래그 시작:", { table: table.tableName, column: column?.columnName });
const dragData = { const dragData = {
type: column ? "column" : "table", type: column ? "column" : "table",
table, table,
column, column,
}; };
console.log("📦 드래그 데이터:", dragData);
e.dataTransfer.setData("application/json", JSON.stringify(dragData)); e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}} }}
selectedTableName={selectedScreen.tableName} selectedTableName={selectedScreen.tableName}
@ -3120,6 +3409,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
/> />
</FloatingPanel> </FloatingPanel>
<FloatingPanel
id="layouts"
title="레이아웃"
isOpen={panelStates.layouts?.isOpen || false}
onClose={() => closePanel("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));
}}
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true }}
screenResolution={screenResolution}
/>
</FloatingPanel>
<FloatingPanel <FloatingPanel
id="components" id="components"
title="컴포넌트" title="컴포넌트"
@ -3130,22 +3442,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
height={700} height={700}
autoHeight={false} autoHeight={false}
> >
<ComponentsPanel <ComponentsPanel />
onDragStart={(e, component) => {
const dragData = {
type: "component",
component: {
id: component.id,
name: component.name,
description: component.description,
category: component.category,
webType: component.webType,
defaultSize: component.defaultSize,
},
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel> </FloatingPanel>
<FloatingPanel <FloatingPanel

View File

@ -0,0 +1,70 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ComponentData } from "@/types/screen";
interface AlertConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const AlertConfigPanel: React.FC<AlertConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
return (
<div className="space-y-4">
<div>
<Label htmlFor="alert-title"></Label>
<Input
id="alert-title"
value={config.title || "알림 제목"}
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
placeholder="알림 제목을 입력하세요"
/>
</div>
<div>
<Label htmlFor="alert-message"></Label>
<Textarea
id="alert-message"
value={config.message || "알림 메시지입니다."}
onChange={(e) => onUpdateProperty("componentConfig.message", e.target.value)}
placeholder="알림 메시지를 입력하세요"
rows={3}
/>
</div>
<div>
<Label htmlFor="alert-type"> </Label>
<Select
value={config.type || "info"}
onValueChange={(value) => onUpdateProperty("componentConfig.type", value)}
>
<SelectTrigger>
<SelectValue placeholder="알림 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="info"> (Info)</SelectItem>
<SelectItem value="warning"> (Warning)</SelectItem>
<SelectItem value="success"> (Success)</SelectItem>
<SelectItem value="error"> (Error)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show-icon"
checked={config.showIcon ?? true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showIcon", checked)}
/>
<Label htmlFor="show-icon"> </Label>
</div>
</div>
);
};

View File

@ -0,0 +1,65 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ComponentData } from "@/types/screen";
interface BadgeConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const BadgeConfigPanel: React.FC<BadgeConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
return (
<div className="space-y-4">
<div>
<Label htmlFor="badge-text"> </Label>
<Input
id="badge-text"
value={config.text || "상태"}
onChange={(e) => onUpdateProperty("componentConfig.text", e.target.value)}
placeholder="뱃지 텍스트를 입력하세요"
/>
</div>
<div>
<Label htmlFor="badge-variant"> </Label>
<Select
value={config.variant || "default"}
onValueChange={(value) => onUpdateProperty("componentConfig.variant", value)}
>
<SelectTrigger>
<SelectValue placeholder="뱃지 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="secondary"> (Secondary)</SelectItem>
<SelectItem value="destructive"> (Destructive)</SelectItem>
<SelectItem value="outline"> (Outline)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="badge-size"> </Label>
<Select
value={config.size || "default"}
onValueChange={(value) => onUpdateProperty("componentConfig.size", value)}
>
<SelectTrigger>
<SelectValue placeholder="뱃지 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="small"> (Small)</SelectItem>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="large"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};

View File

@ -1,139 +1,425 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { ConfigPanelProps } from "@/lib/registry/types"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
export const ButtonConfigPanel: React.FC<ConfigPanelProps> = ({ config: initialConfig, onConfigChange }) => { interface ButtonConfigPanelProps {
const [localConfig, setLocalConfig] = useState({ component: ComponentData;
label: "버튼", onUpdateProperty: (path: string, value: any) => void;
text: "", }
tooltip: "",
variant: "primary",
size: "medium",
disabled: false,
fullWidth: false,
...initialConfig,
});
interface ScreenOption {
id: number;
name: string;
description?: string;
}
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [modalScreenOpen, setModalScreenOpen] = useState(false);
const [navScreenOpen, setNavScreenOpen] = useState(false);
const [modalSearchTerm, setModalSearchTerm] = useState("");
const [navSearchTerm, setNavSearchTerm] = useState("");
// 화면 목록 가져오기
useEffect(() => { useEffect(() => {
setLocalConfig({ const fetchScreens = async () => {
label: "버튼", try {
text: "", setScreensLoading(true);
tooltip: "", console.log("🔍 화면 목록 API 호출 시작");
variant: "primary", const response = await apiClient.get("/screen-management/screens");
size: "medium", console.log("✅ 화면 목록 API 응답:", response.data);
disabled: false,
fullWidth: false,
...initialConfig,
});
}, [initialConfig]);
const updateConfig = (key: string, value: any) => { if (response.data.success && Array.isArray(response.data.data)) {
const newConfig = { ...localConfig, [key]: value }; const screenList = response.data.data.map((screen: any) => ({
setLocalConfig(newConfig); id: screen.screenId,
onConfigChange(newConfig); name: screen.screenName,
description: screen.description,
}));
setScreens(screenList);
console.log("✅ 화면 목록 설정 완료:", screenList.length, "개");
}
} catch (error) {
console.error("❌ 화면 목록 로딩 실패:", error);
} finally {
setScreensLoading(false);
}
};
fetchScreens();
}, []);
// 검색 필터링 함수
const filterScreens = (searchTerm: string) => {
if (!searchTerm.trim()) return screens;
return screens.filter(
(screen) =>
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
}; };
console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
component,
config,
action: config.action,
actionType: config.action?.type,
screensCount: screens.length,
});
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3"> <div>
<div> <Label htmlFor="button-text"> </Label>
<label htmlFor="button-label" className="mb-1 block text-sm font-medium text-gray-700"> <Input
id="button-text"
</label> value={config.text || "버튼"}
<input onChange={(e) => onUpdateProperty("componentConfig.text", e.target.value)}
id="button-label" placeholder="버튼 텍스트를 입력하세요"
type="text" />
value={localConfig.label || ""}
onChange={(e) => updateConfig("label", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="버튼에 표시될 텍스트"
/>
</div>
<div>
<label htmlFor="button-tooltip" className="mb-1 block text-sm font-medium text-gray-700">
()
</label>
<input
id="button-tooltip"
type="text"
value={localConfig.tooltip || ""}
onChange={(e) => updateConfig("tooltip", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="마우스 오버 시 표시될 텍스트"
/>
</div>
<div>
<label htmlFor="button-variant" className="mb-1 block text-sm font-medium text-gray-700">
</label>
<select
id="button-variant"
value={localConfig.variant || "primary"}
onChange={(e) => updateConfig("variant", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="primary"> ()</option>
<option value="secondary"> ()</option>
<option value="success"> ()</option>
<option value="warning"> ()</option>
<option value="danger"> ()</option>
<option value="outline"></option>
</select>
</div>
<div>
<label htmlFor="button-size" className="mb-1 block text-sm font-medium text-gray-700">
</label>
<select
id="button-size"
value={localConfig.size || "medium"}
onChange={(e) => updateConfig("size", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="small"></option>
<option value="medium"></option>
<option value="large"></option>
</select>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={localConfig.disabled || false}
onChange={(e) => updateConfig("disabled", e.target.checked)}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={localConfig.fullWidth || false}
onChange={(e) => updateConfig("fullWidth", e.target.checked)}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<span className="text-sm font-medium text-gray-700"> </span>
</label>
</div>
</div> </div>
<div className="border-t border-gray-200 pt-3"> <div>
<h4 className="mb-2 text-sm font-medium text-gray-700"></h4> <Label htmlFor="button-variant"> </Label>
<button <Select
type="button" value={config.variant || "default"}
disabled={localConfig.disabled} onValueChange={(value) => onUpdateProperty("componentConfig.variant", value)}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors duration-200 ${localConfig.size === "small" ? "px-3 py-1 text-xs" : ""} ${localConfig.size === "large" ? "px-6 py-3 text-base" : ""} ${localConfig.variant === "primary" ? "bg-blue-600 text-white hover:bg-blue-700" : ""} ${localConfig.variant === "secondary" ? "bg-gray-600 text-white hover:bg-gray-700" : ""} ${localConfig.variant === "success" ? "bg-green-600 text-white hover:bg-green-700" : ""} ${localConfig.variant === "warning" ? "bg-yellow-600 text-white hover:bg-yellow-700" : ""} ${localConfig.variant === "danger" ? "bg-red-600 text-white hover:bg-red-700" : ""} ${localConfig.variant === "outline" ? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50" : ""} ${localConfig.fullWidth ? "w-full" : ""} ${localConfig.disabled ? "cursor-not-allowed opacity-50" : ""} `}
title={localConfig.tooltip}
> >
{localConfig.label || "버튼"} <SelectTrigger>
</button> <SelectValue placeholder="버튼 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="primary"> (Primary)</SelectItem>
<SelectItem value="secondary"> (Secondary)</SelectItem>
<SelectItem value="danger"> (Danger)</SelectItem>
<SelectItem value="success"> (Success)</SelectItem>
<SelectItem value="outline"> (Outline)</SelectItem>
<SelectItem value="ghost"> (Ghost)</SelectItem>
<SelectItem value="link"> (Link)</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div>
<Label htmlFor="button-size"> </Label>
<Select
value={config.size || "default"}
onValueChange={(value) => onUpdateProperty("componentConfig.size", value)}
>
<SelectTrigger>
<SelectValue placeholder="버튼 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="small"> (Small)</SelectItem>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="large"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="button-action"> </Label>
<Select
value={config.action?.type || "save"}
defaultValue="save"
onValueChange={(value) => onUpdateProperty("componentConfig.action", { type: value })}
>
<SelectTrigger>
<SelectValue placeholder="버튼 액션 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="save"></SelectItem>
<SelectItem value="cancel"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="edit"></SelectItem>
<SelectItem value="add"></SelectItem>
<SelectItem value="search"></SelectItem>
<SelectItem value="reset"></SelectItem>
<SelectItem value="submit"></SelectItem>
<SelectItem value="close"></SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 모달 열기 액션 설정 */}
{config.action?.type === "modal" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="modal-title"> </Label>
<Input
id="modal-title"
placeholder="모달 제목을 입력하세요"
value={config.action?.modalTitle || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action", {
...config.action,
modalTitle: e.target.value,
})
}
/>
</div>
<div>
<Label htmlFor="modal-size"> </Label>
<Select
value={config.action?.modalSize || "md"}
onValueChange={(value) =>
onUpdateProperty("componentConfig.action", {
...config.action,
modalSize: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-modal"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="w-full justify-between h-10"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
<div className="flex flex-col">
{/* 검색 입력 */}
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
{/* 검색 결과 */}
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action", {
...config.action,
targetScreenId: screen.id,
});
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 페이지 이동 액션 설정 */}
{config.action?.type === "navigate" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="target-screen-nav"> </Label>
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={navScreenOpen}
className="w-full justify-between h-10"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
<div className="flex flex-col">
{/* 검색 입력 */}
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={navSearchTerm}
onChange={(e) => setNavSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
{/* 검색 결과 */}
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(navSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`navigate-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action", {
...config.action,
targetScreenId: screen.id,
});
setNavScreenOpen(false);
setNavSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
/screens/{"{"}ID{"}"}
</p>
</div>
<div>
<Label htmlFor="target-url"> URL ()</Label>
<Input
id="target-url"
placeholder="예: /admin/users 또는 https://example.com"
value={config.action?.targetUrl || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action", {
...config.action,
targetUrl: e.target.value,
})
}
/>
<p className="mt-1 text-xs text-gray-500">URL을 </p>
</div>
</div>
)}
{/* 확인 메시지 설정 (모든 액션 공통) */}
{config.action?.type && config.action.type !== "cancel" && config.action.type !== "close" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="confirm-message"> </Label>
<Input
id="confirm-message"
placeholder="예: 정말 저장하시겠습니까?"
value={config.action?.confirmMessage || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action", {
...config.action,
confirmMessage: e.target.value,
})
}
/>
</div>
<div>
<Label htmlFor="success-message"> </Label>
<Input
id="success-message"
placeholder="예: 저장되었습니다."
value={config.action?.successMessage || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action", {
...config.action,
successMessage: e.target.value,
})
}
/>
</div>
<div>
<Label htmlFor="error-message"> </Label>
<Input
id="error-message"
placeholder="예: 저장 중 오류가 발생했습니다."
value={config.action?.errorMessage || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action", {
...config.action,
errorMessage: e.target.value,
})
}
/>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -0,0 +1,117 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
interface CardConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const CardConfigPanel: React.FC<CardConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const handleConfigChange = (key: string, value: any) => {
onUpdateProperty(`componentConfig.${key}`, value);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 카드 제목 */}
<div className="space-y-2">
<Label htmlFor="card-title"> </Label>
<Input
id="card-title"
placeholder="카드 제목을 입력하세요"
value={config.title || "카드 제목"}
onChange={(e) => handleConfigChange("title", e.target.value)}
/>
</div>
{/* 카드 내용 */}
<div className="space-y-2">
<Label htmlFor="card-content"> </Label>
<Textarea
id="card-content"
placeholder="카드 내용을 입력하세요"
value={config.content || "카드 내용 영역"}
onChange={(e) => handleConfigChange("content", e.target.value)}
rows={3}
/>
</div>
{/* 카드 스타일 */}
<div className="space-y-2">
<Label htmlFor="card-variant"> </Label>
<Select value={config.variant || "default"} onValueChange={(value) => handleConfigChange("variant", value)}>
<SelectTrigger>
<SelectValue placeholder="카드 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="outlined"> (Outlined)</SelectItem>
<SelectItem value="elevated"> (Elevated)</SelectItem>
<SelectItem value="filled"> (Filled)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 헤더 표시 여부 */}
<div className="flex items-center space-x-2">
<Switch
id="show-header"
checked={config.showHeader !== false}
onCheckedChange={(checked) => handleConfigChange("showHeader", checked)}
/>
<Label htmlFor="show-header"> </Label>
</div>
{/* 패딩 설정 */}
<div className="space-y-2">
<Label htmlFor="card-padding"></Label>
<Select value={config.padding || "default"} onValueChange={(value) => handleConfigChange("padding", value)}>
<SelectTrigger>
<SelectValue placeholder="패딩 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> (None)</SelectItem>
<SelectItem value="small"> (Small)</SelectItem>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="large"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label htmlFor="background-color"></Label>
<Input
id="background-color"
type="color"
value={config.backgroundColor || "#ffffff"}
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
/>
</div>
{/* 테두리 반경 */}
<div className="space-y-2">
<Label htmlFor="border-radius"> </Label>
<Input
id="border-radius"
placeholder="8px"
value={config.borderRadius || "8px"}
onChange={(e) => handleConfigChange("borderRadius", e.target.value)}
/>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,79 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ComponentData } from "@/types/screen";
interface ChartConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
return (
<div className="space-y-4">
<div>
<Label htmlFor="chart-title"> </Label>
<Input
id="chart-title"
value={config.title || "차트 제목"}
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
placeholder="차트 제목을 입력하세요"
/>
</div>
<div>
<Label htmlFor="chart-type"> </Label>
<Select
value={config.chartType || "bar"}
onValueChange={(value) => onUpdateProperty("componentConfig.chartType", value)}
>
<SelectTrigger>
<SelectValue placeholder="차트 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar"> (Bar)</SelectItem>
<SelectItem value="line"> (Line)</SelectItem>
<SelectItem value="pie"> (Pie)</SelectItem>
<SelectItem value="area"> (Area)</SelectItem>
<SelectItem value="scatter"> (Scatter)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="chart-data-source"> </Label>
<Input
id="chart-data-source"
value={config.dataSource || ""}
onChange={(e) => onUpdateProperty("componentConfig.dataSource", e.target.value)}
placeholder="데이터 소스 URL 또는 API 엔드포인트"
/>
</div>
<div>
<Label htmlFor="chart-x-axis">X축 </Label>
<Input
id="chart-x-axis"
value={config.xAxisLabel || ""}
onChange={(e) => onUpdateProperty("componentConfig.xAxisLabel", e.target.value)}
placeholder="X축 라벨"
/>
</div>
<div>
<Label htmlFor="chart-y-axis">Y축 </Label>
<Input
id="chart-y-axis"
value={config.yAxisLabel || ""}
onChange={(e) => onUpdateProperty("componentConfig.yAxisLabel", e.target.value)}
placeholder="Y축 라벨"
/>
</div>
</div>
);
};

View File

@ -0,0 +1,148 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
interface DashboardConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const DashboardConfigPanel: React.FC<DashboardConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const handleConfigChange = (key: string, value: any) => {
onUpdateProperty(`componentConfig.${key}`, value);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 그리드 제목 */}
<div className="space-y-2">
<Label htmlFor="grid-title"> </Label>
<Input
id="grid-title"
placeholder="그리드 제목을 입력하세요"
value={config.title || "대시보드 그리드"}
onChange={(e) => handleConfigChange("title", e.target.value)}
/>
</div>
{/* 행 개수 */}
<div className="space-y-2">
<Label htmlFor="grid-rows"> </Label>
<Select
value={String(config.rows || 2)}
onValueChange={(value) => handleConfigChange("rows", parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="행 개수 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</div>
{/* 열 개수 */}
<div className="space-y-2">
<Label htmlFor="grid-columns"> </Label>
<Select
value={String(config.columns || 3)}
onValueChange={(value) => handleConfigChange("columns", parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="열 개수 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
</SelectContent>
</Select>
</div>
{/* 간격 설정 */}
<div className="space-y-2">
<Label htmlFor="grid-gap"> </Label>
<Select value={config.gap || "medium"} onValueChange={(value) => handleConfigChange("gap", value)}>
<SelectTrigger>
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> (0px)</SelectItem>
<SelectItem value="small"> (8px)</SelectItem>
<SelectItem value="medium"> (16px)</SelectItem>
<SelectItem value="large"> (24px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 그리드 아이템 높이 */}
<div className="space-y-2">
<Label htmlFor="item-height"> </Label>
<Input
id="item-height"
placeholder="120px"
value={config.itemHeight || "120px"}
onChange={(e) => handleConfigChange("itemHeight", e.target.value)}
/>
</div>
{/* 반응형 설정 */}
<div className="flex items-center space-x-2">
<Switch
id="responsive"
checked={config.responsive !== false}
onCheckedChange={(checked) => handleConfigChange("responsive", checked)}
/>
<Label htmlFor="responsive"> </Label>
</div>
{/* 테두리 표시 */}
<div className="flex items-center space-x-2">
<Switch
id="show-borders"
checked={config.showBorders !== false}
onCheckedChange={(checked) => handleConfigChange("showBorders", checked)}
/>
<Label htmlFor="show-borders"> </Label>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label htmlFor="background-color"></Label>
<Input
id="background-color"
type="color"
value={config.backgroundColor || "#f8f9fa"}
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
/>
</div>
{/* 테두리 반경 */}
<div className="space-y-2">
<Label htmlFor="border-radius"> </Label>
<Input
id="border-radius"
placeholder="8px"
value={config.borderRadius || "8px"}
onChange={(e) => handleConfigChange("borderRadius", e.target.value)}
/>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,82 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
interface ProgressBarConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const ProgressBarConfigPanel: React.FC<ProgressBarConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
return (
<div className="space-y-4">
<div>
<Label htmlFor="progress-label"></Label>
<Input
id="progress-label"
value={config.label || "진행률"}
onChange={(e) => onUpdateProperty("componentConfig.label", e.target.value)}
placeholder="진행률 라벨을 입력하세요"
/>
</div>
<div>
<Label htmlFor="progress-value"> </Label>
<Input
id="progress-value"
type="number"
value={config.value || 65}
onChange={(e) => onUpdateProperty("componentConfig.value", parseInt(e.target.value) || 0)}
placeholder="현재 값"
min="0"
/>
</div>
<div>
<Label htmlFor="progress-max"> </Label>
<Input
id="progress-max"
type="number"
value={config.max || 100}
onChange={(e) => onUpdateProperty("componentConfig.max", parseInt(e.target.value) || 100)}
placeholder="최대 값"
min="1"
/>
</div>
<div>
<Label htmlFor="progress-color"> </Label>
<Input
id="progress-color"
type="color"
value={config.color || "#3b82f6"}
onChange={(e) => onUpdateProperty("componentConfig.color", e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show-percentage"
checked={config.showPercentage ?? true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showPercentage", checked)}
/>
<Label htmlFor="show-percentage"> </Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show-value"
checked={config.showValue ?? true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showValue", checked)}
/>
<Label htmlFor="show-value"> </Label>
</div>
</div>
);
};

View File

@ -0,0 +1,77 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ComponentData } from "@/types/screen";
interface StatsCardConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const StatsCardConfigPanel: React.FC<StatsCardConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
return (
<div className="space-y-4">
<div>
<Label htmlFor="stats-title"></Label>
<Input
id="stats-title"
value={config.title || "통계 제목"}
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
placeholder="통계 제목을 입력하세요"
/>
</div>
<div>
<Label htmlFor="stats-value"></Label>
<Input
id="stats-value"
value={config.value || "1,234"}
onChange={(e) => onUpdateProperty("componentConfig.value", e.target.value)}
placeholder="통계 값을 입력하세요"
/>
</div>
<div>
<Label htmlFor="stats-change"></Label>
<Input
id="stats-change"
value={config.change || "+12.5%"}
onChange={(e) => onUpdateProperty("componentConfig.change", e.target.value)}
placeholder="변화량을 입력하세요 (예: +12.5%)"
/>
</div>
<div>
<Label htmlFor="stats-trend"></Label>
<Select
value={config.trend || "up"}
onValueChange={(value) => onUpdateProperty("componentConfig.trend", value)}
>
<SelectTrigger>
<SelectValue placeholder="트렌드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="up"> (Up)</SelectItem>
<SelectItem value="down"> (Down)</SelectItem>
<SelectItem value="neutral"> (Neutral)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="stats-description"></Label>
<Input
id="stats-description"
value={config.description || "전월 대비"}
onChange={(e) => onUpdateProperty("componentConfig.description", e.target.value)}
placeholder="설명을 입력하세요"
/>
</div>
</div>
);
};

View File

@ -1,537 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import {
Save,
X,
Trash2,
Edit,
Plus,
RotateCcw,
Send,
ExternalLink,
MousePointer,
Settings,
AlertTriangle,
} from "lucide-react";
import { ButtonActionType, ButtonTypeConfig, WidgetComponent, ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
interface ButtonConfigPanelProps {
component: WidgetComponent;
onUpdateComponent: (updates: Partial<WidgetComponent>) => void;
}
const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.ReactNode; color: string }[] = [
{ value: "save", label: "저장", icon: <Save className="h-4 w-4" />, color: "#3b82f6" },
{ value: "delete", label: "삭제", icon: <Trash2 className="h-4 w-4" />, color: "#ef4444" },
{ value: "edit", label: "수정", icon: <Edit className="h-4 w-4" />, color: "#f59e0b" },
{ value: "add", label: "추가", icon: <Plus className="h-4 w-4" />, color: "#10b981" },
{ value: "search", label: "검색", icon: <MousePointer className="h-4 w-4" />, color: "#8b5cf6" },
{ value: "reset", label: "초기화", icon: <RotateCcw className="h-4 w-4" />, color: "#6b7280" },
{ value: "submit", label: "제출", icon: <Send className="h-4 w-4" />, color: "#059669" },
{ value: "close", label: "닫기", icon: <X className="h-4 w-4" />, color: "#6b7280" },
{ value: "popup", label: "모달 열기", icon: <ExternalLink className="h-4 w-4" />, color: "#8b5cf6" },
{ value: "navigate", label: "페이지 이동", icon: <ExternalLink className="h-4 w-4" />, color: "#0ea5e9" },
{ value: "custom", label: "사용자 정의", icon: <Settings className="h-4 w-4" />, color: "#64748b" },
];
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateComponent }) => {
const config = (component.webTypeConfig as ButtonTypeConfig) || {};
// 로컬 상태 관리
const [localConfig, setLocalConfig] = useState<ButtonTypeConfig>(() => {
const defaultConfig = {
actionType: "custom" as ButtonActionType,
variant: "default" as ButtonVariant,
};
return {
...defaultConfig,
...config, // 저장된 값이 기본값을 덮어씀
};
});
// 화면 목록 상태
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
// 화면 목록 로드 함수
const loadScreens = async () => {
try {
setScreensLoading(true);
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
setScreens(response.data);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setScreensLoading(false);
}
};
// 모달 또는 네비게이션 액션 타입일 때 화면 목록 로드
useEffect(() => {
if (localConfig.actionType === "popup" || localConfig.actionType === "navigate") {
loadScreens();
}
}, [localConfig.actionType]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {};
// 기본값 설정 (실제 값이 있으면 덮어쓰지 않음)
const defaultConfig = {
actionType: "custom" as ButtonActionType,
variant: "default" as ButtonVariant,
};
// 실제 저장된 값이 우선순위를 가지도록 설정
setLocalConfig({
...defaultConfig,
...newConfig, // 저장된 값이 기본값을 덮어씀
});
console.log("🔄 ButtonConfigPanel 로컬 상태 동기화:", {
componentId: component.id,
savedConfig: newConfig,
finalConfig: { ...defaultConfig, ...newConfig },
});
}, [component.webTypeConfig, component.id]);
// 설정 업데이트 함수
const updateConfig = (updates: Partial<ButtonTypeConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
// 스타일 업데이트도 함께 적용
const styleUpdates: any = {};
if (updates.backgroundColor) styleUpdates.backgroundColor = updates.backgroundColor;
if (updates.textColor) styleUpdates.color = updates.textColor;
if (updates.borderColor) styleUpdates.borderColor = updates.borderColor;
onUpdateComponent({
webTypeConfig: newConfig,
...(Object.keys(styleUpdates).length > 0 && {
style: { ...component.style, ...styleUpdates },
}),
});
};
// 액션 타입 변경 시 기본값 설정
const handleActionTypeChange = (actionType: ButtonActionType) => {
const actionOption = actionTypeOptions.find((opt) => opt.value === actionType);
const updates: Partial<ButtonTypeConfig> = { actionType };
// 액션 타입에 따른 기본 설정
switch (actionType) {
case "save":
updates.variant = "default";
updates.backgroundColor = "#3b82f6";
updates.textColor = "#ffffff";
// 버튼 라벨과 스타일도 업데이트
onUpdateComponent({
label: "저장",
style: { ...component.style, backgroundColor: "#3b82f6", color: "#ffffff" },
});
break;
case "close":
updates.variant = "outline";
updates.backgroundColor = "transparent";
updates.textColor = "#6b7280";
onUpdateComponent({
label: "닫기",
style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" },
});
break;
case "delete":
updates.variant = "destructive";
updates.backgroundColor = "#ef4444";
updates.textColor = "#ffffff";
updates.confirmMessage = "정말로 삭제하시겠습니까?";
onUpdateComponent({
label: "삭제",
style: { ...component.style, backgroundColor: "#ef4444", color: "#ffffff" },
});
break;
case "edit":
updates.backgroundColor = "#f59e0b";
updates.textColor = "#ffffff";
onUpdateComponent({
label: "수정",
style: { ...component.style, backgroundColor: "#f59e0b", color: "#ffffff" },
});
break;
case "add":
updates.backgroundColor = "#10b981";
updates.textColor = "#ffffff";
onUpdateComponent({
label: "추가",
style: { ...component.style, backgroundColor: "#10b981", color: "#ffffff" },
});
break;
case "search":
updates.backgroundColor = "#8b5cf6";
updates.textColor = "#ffffff";
onUpdateComponent({
label: "검색",
style: { ...component.style, backgroundColor: "#8b5cf6", color: "#ffffff" },
});
break;
case "reset":
updates.variant = "outline";
updates.backgroundColor = "transparent";
updates.textColor = "#6b7280";
onUpdateComponent({
label: "초기화",
style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" },
});
break;
case "submit":
updates.backgroundColor = "#059669";
updates.textColor = "#ffffff";
onUpdateComponent({
label: "제출",
style: { ...component.style, backgroundColor: "#059669", color: "#ffffff" },
});
break;
case "popup":
updates.backgroundColor = "#8b5cf6";
updates.textColor = "#ffffff";
updates.popupTitle = "상세 정보";
updates.popupContent = "여기에 모달 내용을 입력하세요.";
updates.popupSize = "md";
onUpdateComponent({
label: "상세보기",
style: { ...component.style, backgroundColor: "#8b5cf6", color: "#ffffff" },
});
break;
case "navigate":
updates.backgroundColor = "#0ea5e9";
updates.textColor = "#ffffff";
updates.navigateType = "url";
updates.navigateUrl = "/";
updates.navigateTarget = "_self";
onUpdateComponent({
label: "이동",
style: { ...component.style, backgroundColor: "#0ea5e9", color: "#ffffff" },
});
break;
case "custom":
updates.backgroundColor = "#64748b";
updates.textColor = "#ffffff";
onUpdateComponent({
label: "버튼",
style: { ...component.style, backgroundColor: "#64748b", color: "#ffffff" },
});
break;
}
// 로컬 상태 업데이트 후 webTypeConfig도 함께 업데이트
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
// webTypeConfig를 마지막에 다시 업데이트하여 확실히 저장되도록 함
setTimeout(() => {
onUpdateComponent({
webTypeConfig: newConfig,
});
console.log("🎯 ButtonActionType webTypeConfig 최종 업데이트:", {
actionType,
newConfig,
componentId: component.id,
});
}, 0);
};
const selectedActionOption = actionTypeOptions.find((opt) => opt.value === localConfig.actionType);
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Settings className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 액션 타입 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select value={localConfig.actionType} onValueChange={handleActionTypeChange}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{actionTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
{option.icon}
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedActionOption && (
<div className="flex items-center gap-2 text-xs text-gray-500">
{selectedActionOption.icon}
<span>{selectedActionOption.label}</span>
<Badge
variant="outline"
style={{ backgroundColor: selectedActionOption.color + "20", color: selectedActionOption.color }}
>
{selectedActionOption.value}
</Badge>
</div>
)}
</div>
<Separator />
{/* 기본 설정 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{/* 버튼 텍스트 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={component.label || ""}
onChange={(e) => {
const newValue = e.target.value;
onUpdateComponent({ label: newValue });
}}
placeholder="버튼에 표시될 텍스트"
className="h-8 text-xs"
/>
</div>
{/* 버튼 스타일 */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select value={localConfig.variant} onValueChange={(value) => updateConfig({ variant: value as any })}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="destructive"></SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="secondary"></SelectItem>
<SelectItem value="ghost"></SelectItem>
<SelectItem value="link"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 아이콘 설정 */}
<div className="space-y-1">
<Label className="text-xs"> (Lucide )</Label>
<Input
value={localConfig.icon || ""}
onChange={(e) => updateConfig({ icon: e.target.value })}
placeholder="예: Save, Edit, Trash2"
className="h-8 text-xs"
/>
</div>
</div>
<Separator />
{/* 액션별 세부 설정 */}
{localConfig.actionType === "delete" && (
<div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium">
<AlertTriangle className="h-3 w-3 text-red-500" />
</Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={localConfig.confirmMessage || ""}
onChange={(e) => updateConfig({ confirmMessage: e.target.value })}
placeholder="정말로 삭제하시겠습니까?"
className="h-8 text-xs"
/>
</div>
</div>
)}
{localConfig.actionType === "popup" && (
<div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium">
<ExternalLink className="h-3 w-3 text-purple-500" />
</Label>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.popupScreenId?.toString() || "none"}
onValueChange={(value) =>
updateConfig({
popupScreenId: value === "none" ? undefined : parseInt(value),
})
}
disabled={screensLoading}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={screensLoading ? "로딩 중..." : "화면을 선택하세요"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{screens.map((screen) => (
<SelectItem key={screen.screenId} value={screen.screenId.toString()}>
{screen.screenName} ({screen.screenCode})
</SelectItem>
))}
</SelectContent>
</Select>
{localConfig.popupScreenId && <p className="text-xs text-gray-500"> </p>}
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={localConfig.popupTitle || ""}
onChange={(e) => updateConfig({ popupTitle: e.target.value })}
placeholder="상세 정보"
className="h-8 text-xs"
/>
</div>
{!localConfig.popupScreenId && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Textarea
value={localConfig.popupContent || ""}
onChange={(e) => updateConfig({ popupContent: e.target.value })}
placeholder="여기에 모달 내용을 입력하세요."
className="h-16 resize-none text-xs"
/>
<p className="text-xs text-gray-500"> </p>
</div>
)}
</div>
</div>
)}
{localConfig.actionType === "navigate" && (
<div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium">
<ExternalLink className="h-3 w-3 text-blue-500" />
</Label>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.navigateType || "url"}
onValueChange={(value) => updateConfig({ navigateType: value as "url" | "screen" })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url">URL </SelectItem>
<SelectItem value="screen"> </SelectItem>
</SelectContent>
</Select>
</div>
{(localConfig.navigateType || "url") === "url" ? (
<div className="space-y-1">
<Label className="text-xs"> URL</Label>
<Input
value={localConfig.navigateUrl || ""}
onChange={(e) => updateConfig({ navigateUrl: e.target.value })}
placeholder="/admin/users"
className="h-8 text-xs"
/>
</div>
) : (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.navigateScreenId?.toString() || ""}
onValueChange={(value) => updateConfig({ navigateScreenId: value ? parseInt(value) : undefined })}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="화면을 선택하세요" />
</SelectTrigger>
<SelectContent>
{screensLoading ? (
<SelectItem value="" disabled>
...
</SelectItem>
) : screens.length === 0 ? (
<SelectItem value="" disabled>
</SelectItem>
) : (
screens.map((screen) => (
<SelectItem key={screen.screenId} value={screen.screenId.toString()}>
{screen.screenName} ({screen.screenCode})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.navigateTarget || "_self"}
onValueChange={(value) => updateConfig({ navigateTarget: value as "_self" | "_blank" })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="_self"> </SelectItem>
<SelectItem value="_blank"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{localConfig.actionType === "custom" && (
<div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium">
<Settings className="h-3 w-3 text-gray-500" />
</Label>
<div className="space-y-2">
<Label className="text-xs">JavaScript </Label>
<Textarea
value={localConfig.customAction || ""}
onChange={(e) => updateConfig({ customAction: e.target.value })}
placeholder="alert('버튼이 클릭되었습니다!');"
className="h-16 resize-none font-mono text-xs"
/>
<div className="text-xs text-gray-500">
JavaScript . : alert(), console.log(),
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@ -1,283 +1,260 @@
"use client"; "use client";
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import { Plus, Layers, Search, Filter } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge";
import { useComponents } from "@/hooks/admin/useComponents"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { Search, Package, Grid, Layers, Palette, Zap, RotateCcw } from "lucide-react";
interface ComponentsPanelProps { interface ComponentsPanelProps {
onDragStart: (e: React.DragEvent, component: ComponentItem) => void; className?: string;
} }
interface ComponentItem { export function ComponentsPanel({ className }: ComponentsPanelProps) {
id: string; const [searchQuery, setSearchQuery] = useState("");
name: string; const [selectedCategory, setSelectedCategory] = useState<ComponentCategory | "all">("all");
description: string;
category: string;
componentType: string;
componentConfig: any;
icon: React.ReactNode;
defaultSize: { width: number; height: number };
}
// 컴포넌트 카테고리 정의 (실제 생성된 컴포넌트에 맞게) // 레지스트리에서 모든 컴포넌트 조회
const COMPONENT_CATEGORIES = [ const allComponents = useMemo(() => {
{ id: "action", name: "액션", description: "사용자 동작을 처리하는 컴포넌트" }, return ComponentRegistry.getAllComponents();
{ id: "layout", name: "레이아웃", description: "화면 구조를 제공하는 컴포넌트" }, }, []);
{ id: "data", name: "데이터", description: "데이터를 표시하는 컴포넌트" },
{ id: "navigation", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" },
{ id: "feedback", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" },
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
{ id: "other", name: "기타", description: "기타 컴포넌트" },
];
export const ComponentsPanel: React.FC<ComponentsPanelProps> = ({ onDragStart }) => { // 카테고리별 분류 (input 카테고리 제외)
const [searchTerm, setSearchTerm] = useState(""); const componentsByCategory = useMemo(() => {
const [selectedCategory, setSelectedCategory] = useState<string>("all"); // input 카테고리 컴포넌트들을 제외한 컴포넌트만 필터링
const filteredComponents = allComponents.filter((component) => component.category !== "input");
// 데이터베이스에서 컴포넌트 가져오기 const categories: Record<ComponentCategory | "all", ComponentDefinition[]> = {
const { all: filteredComponents, // input 카테고리 제외된 컴포넌트들만 포함
data: componentsData, input: [], // 빈 배열로 유지 (사용되지 않음)
isLoading: loading, display: [],
error, action: [],
} = useComponents({ layout: [],
active: "Y", utility: [],
}); };
// 컴포넌트를 ComponentItem으로 변환 filteredComponents.forEach((component) => {
const componentItems = useMemo(() => { if (categories[component.category]) {
if (!componentsData?.components) return []; categories[component.category].push(component);
}
});
return componentsData.components.map((component) => ({ return categories;
id: component.component_code, }, [allComponents]);
name: component.component_name,
description: component.description || `${component.component_name} 컴포넌트`,
category: component.category || "other",
componentType: component.component_config?.type || component.component_code,
componentConfig: component.component_config,
icon: getComponentIcon(component.icon_name || component.component_config?.type),
defaultSize: component.default_size || getDefaultSize(component.component_config?.type),
}));
}, [componentsData]);
// 필터링된 컴포넌트 // 검색 및 필터링된 컴포넌트
const filteredComponents = useMemo(() => { const filteredComponents = useMemo(() => {
return componentItems.filter((component) => { let components = componentsByCategory[selectedCategory] || [];
const matchesSearch =
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === "all" || component.category === selectedCategory; if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
components = components.filter(
(component) =>
component.name.toLowerCase().includes(query) ||
component.description.toLowerCase().includes(query) ||
component.tags?.some((tag) => tag.toLowerCase().includes(query)),
);
}
return matchesSearch && matchesCategory; return components;
}); }, [componentsByCategory, selectedCategory, searchQuery]);
}, [componentItems, searchTerm, selectedCategory]);
// 카테고리별 그룹화 // 드래그 시작 핸들러
const groupedComponents = useMemo(() => { const handleDragStart = (e: React.DragEvent, component: ComponentDefinition) => {
const groups: Record<string, ComponentItem[]> = {}; const dragData = {
type: "component",
component: component,
};
console.log("🚀 컴포넌트 드래그 시작:", component.name, dragData);
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = "copy";
};
COMPONENT_CATEGORIES.forEach((category) => { // 카테고리별 아이콘
groups[category.id] = filteredComponents.filter((component) => component.category === category.id); const getCategoryIcon = (category: ComponentCategory | "all") => {
}); switch (category) {
case "input":
return <Grid className="h-4 w-4" />;
case "display":
return <Palette className="h-4 w-4" />;
case "action":
return <Zap className="h-4 w-4" />;
case "layout":
return <Layers className="h-4 w-4" />;
case "utility":
return <Package className="h-4 w-4" />;
default:
return <Package className="h-4 w-4" />;
}
};
return groups; // 컴포넌트 새로고침
}, [filteredComponents]); const handleRefresh = () => {
// Hot Reload 트리거 (개발 모드에서만)
if (loading) { if (process.env.NODE_ENV === "development") {
return ( ComponentRegistry.refreshComponents?.();
<div className="flex h-full items-center justify-center"> }
<div className="text-center"> window.location.reload();
<Layers className="mx-auto h-8 w-8 animate-pulse text-gray-400" /> };
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Layers className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-2 text-sm text-red-500"> </p>
<p className="text-xs text-gray-500">{error.message}</p>
</div>
</div>
);
}
return ( return (
<div className="flex h-full flex-col"> <Card className={className}>
{/* 헤더 */} <CardHeader className="pb-3">
<div className="border-b border-gray-200 p-4"> <CardTitle className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center">
<Layers className="h-4 w-4 text-gray-600" /> <Package className="mr-2 h-5 w-5" />
<h3 className="font-medium text-gray-900"></h3> ({componentsByCategory.all.length})
<Badge variant="secondary" className="text-xs"> </div>
{filteredComponents.length} <Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
</Badge> <RotateCcw className="h-4 w-4" />
</div> </Button>
<p className="mt-1 text-xs text-gray-500"> </p> </CardTitle>
</div>
{/* 검색 및 필터 */} {/* 검색창 */}
<div className="space-y-3 border-b border-gray-200 p-4">
{/* 검색 */}
<div className="relative"> <div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" /> <Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
<Input <Input
placeholder="컴포넌트 검색..." placeholder="컴포넌트 검색..."
value={searchTerm} value={searchQuery}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-9 text-xs" className="pl-8"
/> />
</div> </div>
</CardHeader>
{/* 카테고리 필터 */} <CardContent>
<div className="flex items-center space-x-2"> <Tabs
<Filter className="h-4 w-4 text-gray-400" /> value={selectedCategory}
<Select value={selectedCategory} onValueChange={setSelectedCategory}> onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
<SelectTrigger className="h-8 text-xs"> >
<SelectValue /> {/* 카테고리 탭 (input 카테고리 제외) */}
</SelectTrigger> <TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
<SelectContent> <TabsTrigger value="all" className="flex items-center">
<SelectItem value="all"> </SelectItem> <Package className="mr-1 h-3 w-3" />
{COMPONENT_CATEGORIES.map((category) => (
<SelectItem key={category.id} value={category.id}> </TabsTrigger>
{category.name} <TabsTrigger value="display" className="flex items-center">
</SelectItem> <Palette className="mr-1 h-3 w-3" />
))}
</SelectContent> </TabsTrigger>
</Select> <TabsTrigger value="action" className="flex items-center">
</div> <Zap className="mr-1 h-3 w-3" />
</div>
</TabsTrigger>
<TabsTrigger value="layout" className="flex items-center">
<Layers className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="utility" className="flex items-center">
<Package className="mr-1 h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 컴포넌트 목록 */} {/* 컴포넌트 목록 */}
<div className="flex-1 overflow-y-auto"> <div className="mt-4">
{selectedCategory === "all" ? ( <TabsContent value={selectedCategory} className="space-y-2">
// 카테고리별 그룹 표시 {filteredComponents.length > 0 ? (
<div className="space-y-4 p-4"> <div className="grid max-h-96 grid-cols-1 gap-2 overflow-y-auto">
{COMPONENT_CATEGORIES.map((category) => { {filteredComponents.map((component) => (
const categoryComponents = groupedComponents[category.id]; <div
if (categoryComponents.length === 0) return null; key={component.id}
draggable
onDragStart={(e) => handleDragStart(e, component)}
className="hover:bg-accent flex cursor-grab items-center rounded-lg border p-3 transition-colors active:cursor-grabbing"
title={component.description}
>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between">
<h4 className="truncate text-sm font-medium">{component.name}</h4>
<div className="flex items-center space-x-1">
{/* 카테고리 뱃지 */}
<Badge variant="secondary" className="text-xs">
{getCategoryIcon(component.category)}
<span className="ml-1">{component.category}</span>
</Badge>
return ( {/* 새 컴포넌트 뱃지 */}
<div key={category.id}> <Badge variant="default" className="bg-green-500 text-xs">
<div className="mb-2 flex items-center space-x-2">
<h4 className="text-sm font-medium text-gray-700">{category.name}</h4> </Badge>
<Badge variant="outline" className="text-xs"> </div>
{categoryComponents.length} </div>
</Badge>
</div> <p className="text-muted-foreground truncate text-xs">{component.description}</p>
<p className="mb-3 text-xs text-gray-500">{category.description}</p>
<div className="grid gap-2"> {/* 웹타입 및 크기 정보 */}
{categoryComponents.map((component) => ( <div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} /> <span>: {component.webType}</span>
))} <span>
</div> {component.defaultSize.width}×{component.defaultSize.height}
</span>
</div>
{/* 태그 */}
{component.tags && component.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{component.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{component.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{component.tags.length - 3}
</Badge>
)}
</div>
)}
</div>
</div>
))}
</div> </div>
); ) : (
})} <div className="text-muted-foreground py-8 text-center">
<Package className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="text-sm">
{searchQuery
? `"${searchQuery}"에 대한 검색 결과가 없습니다.`
: "이 카테고리에 컴포넌트가 없습니다."}
</p>
</div>
)}
</TabsContent>
</div> </div>
) : ( </Tabs>
// 선택된 카테고리만 표시
<div className="p-4"> {/* 통계 정보 */}
<div className="grid gap-2"> <div className="mt-4 border-t pt-3">
{filteredComponents.map((component) => ( <div className="grid grid-cols-2 gap-4 text-center">
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} /> <div>
))} <div className="text-lg font-bold text-green-600">{filteredComponents.length}</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div>
<div className="text-lg font-bold text-blue-600">{allComponents.length}</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
</div>
</div>
{/* 개발 정보 (개발 모드에서만) */}
{process.env.NODE_ENV === "development" && (
<div className="mt-4 border-t pt-3">
<div className="text-muted-foreground space-y-1 text-xs">
<div>🔧 </div>
<div> Hot Reload </div>
<div>🛡 </div>
</div> </div>
</div> </div>
)} )}
</CardContent>
{filteredComponents.length === 0 && ( </Card>
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Layers className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
</div>
)}
</div>
</div>
); );
};
// 컴포넌트 카드 컴포넌트
const ComponentCard: React.FC<{
component: ComponentItem;
onDragStart: (e: React.DragEvent, component: ComponentItem) => void;
}> = ({ component, onDragStart }) => {
return (
<div
draggable
onDragStart={(e) => onDragStart(e, component)}
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
{component.icon}
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium text-gray-900">{component.name}</h4>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{component.description}</p>
<div className="mt-2 flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{component.webType}
</Badge>
</div>
</div>
</div>
</div>
);
};
// 웹타입별 아이콘 매핑
function getComponentIcon(webType: string): React.ReactNode {
const iconMap: Record<string, React.ReactNode> = {
text: <span className="text-xs">Aa</span>,
number: <span className="text-xs">123</span>,
date: <span className="text-xs">📅</span>,
select: <span className="text-xs"></span>,
checkbox: <span className="text-xs"></span>,
radio: <span className="text-xs"></span>,
textarea: <span className="text-xs">📝</span>,
file: <span className="text-xs">📎</span>,
button: <span className="text-xs">🔘</span>,
email: <span className="text-xs">📧</span>,
tel: <span className="text-xs">📞</span>,
password: <span className="text-xs">🔒</span>,
code: <span className="text-xs">&lt;&gt;</span>,
entity: <span className="text-xs">🔗</span>,
};
return iconMap[webType] || <span className="text-xs"></span>;
}
// 웹타입별 기본 크기
function getDefaultSize(webType: string): { width: number; height: number } {
const sizeMap: Record<string, { width: number; height: number }> = {
text: { width: 200, height: 36 },
number: { width: 150, height: 36 },
date: { width: 180, height: 36 },
select: { width: 200, height: 36 },
checkbox: { width: 150, height: 36 },
radio: { width: 200, height: 80 },
textarea: { width: 300, height: 100 },
file: { width: 300, height: 120 },
button: { width: 120, height: 36 },
email: { width: 250, height: 36 },
tel: { width: 180, height: 36 },
password: { width: 200, height: 36 },
code: { width: 200, height: 36 },
entity: { width: 200, height: 36 },
};
return sizeMap[webType] || { width: 200, height: 36 };
} }
export default ComponentsPanel; export default ComponentsPanel;

View File

@ -5,10 +5,30 @@ import { Settings } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent"; import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent";
import { ComponentData, WidgetComponent, FileComponent, WebTypeConfig, TableInfo } from "@/types/screen"; import {
import { ButtonConfigPanel } from "./ButtonConfigPanel"; ComponentData,
WidgetComponent,
FileComponent,
WebTypeConfig,
TableInfo,
LayoutComponent,
} from "@/types/screen";
// 레거시 ButtonConfigPanel 제거됨
import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
// 새로운 컴포넌트 설정 패널들 import
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
// 동적 컴포넌트 설정 패널
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
interface DetailSettingsPanelProps { interface DetailSettingsPanelProps {
selectedComponent?: ComponentData; selectedComponent?: ComponentData;
onUpdateProperty: (componentId: string, path: string, value: any) => void; onUpdateProperty: (componentId: string, path: string, value: any) => void;
@ -31,6 +51,641 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
console.log(`🔍 DetailSettingsPanel selectedComponent.widgetType:`, selectedComponent?.widgetType); console.log(`🔍 DetailSettingsPanel selectedComponent.widgetType:`, selectedComponent?.widgetType);
const inputableWebTypes = webTypes.map((wt) => wt.web_type); const inputableWebTypes = webTypes.map((wt) => wt.web_type);
// 레이아웃 컴포넌트 설정 렌더링 함수
const renderLayoutConfig = (layoutComponent: LayoutComponent) => {
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">
{layoutComponent.layoutType}
</span>
</div>
<div className="mt-1 text-xs text-gray-500">ID: {layoutComponent.id}</div>
</div>
{/* 레이아웃 설정 영역 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* 기본 정보 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700"> </label>
<input
type="text"
value={layoutComponent.label || ""}
onChange={(e) => onUpdateProperty(layoutComponent.id, "label", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
placeholder="레이아웃 이름을 입력하세요"
/>
</div>
{/* 그리드 레이아웃 설정 */}
{layoutComponent.layoutType === "grid" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<input
type="number"
min="1"
max="10"
value={layoutComponent.layoutConfig?.grid?.rows || 2}
onChange={(e) => {
const newRows = parseInt(e.target.value);
const newCols = layoutComponent.layoutConfig?.grid?.columns || 2;
// 그리드 설정 업데이트
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.rows", newRows);
// 존 개수 자동 업데이트 (행 × 열)
const totalZones = newRows * newCols;
const currentZones = layoutComponent.zones || [];
if (totalZones !== currentZones.length) {
const newZones = [];
for (let row = 0; row < newRows; row++) {
for (let col = 0; col < newCols; col++) {
const zoneIndex = row * newCols + col;
newZones.push({
id: `zone${zoneIndex + 1}`,
name: `${zoneIndex + 1}`,
position: { row, column: col },
size: { width: "100%", height: "100%" },
});
}
}
onUpdateProperty(layoutComponent.id, "zones", newZones);
}
}}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<input
type="number"
min="1"
max="10"
value={layoutComponent.layoutConfig?.grid?.columns || 2}
onChange={(e) => {
const newCols = parseInt(e.target.value);
const newRows = layoutComponent.layoutConfig?.grid?.rows || 2;
// 그리드 설정 업데이트
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.columns", newCols);
// 존 개수 자동 업데이트 (행 × 열)
const totalZones = newRows * newCols;
const currentZones = layoutComponent.zones || [];
if (totalZones !== currentZones.length) {
const newZones = [];
for (let row = 0; row < newRows; row++) {
for (let col = 0; col < newCols; col++) {
const zoneIndex = row * newCols + col;
newZones.push({
id: `zone${zoneIndex + 1}`,
name: `${zoneIndex + 1}`,
position: { row, column: col },
size: { width: "100%", height: "100%" },
});
}
}
onUpdateProperty(layoutComponent.id, "zones", newZones);
}
}}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> (px)</label>
<input
type="number"
min="0"
max="50"
value={layoutComponent.layoutConfig?.grid?.gap || 16}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.gap", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
)}
{/* 플렉스박스 레이아웃 설정 */}
{layoutComponent.layoutType === "flexbox" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"></label>
<select
value={layoutComponent.layoutConfig?.flexbox?.direction || "row"}
onChange={(e) => {
const newDirection = e.target.value;
console.log("🔄 플렉스박스 방향 변경:", newDirection);
// 방향 설정 업데이트
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.direction", newDirection);
// 방향 변경 시 존 크기 자동 조정
const currentZones = layoutComponent.zones || [];
const zoneCount = currentZones.length;
if (zoneCount > 0) {
const updatedZones = currentZones.map((zone, index) => ({
...zone,
size: {
...zone.size,
width: newDirection === "row" ? `${100 / zoneCount}%` : "100%",
height: newDirection === "column" ? `${100 / zoneCount}%` : "auto",
},
}));
console.log("🔄 존 크기 자동 조정:", {
direction: newDirection,
zoneCount,
updatedZones: updatedZones.map((z) => ({ id: z.id, size: z.size })),
});
onUpdateProperty(layoutComponent.id, "zones", updatedZones);
}
}}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value="row"> (row)</option>
<option value="column"> (column)</option>
<option value="row-reverse"> </option>
<option value="column-reverse"> </option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<div className="flex items-center space-x-2">
<input
type="number"
min="1"
max="10"
value={layoutComponent.zones?.length || 2}
onChange={(e) => {
const newZoneCount = parseInt(e.target.value);
const currentZones = layoutComponent.zones || [];
const direction = layoutComponent.layoutConfig?.flexbox?.direction || "row";
if (newZoneCount > currentZones.length) {
// 존 추가
const newZones = [...currentZones];
for (let i = currentZones.length; i < newZoneCount; i++) {
newZones.push({
id: `zone${i + 1}`,
name: `${i + 1}`,
position: {},
size: {
width: direction === "row" ? `${100 / newZoneCount}%` : "100%",
height: direction === "column" ? `${100 / newZoneCount}%` : "100%",
},
});
}
// 기존 존들의 크기도 조정
newZones.forEach((zone, index) => {
if (direction === "row") {
zone.size.width = `${100 / newZoneCount}%`;
} else {
zone.size.height = `${100 / newZoneCount}%`;
}
});
onUpdateProperty(layoutComponent.id, "zones", newZones);
} else if (newZoneCount < currentZones.length) {
// 존 제거
const newZones = currentZones.slice(0, newZoneCount);
// 남은 존들의 크기 재조정
newZones.forEach((zone, index) => {
if (direction === "row") {
zone.size.width = `${100 / newZoneCount}%`;
} else {
zone.size.height = `${100 / newZoneCount}%`;
}
});
onUpdateProperty(layoutComponent.id, "zones", newZones);
}
}}
className="w-20 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<span className="text-xs text-gray-500"></span>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> (px)</label>
<input
type="number"
min="0"
max="50"
value={layoutComponent.layoutConfig?.flexbox?.gap || 16}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.gap", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
)}
{/* 분할 레이아웃 설정 */}
{layoutComponent.layoutType === "split" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<select
value={layoutComponent.layoutConfig?.split?.direction || "horizontal"}
onChange={(e) => onUpdateProperty(layoutComponent.id, "layoutConfig.split.direction", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value="horizontal"> </option>
<option value="vertical"> </option>
</select>
</div>
</div>
)}
{/* 카드 레이아웃 설정 */}
{layoutComponent.layoutType === "card-layout" && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
{/* 테이블 컬럼 매핑 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-xs font-medium text-gray-700"> </h5>
{currentTable && (
<span className="rounded bg-blue-50 px-2 py-1 text-xs text-blue-600">
: {currentTable.table_name}
</span>
)}
</div>
{/* 테이블이 선택되지 않은 경우 안내 */}
{!currentTable && (
<div className="rounded-lg bg-yellow-50 p-3 text-center">
<p className="text-sm text-yellow-700"> </p>
<p className="mt-1 text-xs text-yellow-600">
</p>
</div>
)}
{/* 테이블이 선택된 경우 컬럼 드롭다운 */}
{currentTable && (
<>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.titleColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.titleColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.subtitleColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.subtitleColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.descriptionColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.descriptionColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.imageColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.imageColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
{/* 동적 표시 컬럼 추가 */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-medium text-gray-600"> </label>
<button
type="button"
onClick={() => {
const currentColumns =
layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || [];
const newColumns = [...currentColumns, ""];
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.displayColumns",
newColumns,
);
}}
className="rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600"
>
+
</button>
</div>
<div className="space-y-2">
{(layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || []).map(
(column, index) => (
<div key={index} className="flex items-center space-x-2">
<select
value={column}
onChange={(e) => {
const currentColumns = [
...(layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || []),
];
currentColumns[index] = e.target.value;
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.displayColumns",
currentColumns,
);
}}
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} ({col.dataType})
</option>
))}
</select>
<button
type="button"
onClick={() => {
const currentColumns = [
...(layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || []),
];
currentColumns.splice(index, 1);
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.displayColumns",
currentColumns,
);
}}
className="rounded bg-red-500 px-2 py-1 text-xs text-white hover:bg-red-600"
>
</button>
</div>
),
)}
{(!layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns ||
layoutComponent.layoutConfig.card.columnMapping.displayColumns.length === 0) && (
<div className="rounded border border-dashed border-gray-300 py-2 text-center text-xs text-gray-500">
"컬럼 추가"
</div>
)}
</div>
</div>
</>
)}
</div>
{/* 카드 스타일 설정 */}
<div className="space-y-3">
<h5 className="text-xs font-medium text-gray-700"> </h5>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<input
type="number"
min="1"
max="6"
value={layoutComponent.layoutConfig?.card?.cardsPerRow || 3}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardsPerRow", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> (px)</label>
<input
type="number"
min="0"
max="50"
value={layoutComponent.layoutConfig?.card?.cardSpacing || 16}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardSpacing", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showTitle"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showTitle ?? true}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardStyle.showTitle", e.target.checked)
}
className="rounded border-gray-300"
/>
<label htmlFor="showTitle" className="text-xs text-gray-600">
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showSubtitle"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showSubtitle ?? true}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.cardStyle.showSubtitle",
e.target.checked,
)
}
className="rounded border-gray-300"
/>
<label htmlFor="showSubtitle" className="text-xs text-gray-600">
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showDescription"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showDescription ?? true}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.cardStyle.showDescription",
e.target.checked,
)
}
className="rounded border-gray-300"
/>
<label htmlFor="showDescription" className="text-xs text-gray-600">
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showImage"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showImage ?? false}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardStyle.showImage", e.target.checked)
}
className="rounded border-gray-300"
/>
<label htmlFor="showImage" className="text-xs text-gray-600">
</label>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<input
type="number"
min="10"
max="500"
value={layoutComponent.layoutConfig?.card?.cardStyle?.maxDescriptionLength || 100}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.cardStyle.maxDescriptionLength",
parseInt(e.target.value),
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
</div>
)}
{/* 존 목록 - 카드 레이아웃은 데이터 기반이므로 존 관리 불필요 */}
{layoutComponent.layoutType !== "card-layout" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="space-y-2">
{layoutComponent.zones?.map((zone, index) => (
<div key={zone.id} className="rounded-lg bg-gray-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{zone.name}</span>
<span className="text-xs text-gray-500">ID: {zone.id}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs text-gray-600"></label>
<input
type="text"
value={zone.size?.width || "100%"}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, `zones.${index}.size.width`, e.target.value)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
placeholder="100%"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600"></label>
<input
type="text"
value={zone.size?.height || "auto"}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, `zones.${index}.size.height`, e.target.value)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
placeholder="auto"
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
// 웹타입별 상세 설정 렌더링 함수 - useCallback 제거하여 항상 최신 widget 사용 // 웹타입별 상세 설정 렌더링 함수 - useCallback 제거하여 항상 최신 widget 사용
const renderWebTypeConfig = (widget: WidgetComponent) => { const renderWebTypeConfig = (widget: WidgetComponent) => {
const currentConfig = widget.webTypeConfig || {}; const currentConfig = widget.webTypeConfig || {};
@ -106,13 +761,138 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
); );
} }
if (selectedComponent.type !== "widget" && selectedComponent.type !== "file" && selectedComponent.type !== "button") { // 컴포넌트 타입별 설정 패널 렌더링
const renderComponentConfigPanel = () => {
console.log("🔍 renderComponentConfigPanel - selectedComponent:", selectedComponent);
if (!selectedComponent) {
console.error("❌ selectedComponent가 undefined입니다!");
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-red-400" />
<h3 className="mb-2 text-lg font-medium text-red-900"></h3>
<p className="text-sm text-red-500"> .</p>
</div>
);
}
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
const handleUpdateProperty = (path: string, value: any) => {
onUpdateProperty(selectedComponent.id, path, value);
};
switch (componentType) {
case "button":
case "button-primary":
case "button-secondary":
return <NewButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "card":
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "dashboard":
return <DashboardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "stats":
case "stats-card":
return <StatsCardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "progress":
case "progress-bar":
return <ProgressBarConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "chart":
case "chart-basic":
return <ChartConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "alert":
case "alert-info":
return <AlertConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "badge":
case "badge-status":
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
default:
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> "{componentType}" .</p>
</div>
);
}
};
// 새로운 컴포넌트 타입들에 대한 설정 패널 확인
const componentType = selectedComponent?.componentConfig?.type || selectedComponent?.type;
console.log("🔍 DetailSettingsPanel componentType 확인:", {
selectedComponentType: selectedComponent?.type,
componentConfigType: selectedComponent?.componentConfig?.type,
finalComponentType: componentType,
});
const hasNewConfigPanel =
componentType &&
[
"button",
"button-primary",
"button-secondary",
"card",
"dashboard",
"stats",
"stats-card",
"progress",
"progress-bar",
"chart",
"chart-basic",
"alert",
"alert-info",
"badge",
"badge-status",
].includes(componentType);
console.log("🔍 hasNewConfigPanel:", hasNewConfigPanel);
if (hasNewConfigPanel) {
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentType}</span>
</div>
</div>
{/* 설정 패널 영역 */}
<div className="flex-1 overflow-y-auto p-4">{renderComponentConfigPanel()}</div>
</div>
);
}
// 레이아웃 컴포넌트 처리
if (selectedComponent.type === "layout") {
return renderLayoutConfig(selectedComponent as LayoutComponent);
}
if (
selectedComponent.type !== "widget" &&
selectedComponent.type !== "file" &&
selectedComponent.type !== "button" &&
selectedComponent.type !== "component"
) {
return ( return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center"> <div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" /> <Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3> <h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
, , . , , , , .
<br /> <br />
: {selectedComponent.type} : {selectedComponent.type}
</p> </p>
@ -152,9 +932,45 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
); );
} }
// 버튼 컴포넌트인 경우 ButtonConfigPanel 렌더링 // 레거시 버튼을 새로운 컴포넌트 시스템으로 강제 변환
if (selectedComponent.type === "button") { if (selectedComponent.type === "button") {
const buttonWidget = selectedComponent as WidgetComponent; console.log("🔄 레거시 버튼을 새로운 컴포넌트 시스템으로 변환:", selectedComponent);
// 레거시 버튼을 새로운 시스템으로 변환
const convertedComponent = {
...selectedComponent,
type: "component" as const,
componentConfig: {
type: "button-primary",
webType: "button",
...selectedComponent.componentConfig,
},
};
// 변환된 컴포넌트로 DB 업데이트
onUpdateProperty(selectedComponent.id, "type", "component");
onUpdateProperty(selectedComponent.id, "componentConfig", convertedComponent.componentConfig);
// 변환된 컴포넌트로 처리 계속
selectedComponent = convertedComponent;
}
// 새로운 컴포넌트 시스템 처리 (type: "component")
if (selectedComponent.type === "component") {
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
const webType = selectedComponent.componentConfig?.webType;
console.log("🔧 새로운 컴포넌트 시스템 설정 패널:", { componentId, webType });
if (!componentId) {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> ID가 </h3>
<p className="text-sm text-gray-500">componentConfig.type이 .</p>
</div>
);
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@ -162,22 +978,35 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
<div className="border-b border-gray-200 p-4"> <div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" /> <Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3> <h3 className="font-medium text-gray-900"> </h3>
</div> </div>
<div className="mt-2 flex items-center space-x-2"> <div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span> <span className="text-sm text-gray-600">:</span>
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800"></span> <span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentId}</span>
</div> </div>
<div className="mt-1 text-xs text-gray-500">: {buttonWidget.label || "버튼"}</div> {webType && (
<div className="mt-1 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">{webType}</span>
</div>
)}
{selectedComponent.columnName && (
<div className="mt-1 text-xs text-gray-500">: {selectedComponent.columnName}</div>
)}
</div> </div>
{/* 버튼 설정 영역 */} {/* 컴포넌트 설정 패널 */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<ButtonConfigPanel <DynamicComponentConfigPanel
component={buttonWidget} componentId={componentId}
onUpdateComponent={(updates) => { config={selectedComponent.componentConfig || {}}
Object.entries(updates).forEach(([key, value]) => { screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
onUpdateProperty(buttonWidget.id, key, value); tableColumns={currentTable?.columns || []}
onChange={(newConfig) => {
console.log("🔧 컴포넌트 설정 변경:", newConfig);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
Object.entries(newConfig).forEach(([key, value]) => {
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
}); });
}} }}
/> />
@ -186,6 +1015,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
); );
} }
// 기존 위젯 시스템 처리 (type: "widget")
const widget = selectedComponent as WidgetComponent; const widget = selectedComponent as WidgetComponent;
return ( return (

View File

@ -0,0 +1,235 @@
"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";
import { calculateGridInfo, calculateWidthFromColumns } from "@/lib/utils/gridUtils";
// 카테고리 아이콘 매핑
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;
gridSettings?: {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
};
screenResolution?: {
width: number;
height: number;
};
}
export default function LayoutsPanel({
onDragStart,
onLayoutSelect,
className,
gridSettings,
screenResolution,
}: 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) => {
// 격자 기반 동적 크기 계산
let calculatedSize = layoutDefinition.defaultSize || { width: 400, height: 300 };
if (gridSettings && screenResolution && layoutDefinition.id === "card-layout") {
// 카드 레이아웃의 경우 8그리드 컬럼에 맞는 너비 계산
const gridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, gridSettings);
const calculatedWidth = calculateWidthFromColumns(8, gridInfo, gridSettings);
calculatedSize = {
width: Math.max(calculatedWidth, 400), // 최소 400px 보장
height: 400, // 높이는 고정
};
console.log("🎯 카드 레이아웃 동적 크기 계산:", {
gridColumns: 8,
screenResolution,
gridSettings,
gridInfo,
calculatedWidth,
finalSize: calculatedSize,
});
}
// 새 레이아웃 컴포넌트 데이터 생성
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: calculatedSize,
label: layoutDefinition.name,
gridColumns: layoutDefinition.id === "card-layout" ? 8 : 1, // 카드 레이아웃은 기본 8그리드
};
// 드래그 데이터 설정
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

@ -191,9 +191,11 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
positionX: currentPosition.x.toString(), positionX: currentPosition.x.toString(),
positionY: currentPosition.y.toString(), positionY: currentPosition.y.toString(),
positionZ: selectedComponent?.position.z?.toString() || "1", positionZ: selectedComponent?.position.z?.toString() || "1",
width: selectedComponent?.size.width?.toString() || "0", width: selectedComponent?.size?.width?.toString() || "0",
height: selectedComponent?.size.height?.toString() || "0", height: selectedComponent?.size?.height?.toString() || "0",
gridColumns: selectedComponent?.gridColumns?.toString() || "1", gridColumns:
selectedComponent?.gridColumns?.toString() ||
(selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout" ? "8" : "1"),
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "", labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px", labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#374151", labelColor: selectedComponent?.style?.labelColor || "#374151",
@ -244,14 +246,18 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
description: area?.description || "", description: area?.description || "",
positionX: currentPos.x.toString(), positionX: currentPos.x.toString(),
positionY: currentPos.y.toString(), positionY: currentPos.y.toString(),
positionZ: selectedComponent.position.z?.toString() || "1", positionZ: selectedComponent?.position?.z?.toString() || "1",
width: selectedComponent.size.width?.toString() || "0", width: selectedComponent?.size?.width?.toString() || "0", // 안전한 접근
height: selectedComponent.size.height?.toString() || "0", height: selectedComponent?.size?.height?.toString() || "0", // 안전한 접근
gridColumns: selectedComponent.gridColumns?.toString() || "1", gridColumns:
labelText: selectedComponent.style?.labelText || selectedComponent.label || "", selectedComponent?.gridColumns?.toString() ||
labelFontSize: selectedComponent.style?.labelFontSize || "12px", (selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout"
labelColor: selectedComponent.style?.labelColor || "#374151", ? "8"
labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px", : "1"),
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#374151",
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: widget?.required || false, required: widget?.required || false,
readonly: widget?.readonly || false, readonly: widget?.readonly || false,
labelDisplay: selectedComponent.style?.labelDisplay !== false, labelDisplay: selectedComponent.style?.labelDisplay !== false,
@ -529,7 +535,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionX: newValue })); setLocalInputs((prev) => ({ ...prev, positionX: newValue }));
onUpdateProperty("position", { ...selectedComponent.position, x: Number(newValue) }); onUpdateProperty("position.x", Number(newValue));
}} }}
className={`mt-1 ${ className={`mt-1 ${
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
@ -559,7 +565,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionY: newValue })); setLocalInputs((prev) => ({ ...prev, positionY: newValue }));
onUpdateProperty("position", { ...selectedComponent.position, y: Number(newValue) }); onUpdateProperty("position.y", Number(newValue));
}} }}
className={`mt-1 ${ className={`mt-1 ${
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
@ -570,39 +576,49 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
/> />
</div> </div>
<div> {/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
<Label htmlFor="width" className="text-sm font-medium"> {selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
<>
</Label> <div>
<Input <Label htmlFor="width" className="text-sm font-medium">
id="width"
type="number" </Label>
value={localInputs.width} <Input
onChange={(e) => { id="width"
const newValue = e.target.value; type="number"
setLocalInputs((prev) => ({ ...prev, width: newValue })); value={localInputs.width}
onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) }); onChange={(e) => {
}} const newValue = e.target.value;
className="mt-1" setLocalInputs((prev) => ({ ...prev, width: newValue }));
/> onUpdateProperty("size.width", Number(newValue));
</div> }}
className="mt-1"
/>
</div>
<div> <div>
<Label htmlFor="height" className="text-sm font-medium"> <Label htmlFor="height" className="text-sm font-medium">
</Label> </Label>
<Input <Input
id="height" id="height"
type="number" type="number"
value={localInputs.height} value={localInputs.height}
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, height: newValue })); setLocalInputs((prev) => ({ ...prev, height: newValue }));
onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) }); onUpdateProperty("size.height", Number(newValue));
}} }}
className="mt-1" className="mt-1"
/> />
</div> </div>
</>
) : (
<div className="col-span-2 rounded-lg bg-blue-50 p-3 text-center">
<p className="text-sm text-blue-600"> </p>
<p className="mt-1 text-xs text-blue-500"> </p>
</div>
)}
<div> <div>
<Label htmlFor="zIndex" className="text-sm font-medium"> <Label htmlFor="zIndex" className="text-sm font-medium">
@ -617,7 +633,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionZ: newValue })); setLocalInputs((prev) => ({ ...prev, positionZ: newValue }));
onUpdateProperty("position", { ...selectedComponent.position, z: Number(newValue) }); onUpdateProperty("position.z", Number(newValue));
}} }}
className="mt-1" className="mt-1"
placeholder="1" placeholder="1"

View File

@ -7,11 +7,11 @@ import { WidgetComponent, TextTypeConfig } from "@/types/screen";
export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => { export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent; const widget = component as WidgetComponent;
const { placeholder, required, style } = widget; const { placeholder, required, style } = widget || {};
const config = widget.webTypeConfig as TextTypeConfig | undefined; const config = widget?.webTypeConfig as TextTypeConfig | undefined;
// 입력 타입에 따른 처리 // 입력 타입에 따른 처리
const isAutoInput = widget.inputType === "auto"; const isAutoInput = widget?.inputType === "auto";
// 자동 값 생성 함수 // 자동 값 생성 함수
const getAutoValue = (autoValueType: string) => { const getAutoValue = (autoValueType: string) => {
@ -63,11 +63,11 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
// 플레이스홀더 처리 // 플레이스홀더 처리
const finalPlaceholder = isAutoInput const finalPlaceholder = isAutoInput
? getAutoPlaceholder(widget.autoValueType || "") ? getAutoPlaceholder(widget?.autoValueType || "")
: placeholder || config?.placeholder || "입력하세요..."; : placeholder || config?.placeholder || "입력하세요...";
// 값 처리 // 값 처리
const finalValue = isAutoInput ? getAutoValue(widget.autoValueType || "") : value || ""; const finalValue = isAutoInput ? getAutoValue(widget?.autoValueType || "") : value || "";
// 사용자가 테두리를 설정했는지 확인 // 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
@ -77,7 +77,7 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
// 웹타입에 따른 input type 결정 // 웹타입에 따른 input type 결정
const getInputType = () => { const getInputType = () => {
switch (widget.widgetType) { switch (widget?.widgetType) {
case "email": case "email":
return "email"; return "email";
case "tel": case "tel":
@ -106,5 +106,3 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
}; };
TextWidget.displayName = "TextWidget"; TextWidget.displayName = "TextWidget";

View File

@ -0,0 +1,182 @@
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface AccordionContextValue {
type: "single" | "multiple";
collapsible?: boolean;
value?: string | string[];
onValueChange?: (value: string | string[]) => void;
}
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
interface AccordionItemContextValue {
value: string;
}
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
interface AccordionProps {
type: "single" | "multiple";
collapsible?: boolean;
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
className?: string;
children: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
}
function Accordion({
type,
collapsible = false,
value: controlledValue,
defaultValue,
onValueChange,
className,
children,
onClick,
...props
}: AccordionProps) {
const [uncontrolledValue, setUncontrolledValue] = React.useState<string | string[]>(
defaultValue || (type === "multiple" ? [] : ""),
);
const value = controlledValue !== undefined ? controlledValue : uncontrolledValue;
const handleValueChange = React.useCallback(
(newValue: string | string[]) => {
if (controlledValue === undefined) {
setUncontrolledValue(newValue);
}
onValueChange?.(newValue);
},
[controlledValue, onValueChange],
);
const contextValue = React.useMemo(
() => ({
type,
collapsible,
value,
onValueChange: handleValueChange,
}),
[type, collapsible, value, handleValueChange],
);
return (
<AccordionContext.Provider value={contextValue}>
<div className={cn("space-y-2", className)} onClick={onClick} {...props}>
{children}
</div>
</AccordionContext.Provider>
);
}
interface AccordionItemProps {
value: string;
className?: string;
children: React.ReactNode;
}
function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
return (
<div className={cn("rounded-md border", className)} data-value={value} {...props}>
{children}
</div>
);
}
interface AccordionTriggerProps {
className?: string;
children: React.ReactNode;
}
function AccordionTrigger({ className, children, ...props }: AccordionTriggerProps) {
const context = React.useContext(AccordionContext);
const parent = React.useContext(AccordionItemContext);
if (!context || !parent) {
throw new Error("AccordionTrigger must be used within AccordionItem");
}
const isOpen =
context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(parent.value)
: context.value === parent.value;
const handleClick = () => {
if (!context.onValueChange) return;
if (context.type === "multiple") {
const currentValue = Array.isArray(context.value) ? context.value : [];
const newValue = isOpen ? currentValue.filter((v) => v !== parent.value) : [...currentValue, parent.value];
context.onValueChange(newValue);
} else {
const newValue = isOpen && context.collapsible ? "" : parent.value;
context.onValueChange(newValue);
}
};
return (
<button
className={cn(
"flex w-full items-center justify-between p-4 text-left font-medium transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
className,
)}
onClick={handleClick}
type="button"
{...props}
>
{children}
<ChevronDownIcon className={cn("h-4 w-4 transition-transform duration-200", isOpen && "rotate-180")} />
</button>
);
}
interface AccordionContentProps {
className?: string;
children: React.ReactNode;
}
function AccordionContent({ className, children, ...props }: AccordionContentProps) {
const context = React.useContext(AccordionContext);
const parent = React.useContext(AccordionItemContext);
if (!context || !parent) {
throw new Error("AccordionContent must be used within AccordionItem");
}
const isOpen =
context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(parent.value)
: context.value === parent.value;
if (!isOpen) return null;
return (
<div className={cn("px-4 pb-4 text-sm text-gray-600", className)} {...props}>
{children}
</div>
);
}
// AccordionItem을 래핑하여 컨텍스트 제공
const AccordionItemWithContext = React.forwardRef<HTMLDivElement, AccordionItemProps>(
({ value, children, ...props }, ref) => {
return (
<AccordionItemContext.Provider value={{ value }}>
<AccordionItem ref={ref} value={value} {...props}>
{children}
</AccordionItem>
</AccordionItemContext.Provider>
);
},
);
AccordionItemWithContext.displayName = "AccordionItem";
export { Accordion, AccordionItemWithContext as AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,216 @@
# 컴포넌트 자동 생성 CLI 가이드
화면 관리 시스템의 컴포넌트를 자동으로 생성하는 CLI 도구 사용법입니다.
## 사용법
```bash
node scripts/create-component.js <컴포넌트이름> <표시이름> <설명> <카테고리> [웹타입]
```
### 파라미터
| 파라미터 | 필수 | 설명 | 예시 |
|---------|-----|------|------|
| 컴포넌트이름 | ✅ | kebab-case 형식의 컴포넌트 ID | `text-input`, `date-picker` |
| 표시이름 | ✅ | 한글 표시명 | `텍스트 입력`, `날짜 선택` |
| 설명 | ✅ | 컴포넌트 설명 | `텍스트를 입력하는 컴포넌트` |
| 카테고리 | ✅ | 컴포넌트 카테고리 | `input`, `display`, `action` |
| 웹타입 | ⭕ | 기본 웹타입 (기본값: text) | `text`, `number`, `button` |
### 카테고리 옵션
| 카테고리 | 설명 | 아이콘 |
|---------|-----|-------|
| `input` | 입력 컴포넌트 | Edit |
| `display` | 표시 컴포넌트 | Eye |
| `action` | 액션/버튼 컴포넌트 | MousePointer |
| `layout` | 레이아웃 컴포넌트 | Layout |
| `form` | 폼 관련 컴포넌트 | FormInput |
| `chart` | 차트 컴포넌트 | BarChart |
| `media` | 미디어 컴포넌트 | Image |
| `navigation` | 네비게이션 컴포넌트 | Menu |
| `feedback` | 피드백 컴포넌트 | Bell |
| `utility` | 유틸리티 컴포넌트 | Settings |
### 웹타입 옵션
| 웹타입 | 설명 | 적용 대상 |
|-------|-----|----------|
| `text` | 텍스트 입력 | 기본 텍스트 필드 |
| `number` | 숫자 입력 | 숫자 전용 필드 |
| `email` | 이메일 입력 | 이메일 검증 필드 |
| `password` | 비밀번호 입력 | 패스워드 필드 |
| `textarea` | 다중행 텍스트 | 텍스트 영역 |
| `select` | 선택박스 | 드롭다운 선택 |
| `button` | 버튼 | 클릭 액션 |
| `checkbox` | 체크박스 | 불린 값 선택 |
| `radio` | 라디오 버튼 | 단일 선택 |
| `date` | 날짜 선택 | 날짜 피커 |
| `file` | 파일 업로드 | 파일 선택 |
## 사용 예시
### 1. 기본 텍스트 입력 컴포넌트
```bash
node scripts/create-component.js text-input "텍스트 입력" "기본 텍스트 입력 컴포넌트" input text
```
### 2. 숫자 입력 컴포넌트
```bash
node scripts/create-component.js number-input "숫자 입력" "숫자만 입력 가능한 컴포넌트" input number
```
### 3. 버튼 컴포넌트
```bash
node scripts/create-component.js action-button "액션 버튼" "사용자 액션을 처리하는 버튼" action button
```
### 4. 차트 컴포넌트
```bash
node scripts/create-component.js bar-chart "막대 차트" "데이터를 막대 그래프로 표시" chart
```
### 5. 이미지 표시 컴포넌트
```bash
node scripts/create-component.js image-viewer "이미지 뷰어" "이미지를 표시하는 컴포넌트" media
```
## 생성되는 파일들
CLI를 실행하면 다음 파일들이 자동으로 생성됩니다:
```
lib/registry/components/[컴포넌트이름]/
├── index.ts # 컴포넌트 정의 및 메타데이터
├── [컴포넌트이름]Component.tsx # 메인 컴포넌트 파일
├── [컴포넌트이름]Renderer.tsx # 자동 등록 렌더러
├── [컴포넌트이름]ConfigPanel.tsx # 설정 패널 UI
├── types.ts # TypeScript 타입 정의
└── README.md # 컴포넌트 문서
```
## 자동 처리되는 작업들
### ✅ 자동 등록
- `lib/registry/components/index.ts`에 import 구문 자동 추가
- 컴포넌트 레지스트리에 자동 등록
- 브라우저에서 즉시 사용 가능
### ✅ 타입 안전성
- TypeScript 인터페이스 자동 생성
- 컴포넌트 설정 타입 정의
- Props 타입 안전성 보장
### ✅ 설정 패널
- 웹타입별 맞춤 설정 UI 자동 생성
- 공통 설정 (disabled, required, readonly) 포함
- 실시간 설정 값 업데이트
### ✅ 문서화
- 자동 생성된 README.md
- 사용법 및 설정 옵션 문서
- 개발자 정보 및 CLI 명령어 기록
## CLI 실행 후 확인사항
### 1. 브라우저에서 확인
```javascript
// 개발자 도구에서 확인
__COMPONENT_REGISTRY__.get("컴포넌트이름")
```
### 2. 컴포넌트 패널에서 테스트
1. 화면 디자이너 열기
2. 컴포넌트 패널에서 새 컴포넌트 확인
3. 드래그앤드롭으로 캔버스에 추가
4. 속성 편집 패널에서 설정 테스트
### 3. 설정 패널 동작 확인
- 속성 변경 시 실시간 반영 여부
- 필수/선택 설정들의 정상 동작
- 웹타입별 특화 설정 확인
## 트러블슈팅
### import 자동 추가 실패
만약 index.ts에 import가 자동 추가되지 않았다면:
```typescript
// lib/registry/components/index.ts에 수동 추가
import "./컴포넌트이름/컴포넌트이름Renderer";
```
### 컴포넌트가 패널에 나타나지 않는 경우
1. 브라우저 새로고침
2. 개발자 도구에서 오류 확인
3. import 구문 확인
4. TypeScript 컴파일 오류 확인
### 설정 패널이 제대로 작동하지 않는 경우
1. 타입 정의 확인 (`types.ts`)
2. ConfigPanel 컴포넌트 확인
3. 웹타입별 설정 로직 확인
## 고급 사용법
### 사용자 정의 옵션
```bash
# 크기 지정
node scripts/create-component.js my-component "내 컴포넌트" "설명" display --size=300x50
# 태그 추가
node scripts/create-component.js my-component "내 컴포넌트" "설명" display --tags=tag1,tag2,tag3
# 작성자 지정
node scripts/create-component.js my-component "내 컴포넌트" "설명" display --author="개발자명"
```
### 생성 후 커스터마이징
1. **컴포넌트 로직 수정**: `[컴포넌트이름]Component.tsx`
2. **설정 패널 확장**: `[컴포넌트이름]ConfigPanel.tsx`
3. **타입 정의 확장**: `types.ts`
4. **렌더러 로직 수정**: `[컴포넌트이름]Renderer.tsx`
## 베스트 프랙티스
### 네이밍 규칙
- **컴포넌트이름**: kebab-case (예: `text-input`, `date-picker`)
- **표시이름**: 명확한 한글명 (예: "텍스트 입력", "날짜 선택")
- **설명**: 구체적이고 명확한 설명
### 카테고리 선택
- 컴포넌트의 주된 용도에 맞는 카테고리 선택
- 일관성 있는 카테고리 분류
- 사용자가 찾기 쉬운 카테고리 구조
### 웹타입 선택
- 컴포넌트의 데이터 타입에 맞는 웹타입 선택
- 기본 동작과 검증 로직 고려
- 확장 가능성 고려
## 결론
이 CLI 도구를 사용하면 화면 관리 시스템에 새로운 컴포넌트를 빠르고 일관성 있게 추가할 수 있습니다. 자동 생성된 템플릿을 기반으로 비즈니스 로직에 집중하여 개발할 수 있습니다.
더 자세한 정보는 [컴포넌트 시스템 가이드](./컴포넌트_시스템_가이드.md)를 참조하세요.

View File

@ -0,0 +1,795 @@
# 화면관리 시스템 컴포넌트 개발 가이드
화면관리 시스템에서 새로운 컴포넌트, 템플릿, 웹타입을 추가하는 완전한 가이드입니다.
## 🎯 목차
1. [컴포넌트 추가하기](#1-컴포넌트-추가하기)
2. [웹타입 추가하기](#2-웹타입-추가하기)
3. [템플릿 추가하기](#3-템플릿-추가하기)
4. [설정 패널 개발](#4-설정-패널-개발)
5. [데이터베이스 설정](#5-데이터베이스-설정)
6. [테스트 및 검증](#6-테스트-및-검증)
---
## 1. 컴포넌트 추가하기
### 1.1 컴포넌트 렌더러 생성
새로운 컴포넌트 렌더러를 생성합니다.
**파일 위치**: `frontend/lib/registry/components/{ComponentName}Renderer.tsx`
```typescript
// 예시: AlertRenderer.tsx
import React from "react";
import { ComponentRenderer } from "../DynamicComponentRenderer";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertTriangle, Info, CheckCircle, XCircle } from "lucide-react";
const AlertRenderer: ComponentRenderer = ({
component,
children,
isInteractive,
...props
}) => {
const config = component.componentConfig || {};
const {
title = "알림",
message = "알림 메시지입니다.",
type = "info", // info, warning, success, error
showIcon = true,
style = {}
} = config;
// 타입별 아이콘 매핑
const iconMap = {
info: Info,
warning: AlertTriangle,
success: CheckCircle,
error: XCircle,
};
const Icon = iconMap[type as keyof typeof iconMap] || Info;
return (
<Alert
className={`h-full w-full ${type === 'error' ? 'border-red-500' : ''}`}
style={style}
>
{showIcon && <Icon className="h-4 w-4" />}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>
{isInteractive ? (
// 실제 할당된 화면에서는 설정된 메시지 표시
message
) : (
// 디자이너에서는 플레이스홀더 + children 표시
children && React.Children.count(children) > 0 ? (
children
) : (
<div className="text-sm">
<div>{message}</div>
<div className="mt-1 text-xs text-gray-400">
알림 컴포넌트 - {type} 타입
</div>
</div>
)
)}
</AlertDescription>
</Alert>
);
};
export default AlertRenderer;
```
### 1.2 컴포넌트 등록
**파일**: `frontend/lib/registry/index.ts`
```typescript
// 컴포넌트 렌더러 import 추가
import AlertRenderer from "./components/AlertRenderer";
// 컴포넌트 레지스트리에 등록
export const registerComponents = () => {
// 기존 컴포넌트들...
ComponentRegistry.register("alert", AlertRenderer);
ComponentRegistry.register("alert-info", AlertRenderer);
ComponentRegistry.register("alert-warning", AlertRenderer);
};
```
### 1.3 InteractiveScreenViewer에 등록
**파일**: `frontend/components/screen/InteractiveScreenViewerDynamic.tsx`
```typescript
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
import "@/lib/registry/components/CardRenderer";
import "@/lib/registry/components/DashboardRenderer";
import "@/lib/registry/components/AlertRenderer"; // 추가
import "@/lib/registry/components/WidgetRenderer";
```
---
## 2. 웹타입 추가하기
### 2.1 웹타입 컴포넌트 생성
**파일 위치**: `frontend/components/screen/widgets/types/{WebTypeName}Widget.tsx`
```typescript
// 예시: ColorPickerWidget.tsx
import React from "react";
import { WebTypeComponentProps } from "@/types/screen";
import { WidgetComponent } from "@/types/screen";
interface ColorPickerConfig {
defaultColor?: string;
showAlpha?: boolean;
presetColors?: string[];
}
export const ColorPickerWidget: React.FC<WebTypeComponentProps> = ({
component,
value,
onChange,
readonly = false
}) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget || {};
const config = widget?.webTypeConfig as ColorPickerConfig | undefined;
const handleColorChange = (color: string) => {
if (!readonly && onChange) {
onChange(color);
}
};
return (
<div className="h-full w-full" style={style}>
{/* 라벨 표시 */}
{widget?.label && (
<label className="mb-1 block text-sm font-medium">
{widget.label}
{required && <span className="text-orange-500">*</span>}
</label>
)}
<div className="flex gap-2 items-center">
{/* 색상 입력 */}
<input
type="color"
value={value || config?.defaultColor || "#000000"}
onChange={(e) => handleColorChange(e.target.value)}
disabled={readonly}
className="h-10 w-16 rounded border border-gray-300 cursor-pointer disabled:cursor-not-allowed"
/>
{/* 색상 값 표시 */}
<input
type="text"
value={value || config?.defaultColor || "#000000"}
onChange={(e) => handleColorChange(e.target.value)}
placeholder={placeholder || "색상을 선택하세요"}
disabled={readonly}
className="flex-1 h-10 px-3 rounded border border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100"
/>
</div>
{/* 미리 설정된 색상들 */}
{config?.presetColors && (
<div className="mt-2 flex gap-1 flex-wrap">
{config.presetColors.map((color, idx) => (
<button
key={idx}
type="button"
onClick={() => handleColorChange(color)}
disabled={readonly}
className="w-6 h-6 rounded border border-gray-300 cursor-pointer hover:scale-110 transition-transform disabled:cursor-not-allowed"
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
)}
</div>
);
};
```
### 2.2 웹타입 등록
**파일**: `frontend/lib/registry/index.ts`
```typescript
// 웹타입 컴포넌트 import 추가
import { ColorPickerWidget } from "@/components/screen/widgets/types/ColorPickerWidget";
// 웹타입 레지스트리에 등록
export const registerWebTypes = () => {
// 기존 웹타입들...
WebTypeRegistry.register("color", ColorPickerWidget);
WebTypeRegistry.register("colorpicker", ColorPickerWidget);
};
```
### 2.3 웹타입 설정 인터페이스 추가
**파일**: `frontend/types/screen.ts`
```typescript
// 웹타입별 설정 인터페이스에 추가
export interface ColorPickerTypeConfig {
defaultColor?: string;
showAlpha?: boolean;
presetColors?: string[];
}
// 전체 웹타입 설정 유니온에 추가
export type WebTypeConfig =
| TextTypeConfig
| NumberTypeConfig
| DateTypeConfig
| SelectTypeConfig
| FileTypeConfig
| ColorPickerTypeConfig; // 추가
```
---
## 3. 템플릿 추가하기
### 3.1 템플릿 컴포넌트 생성
**파일 위치**: `frontend/components/screen/templates/{TemplateName}Template.tsx`
```typescript
// 예시: ContactFormTemplate.tsx
import React from "react";
import { ComponentData } from "@/types/screen";
import { generateComponentId } from "@/lib/utils/componentUtils";
interface ContactFormTemplateProps {
onAddComponents: (components: ComponentData[]) => void;
position: { x: number; y: number };
}
export const ContactFormTemplate: React.FC<ContactFormTemplateProps> = ({
onAddComponents,
position,
}) => {
const createContactForm = () => {
const components: ComponentData[] = [
// 컨테이너
{
id: generateComponentId(),
type: "container",
position: position,
size: { width: 500, height: 600 },
style: {
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "24px",
},
children: [],
},
// 제목
{
id: generateComponentId(),
type: "widget",
webType: "text",
position: { x: position.x + 24, y: position.y + 24 },
size: { width: 452, height: 40 },
label: "연락처 양식",
placeholder: "연락처를 입력해주세요",
style: {
fontSize: "24px",
fontWeight: "bold",
color: "#1f2937",
},
},
// 이름 입력
{
id: generateComponentId(),
type: "widget",
webType: "text",
position: { x: position.x + 24, y: position.y + 84 },
size: { width: 452, height: 40 },
label: "이름",
placeholder: "이름을 입력하세요",
required: true,
},
// 이메일 입력
{
id: generateComponentId(),
type: "widget",
webType: "email",
position: { x: position.x + 24, y: position.y + 144 },
size: { width: 452, height: 40 },
label: "이메일",
placeholder: "이메일을 입력하세요",
required: true,
},
// 전화번호 입력
{
id: generateComponentId(),
type: "widget",
webType: "tel",
position: { x: position.x + 24, y: position.y + 204 },
size: { width: 452, height: 40 },
label: "전화번호",
placeholder: "전화번호를 입력하세요",
},
// 메시지 입력
{
id: generateComponentId(),
type: "widget",
webType: "textarea",
position: { x: position.x + 24, y: position.y + 264 },
size: { width: 452, height: 120 },
label: "메시지",
placeholder: "메시지를 입력하세요",
required: true,
},
// 제출 버튼
{
id: generateComponentId(),
type: "button",
componentType: "button-primary",
position: { x: position.x + 24, y: position.y + 404 },
size: { width: 120, height: 40 },
componentConfig: {
text: "제출",
actionType: "submit",
style: "primary",
},
},
// 취소 버튼
{
id: generateComponentId(),
type: "button",
componentType: "button-secondary",
position: { x: position.x + 164, y: position.y + 404 },
size: { width: 120, height: 40 },
componentConfig: {
text: "취소",
actionType: "cancel",
style: "secondary",
},
},
];
onAddComponents(components);
};
return (
<div
className="cursor-pointer rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4 hover:border-blue-400 hover:bg-blue-50"
onClick={createContactForm}
>
<div className="text-center">
<div className="text-lg font-medium">연락처 양식</div>
<div className="mt-1 text-sm text-gray-600">
이름, 이메일, 전화번호, 메시지 입력이 포함된 연락처 양식
</div>
</div>
</div>
);
};
```
### 3.2 템플릿 패널에 등록
**파일**: `frontend/components/screen/panels/TemplatesPanel.tsx`
```typescript
// 템플릿 import 추가
import { ContactFormTemplate } from "@/components/screen/templates/ContactFormTemplate";
// 템플릿 목록에 추가
const templates = [
// 기존 템플릿들...
{
id: "contact-form",
name: "연락처 양식",
description: "이름, 이메일, 전화번호, 메시지가 포함된 연락처 양식",
component: ContactFormTemplate,
},
];
```
---
## 4. 설정 패널 개발
### 4.1 설정 패널 컴포넌트 생성
**파일 위치**: `frontend/components/screen/config-panels/{ComponentName}ConfigPanel.tsx`
```typescript
// 예시: AlertConfigPanel.tsx
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
interface AlertConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
export const AlertConfigPanel: React.FC<AlertConfigPanelProps> = ({
component,
onUpdateProperty,
}) => {
const config = component.componentConfig || {};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">알림 설정</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 제목 설정 */}
<div>
<Label htmlFor="alert-title" className="text-xs">제목</Label>
<Input
id="alert-title"
value={config.title || ""}
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
placeholder="알림 제목"
className="h-8"
/>
</div>
{/* 메시지 설정 */}
<div>
<Label htmlFor="alert-message" className="text-xs">메시지</Label>
<Textarea
id="alert-message"
value={config.message || ""}
onChange={(e) => onUpdateProperty("componentConfig.message", e.target.value)}
placeholder="알림 메시지를 입력하세요"
className="min-h-[60px]"
/>
</div>
{/* 타입 설정 */}
<div>
<Label className="text-xs">알림 타입</Label>
<Select
value={config.type || "info"}
onValueChange={(value) => onUpdateProperty("componentConfig.type", value)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="info">정보</SelectItem>
<SelectItem value="warning">경고</SelectItem>
<SelectItem value="success">성공</SelectItem>
<SelectItem value="error">오류</SelectItem>
</SelectContent>
</Select>
</div>
{/* 아이콘 표시 설정 */}
<div className="flex items-center justify-between">
<Label htmlFor="show-icon" className="text-xs">아이콘 표시</Label>
<Switch
id="show-icon"
checked={config.showIcon ?? true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showIcon", checked)}
/>
</div>
{/* 스타일 설정 */}
<div>
<Label htmlFor="alert-bg-color" className="text-xs">배경 색상</Label>
<Input
id="alert-bg-color"
type="color"
value={config.style?.backgroundColor || "#ffffff"}
onChange={(e) => onUpdateProperty("componentConfig.style.backgroundColor", e.target.value)}
className="h-8 w-full"
/>
</div>
{/* 테두리 반경 설정 */}
<div>
<Label htmlFor="border-radius" className="text-xs">테두리 반경 (px)</Label>
<Input
id="border-radius"
type="number"
value={parseInt(config.style?.borderRadius || "6") || 6}
onChange={(e) => onUpdateProperty("componentConfig.style.borderRadius", `${e.target.value}px`)}
min="0"
max="50"
className="h-8"
/>
</div>
</CardContent>
</Card>
);
};
```
### 4.2 설정 패널 등록
**파일**: `frontend/components/screen/panels/DetailSettingsPanel.tsx`
```typescript
// 설정 패널 import 추가
import { AlertConfigPanel } from "@/components/screen/config-panels/AlertConfigPanel";
// hasNewConfigPanel 배열에 추가
const hasNewConfigPanel =
componentType &&
[
"button",
"button-primary",
"button-secondary",
"card",
"dashboard",
"alert", // 추가
"alert-info", // 추가
// 기타 컴포넌트들...
].includes(componentType);
// switch 문에 케이스 추가
switch (componentType) {
case "button":
case "button-primary":
case "button-secondary":
return <NewButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "card":
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "dashboard":
return <DashboardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "alert": // 추가
case "alert-info":
return <AlertConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
// 기타 케이스들...
}
```
---
## 5. 데이터베이스 설정
### 5.1 component_standards 테이블에 데이터 추가
```javascript
// backend-node/scripts/add-new-component.js
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addNewComponents() {
// 알림 컴포넌트들 추가
const alertComponents = [
{
component_code: "alert-info",
component_name: "정보 알림",
category: "display",
description: "정보성 내용을 표시하는 알림 컴포넌트",
component_config: {
type: "alert",
config_panel: "AlertConfigPanel",
},
default_size: {
width: 400,
height: 80,
},
icon_name: "info",
},
{
component_code: "alert-warning",
component_name: "경고 알림",
category: "display",
description: "경고 내용을 표시하는 알림 컴포넌트",
component_config: {
type: "alert",
config_panel: "AlertConfigPanel",
},
default_size: {
width: 400,
height: 80,
},
icon_name: "alert-triangle",
},
];
// 웹타입 추가
const webTypes = [
{
component_code: "color-picker",
component_name: "색상 선택기",
category: "input",
description: "색상을 선택할 수 있는 입력 컴포넌트",
component_config: {
type: "widget",
webType: "color",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "palette",
},
];
// 데이터베이스에 삽입
for (const component of [...alertComponents, ...webTypes]) {
await prisma.component_standards.create({
data: component,
});
console.log(`✅ ${component.component_name} 추가 완료`);
}
console.log("🎉 모든 컴포넌트 추가 완료!");
await prisma.$disconnect();
}
addNewComponents().catch(console.error);
```
### 5.2 스크립트 실행
```bash
cd backend-node
node scripts/add-new-component.js
```
---
## 6. 테스트 및 검증
### 6.1 개발 서버 재시작
```bash
# 프론트엔드 재시작
cd frontend
npm run dev
# 백엔드 재시작 (필요시)
cd ../backend-node
npm run dev
```
### 6.2 테스트 체크리스트
#### ✅ 컴포넌트 패널 확인
- [ ] 새 컴포넌트가 컴포넌트 패널에 표시됨
- [ ] 카테고리별로 올바르게 분류됨
- [ ] 아이콘이 올바르게 표시됨
#### ✅ 드래그앤드롭 확인
- [ ] 컴포넌트를 캔버스에 드래그 가능
- [ ] 기본 크기로 올바르게 배치됨
- [ ] 컴포넌트가 텍스트박스가 아닌 실제 형태로 렌더링됨
#### ✅ 속성 편집 확인
- [ ] 컴포넌트 선택 시 속성 패널에 기본 속성 표시
- [ ] 상세 설정 패널이 올바르게 표시됨
- [ ] 설정값 변경이 실시간으로 반영됨
#### ✅ 할당된 화면 확인
- [ ] 화면 저장 후 메뉴에 할당
- [ ] 할당된 화면에서 컴포넌트가 올바르게 표시됨
- [ ] 위치와 크기가 편집기와 동일함
- [ ] 인터랙티브 모드로 올바르게 동작함
### 6.3 문제 해결
#### 컴포넌트가 텍스트박스로 표시되는 경우
1. `DynamicComponentRenderer.tsx`에서 컴포넌트가 등록되었는지 확인
2. `InteractiveScreenViewerDynamic.tsx`에서 import 되었는지 확인
3. 브라우저 콘솔에서 레지스트리 등록 로그 확인
#### 설정 패널이 표시되지 않는 경우
1. `DetailSettingsPanel.tsx``hasNewConfigPanel` 배열 확인
2. switch 문에 케이스가 추가되었는지 확인
3. 데이터베이스의 `config_panel` 값 확인
#### 할당된 화면에서 렌더링 안 되는 경우
1. `InteractiveScreenViewerDynamic.tsx`에서 import 확인
2. 컴포넌트 렌더러에서 `isInteractive` prop 처리 확인
3. 브라우저 콘솔에서 오류 메시지 확인
---
## 🎯 모범 사례
### 1. 컴포넌트 네이밍
- 컴포넌트 코드: `kebab-case` (예: `alert-info`, `contact-form`)
- 파일명: `PascalCase` (예: `AlertRenderer.tsx`, `ContactFormTemplate.tsx`)
- 클래스명: `camelCase` (예: `alertContainer`, `formInput`)
### 2. 설정 구조
```typescript
// 일관된 설정 구조 사용
interface ComponentConfig {
// 기본 설정
title?: string;
description?: string;
// 표시 설정
showIcon?: boolean;
showBorder?: boolean;
// 스타일 설정
style?: {
backgroundColor?: string;
borderRadius?: string;
padding?: string;
};
// 컴포넌트별 전용 설정
[key: string]: any;
}
```
### 3. 반응형 지원
```typescript
// 컨테이너 크기에 따른 반응형 처리
const isSmall = component.size.width < 300;
const columns = isSmall ? 1 : 3;
```
### 4. 접근성 고려
```typescript
// 접근성 속성 추가
<button
aria-label={config.ariaLabel || config.text}
role="button"
tabIndex={0}
>
{config.text}
</button>
```
---
## 📚 참고 자료
- [Shadcn/ui 컴포넌트 문서](https://ui.shadcn.com)
- [Lucide 아이콘 문서](https://lucide.dev)
- [React Hook Form 문서](https://react-hook-form.com)
- [TypeScript 타입 정의 가이드](https://www.typescriptlang.org/docs)
---
이 가이드를 따라 새로운 컴포넌트, 웹타입, 템플릿을 성공적으로 추가할 수 있습니다. 추가 질문이나 문제가 발생하면 언제든지 문의해주세요! 🚀

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 일관성 확보

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { checkComponentDuplicate } from "@/lib/api/componentApi";
export const useComponentDuplicateCheck = (componentCode: string, enabled: boolean = true) => {
return useQuery({
queryKey: ["componentDuplicateCheck", componentCode],
queryFn: async () => {
const result = await checkComponentDuplicate(componentCode);
console.log(`🔍 중복 체크 응답 데이터:`, { componentCode, result, isDuplicate: result.isDuplicate });
return result;
},
enabled: enabled && !!componentCode && componentCode.length > 0,
staleTime: 0, // 항상 최신 데이터 확인
retry: false, // 실패 시 재시도 안함
});
};

View File

@ -1,228 +0,0 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
// 컴포넌트 표준 타입 정의
export interface ComponentStandard {
component_code: string;
component_name: string;
component_name_eng?: string;
description?: string;
category: string;
icon_name?: string;
default_size?: { width: number; height: number };
component_config: any;
preview_image?: string;
sort_order?: number;
is_active?: string;
is_public?: string;
company_code: string;
created_date?: string;
created_by?: string;
updated_date?: string;
updated_by?: string;
}
export interface ComponentQueryParams {
category?: string;
active?: string;
is_public?: string;
search?: string;
sort?: string;
order?: "asc" | "desc";
limit?: number;
offset?: number;
}
export interface ComponentListResponse {
components: ComponentStandard[];
total: number;
limit?: number;
offset?: number;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// API 함수들
const componentApi = {
// 컴포넌트 목록 조회
getComponents: async (params: ComponentQueryParams = {}): Promise<ComponentListResponse> => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
searchParams.append(key, value.toString());
}
});
const response = await apiClient.get<ApiResponse<ComponentListResponse>>(
`/admin/component-standards?${searchParams.toString()}`,
);
return response.data.data;
},
// 컴포넌트 상세 조회
getComponent: async (component_code: string): Promise<ComponentStandard> => {
const response = await apiClient.get<ApiResponse<ComponentStandard>>(
`/admin/component-standards/${component_code}`,
);
return response.data.data;
},
// 컴포넌트 생성
createComponent: async (data: Partial<ComponentStandard>): Promise<ComponentStandard> => {
const response = await apiClient.post<ApiResponse<ComponentStandard>>("/admin/component-standards", data);
return response.data.data;
},
// 컴포넌트 수정
updateComponent: async (component_code: string, data: Partial<ComponentStandard>): Promise<ComponentStandard> => {
const response = await apiClient.put<ApiResponse<ComponentStandard>>(
`/admin/component-standards/${component_code}`,
data,
);
return response.data.data;
},
// 컴포넌트 삭제
deleteComponent: async (component_code: string): Promise<void> => {
await apiClient.delete(`/admin/component-standards/${component_code}`);
},
// 정렬 순서 업데이트
updateSortOrder: async (updates: Array<{ component_code: string; sort_order: number }>): Promise<void> => {
await apiClient.put("/admin/component-standards/sort/order", { updates });
},
// 컴포넌트 복제
duplicateComponent: async (data: {
source_code: string;
new_code: string;
new_name: string;
}): Promise<ComponentStandard> => {
const response = await apiClient.post<ApiResponse<ComponentStandard>>("/admin/component-standards/duplicate", data);
return response.data.data;
},
// 카테고리 목록 조회
getCategories: async (): Promise<string[]> => {
const response = await apiClient.get<ApiResponse<string[]>>("/admin/component-standards/categories");
return response.data.data;
},
// 통계 조회
getStatistics: async (): Promise<{
total: number;
byCategory: Array<{ category: string; count: number }>;
byStatus: Array<{ status: string; count: number }>;
}> => {
const response = await apiClient.get<ApiResponse<any>>("/admin/component-standards/statistics");
return response.data.data;
},
};
// React Query 훅들
export const useComponents = (params: ComponentQueryParams = {}) => {
return useQuery({
queryKey: ["components", params],
queryFn: () => componentApi.getComponents(params),
staleTime: 5 * 60 * 1000, // 5분
});
};
export const useComponent = (component_code: string) => {
return useQuery({
queryKey: ["component", component_code],
queryFn: () => componentApi.getComponent(component_code),
enabled: !!component_code,
});
};
export const useComponentCategories = () => {
return useQuery({
queryKey: ["component-categories"],
queryFn: componentApi.getCategories,
staleTime: 10 * 60 * 1000, // 10분
});
};
export const useComponentStatistics = () => {
return useQuery({
queryKey: ["component-statistics"],
queryFn: componentApi.getStatistics,
staleTime: 2 * 60 * 1000, // 2분
});
};
// Mutation 훅들
export const useCreateComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.createComponent,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-categories"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};
export const useUpdateComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ component_code, data }: { component_code: string; data: Partial<ComponentStandard> }) =>
componentApi.updateComponent(component_code, data),
onSuccess: (data, variables) => {
// 특정 컴포넌트와 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["component", variables.component_code] });
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};
export const useDeleteComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.deleteComponent,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-categories"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};
export const useUpdateSortOrder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.updateSortOrder,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
},
});
};
export const useDuplicateComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.duplicateComponent,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};

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

@ -0,0 +1,462 @@
"use client";
import React from "react";
import { ComponentDefinition, ComponentRendererProps, ComponentConfig } from "@/types/component";
import { ComponentRegistry } from "./ComponentRegistry";
/**
*
*
* AutoRegisteringLayoutRenderer와
*/
export class AutoRegisteringComponentRenderer {
protected props: ComponentRendererProps;
/**
*
*
*/
static componentDefinition: ComponentDefinition;
constructor(props: ComponentRendererProps) {
this.props = props;
}
/**
*
*
*/
render(): React.ReactElement {
throw new Error(`${this.constructor.name}: render() 메서드를 구현해야 합니다. 이는 추상 메서드입니다.`);
}
/**
*
* ,
*/
protected getComponentStyle(): React.CSSProperties {
const { component, isDesignMode = false } = this.props;
const baseStyle: React.CSSProperties = {
position: "absolute",
left: `${component.position?.x || 0}px`,
top: `${component.position?.y || 0}px`,
width: `${component.size?.width || 200}px`,
height: `${component.size?.height || 36}px`,
zIndex: component.position?.z || 1,
...component.style,
};
// 디자인 모드에서 추가 스타일
if (isDesignMode) {
baseStyle.border = "1px dashed #cbd5e1";
baseStyle.borderColor = this.props.isSelected ? "#3b82f6" : "#cbd5e1";
}
return baseStyle;
}
/**
* Props
* HTML
*/
protected getWebTypeProps(): Record<string, any> {
const { component } = this.props;
const baseProps = {
id: component.id,
name: component.id,
value: component.value || "",
disabled: component.readonly || false,
required: component.required || false,
placeholder: component.placeholder || "",
};
switch (component.webType) {
case "text":
return {
...baseProps,
type: "text",
maxLength: component.maxLength,
minLength: component.minLength,
};
case "number":
return {
...baseProps,
type: "number",
min: component.min,
max: component.max,
step: component.step || 1,
};
case "email":
return {
...baseProps,
type: "email",
};
case "password":
return {
...baseProps,
type: "password",
};
case "date":
return {
...baseProps,
type: "date",
min: component.minDate,
max: component.maxDate,
};
case "datetime":
return {
...baseProps,
type: "datetime-local",
min: component.minDate,
max: component.maxDate,
};
case "time":
return {
...baseProps,
type: "time",
};
case "url":
return {
...baseProps,
type: "url",
};
case "tel":
return {
...baseProps,
type: "tel",
};
case "search":
return {
...baseProps,
type: "search",
};
case "textarea":
return {
...baseProps,
rows: component.rows || 3,
cols: component.cols,
wrap: component.wrap || "soft",
};
case "select":
case "dropdown":
return {
...baseProps,
multiple: component.multiple || false,
};
case "checkbox":
return {
...baseProps,
type: "checkbox",
checked: component.checked || false,
};
case "radio":
return {
...baseProps,
type: "radio",
checked: component.checked || false,
};
case "button":
return {
...baseProps,
type: component.buttonType || "button",
};
case "file":
return {
...baseProps,
type: "file",
accept: component.accept,
multiple: component.multiple || false,
};
case "range":
return {
...baseProps,
type: "range",
min: component.min || 0,
max: component.max || 100,
step: component.step || 1,
};
case "color":
return {
...baseProps,
type: "color",
};
default:
return baseProps;
}
}
/**
*
*
*/
protected getLabelStyle(): React.CSSProperties {
const { component } = this.props;
return {
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
marginBottom: component.style?.labelMarginBottom || "4px",
fontWeight: "500",
};
}
/**
*
*/
protected getLabelInfo(): { text: string; isRequired: boolean } | null {
const { component } = this.props;
if (!component.label && !component.style?.labelText) {
return null;
}
return {
text: component.style?.labelText || component.label,
isRequired: component.required || false,
};
}
/**
*
*/
protected getErrorStyle(): React.CSSProperties {
return {
position: "absolute",
top: "100%",
left: "0px",
fontSize: "12px",
color: "#ef4444",
marginTop: "4px",
};
}
/**
*
*/
protected getHelperTextStyle(): React.CSSProperties {
return {
position: "absolute",
top: "100%",
left: "0px",
fontSize: "12px",
color: "#6b7280",
marginTop: "4px",
};
}
/**
*
*
*/
protected getEventHandlers() {
const { onClick, onDragStart, onDragEnd } = this.props;
return {
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
},
onDragStart: (e: React.DragEvent) => {
onDragStart?.(e);
},
onDragEnd: (e: React.DragEvent) => {
onDragEnd?.(e);
},
};
}
/**
*
*/
protected getConfig<T = ComponentConfig>(): T {
const { component } = this.props;
const definition = ComponentRegistry.getComponent(component.componentType);
return {
...definition?.defaultConfig,
...component.config,
} as T;
}
/**
*
*/
protected updateComponent(updates: Partial<any>): void {
this.props.onUpdate?.(updates);
}
/**
*
*/
protected handleValueChange(value: any): void {
this.updateComponent({ value });
}
/**
*
*/
private static registeredComponents = new Set<string>();
/**
*
*
*/
static registerSelf(): void {
const definition = this.componentDefinition;
if (!definition) {
console.error(`${this.name}: componentDefinition이 정의되지 않았습니다.`);
return;
}
if (this.registeredComponents.has(definition.id)) {
console.warn(`⚠️ ${definition.id} 컴포넌트가 이미 등록되어 있습니다.`);
return;
}
try {
// 레지스트리에 등록
ComponentRegistry.registerComponent(definition);
this.registeredComponents.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,
webType: definition.webType,
tags: definition.tags?.join(", ") || "none",
});
}
} catch (error) {
console.error(`${definition.id} 컴포넌트 등록 실패:`, error);
}
}
/**
* ( Hot Reload용)
*/
static unregisterSelf(): void {
const definition = this.componentDefinition;
if (definition && this.registeredComponents.has(definition.id)) {
ComponentRegistry.unregisterComponent(definition.id);
this.registeredComponents.delete(definition.id);
console.log(`🗑️ 컴포넌트 자동 해제: ${definition.id}`);
}
}
/**
* Hot Reload
*/
static enableHotReload(): void {
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
// HMR (Hot Module Replacement) 감지
if ((module as any).hot) {
(module as any).hot.dispose(() => {
this.unregisterSelf();
});
(module as any).hot.accept(() => {
this.registerSelf();
});
}
}
}
/**
*
*/
static validateDefinition(): {
isValid: boolean;
errors: string[];
warnings: string[];
} {
const definition = this.componentDefinition;
if (!definition) {
return {
isValid: false,
errors: ["componentDefinition이 정의되지 않았습니다"],
warnings: [],
};
}
// 기본적인 검증만 수행
const errors: string[] = [];
const warnings: string[] = [];
if (!definition.id) errors.push("id가 필요합니다");
if (!definition.name) errors.push("name이 필요합니다");
if (!definition.category) errors.push("category가 필요합니다");
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
*
*/
static getDebugInfo(): object {
const definition = this.componentDefinition;
return {
className: this.name,
definition: definition || null,
isRegistered: definition ? ComponentRegistry.hasComponent(definition.id) : false,
validation: this.validateDefinition(),
};
}
}
// 클래스가 정의되는 즉시 자동 등록 활성화
// 하위 클래스에서 이 클래스를 상속받으면 자동으로 등록됩니다
if (typeof window !== "undefined") {
// 브라우저 환경에서만 실행
setTimeout(() => {
// 모든 모듈이 로드된 후 등록 실행
const subclasses = Object.getOwnPropertyNames(window)
.map((name) => (window as any)[name])
.filter(
(obj) =>
typeof obj === "function" &&
obj.prototype instanceof AutoRegisteringComponentRenderer &&
obj.componentDefinition,
);
subclasses.forEach((cls) => {
try {
cls.registerSelf();
} catch (error) {
console.error(`컴포넌트 자동 등록 실패: ${cls.name}`, error);
}
});
}, 0);
}

View File

@ -0,0 +1,462 @@
"use client";
import React from "react";
import {
ComponentDefinition,
ComponentCategory,
ComponentRegistryEvent,
ComponentSearchOptions,
ComponentStats,
ComponentAutoDiscoveryOptions,
ComponentDiscoveryResult,
} from "@/types/component";
import type { WebType } from "@/types/screen";
/**
*
* , ,
*
*/
export class ComponentRegistry {
private static components = new Map<string, ComponentDefinition>();
private static eventListeners: Array<(event: ComponentRegistryEvent) => void> = [];
/**
*
*/
static registerComponent(definition: ComponentDefinition): void {
// 유효성 검사
const validation = this.validateComponentDefinition(definition);
if (!validation.isValid) {
throw new Error(`컴포넌트 등록 실패 (${definition.id}): ${validation.errors.join(", ")}`);
}
// 중복 등록 체크
if (this.components.has(definition.id)) {
console.warn(`⚠️ 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`);
}
// 타임스탬프 추가
const enhancedDefinition = {
...definition,
createdAt: definition.createdAt || new Date(),
updatedAt: new Date(),
};
this.components.set(definition.id, enhancedDefinition);
// 이벤트 발생
this.emitEvent({
type: "component_registered",
data: enhancedDefinition,
timestamp: new Date(),
});
console.log(`✅ 컴포넌트 등록: ${definition.id} (${definition.name})`);
// 개발자 도구 등록 (개발 모드에서만)
if (process.env.NODE_ENV === "development") {
this.registerGlobalDevTools();
}
}
/**
*
*/
static unregisterComponent(id: string): void {
const definition = this.components.get(id);
if (!definition) {
console.warn(`⚠️ 등록되지 않은 컴포넌트 해제 시도: ${id}`);
return;
}
this.components.delete(id);
// 이벤트 발생
this.emitEvent({
type: "component_unregistered",
data: definition,
timestamp: new Date(),
});
console.log(`🗑️ 컴포넌트 해제: ${id}`);
}
/**
*
*/
static getComponent(id: string): ComponentDefinition | undefined {
return this.components.get(id);
}
/**
*
*/
static getAllComponents(): ComponentDefinition[] {
return Array.from(this.components.values()).sort((a, b) => {
// 카테고리별 정렬, 그 다음 이름순
if (a.category !== b.category) {
return a.category.localeCompare(b.category);
}
return a.name.localeCompare(b.name);
});
}
/**
*
*/
static getByCategory(category: ComponentCategory): ComponentDefinition[] {
return this.getAllComponents().filter((comp) => comp.category === category);
}
/**
*
*/
static getByWebType(webType: WebType): ComponentDefinition[] {
return this.getAllComponents().filter((comp) => comp.webType === webType);
}
/**
*
*/
static search(options: ComponentSearchOptions = {}): ComponentDefinition[] {
let results = this.getAllComponents();
// 검색어 필터
if (options.query) {
const lowercaseQuery = options.query.toLowerCase();
results = results.filter(
(comp) =>
comp.name.toLowerCase().includes(lowercaseQuery) ||
comp.nameEng?.toLowerCase().includes(lowercaseQuery) ||
comp.description.toLowerCase().includes(lowercaseQuery) ||
comp.tags?.some((tag) => tag.toLowerCase().includes(lowercaseQuery)) ||
comp.id.toLowerCase().includes(lowercaseQuery),
);
}
// 카테고리 필터
if (options.category) {
results = results.filter((comp) => comp.category === options.category);
}
// 웹타입 필터
if (options.webType) {
results = results.filter((comp) => comp.webType === options.webType);
}
// 태그 필터
if (options.tags && options.tags.length > 0) {
results = results.filter((comp) => comp.tags?.some((tag) => options.tags!.includes(tag)));
}
// 작성자 필터
if (options.author) {
results = results.filter((comp) => comp.author === options.author);
}
// 페이징
if (options.offset !== undefined || options.limit !== undefined) {
const start = options.offset || 0;
const end = options.limit ? start + options.limit : undefined;
results = results.slice(start, end);
}
return results;
}
/**
*
*/
static hasComponent(id: string): boolean {
return this.components.has(id);
}
/**
*
*/
static getComponentCount(): number {
return this.components.size;
}
/**
*
*/
static getStats(): ComponentStats {
const components = this.getAllComponents();
// 카테고리별 통계
const categoryMap = new Map<ComponentCategory, number>();
const webTypeMap = new Map<WebType, number>();
const authorMap = new Map<string, number>();
components.forEach((comp) => {
// 카테고리별 집계
categoryMap.set(comp.category, (categoryMap.get(comp.category) || 0) + 1);
// 웹타입별 집계
webTypeMap.set(comp.webType, (webTypeMap.get(comp.webType) || 0) + 1);
// 작성자별 집계
if (comp.author) {
authorMap.set(comp.author, (authorMap.get(comp.author) || 0) + 1);
}
});
// 최근 추가된 컴포넌트 (7개)
const recentlyAdded = components
.filter((comp) => comp.createdAt)
.sort((a, b) => b.createdAt!.getTime() - a.createdAt!.getTime())
.slice(0, 7);
return {
total: components.length,
byCategory: Array.from(categoryMap.entries()).map(([category, count]) => ({
category,
count,
})),
byWebType: Array.from(webTypeMap.entries()).map(([webType, count]) => ({
webType,
count,
})),
byAuthor: Array.from(authorMap.entries()).map(([author, count]) => ({
author,
count,
})),
recentlyAdded,
};
}
/**
*
*/
private static validateComponentDefinition(definition: ComponentDefinition): {
isValid: boolean;
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// 필수 필드 검사
if (!definition.id) errors.push("id는 필수입니다");
if (!definition.name) errors.push("name은 필수입니다");
if (!definition.description) errors.push("description은 필수입니다");
if (!definition.category) errors.push("category는 필수입니다");
if (!definition.webType) errors.push("webType은 필수입니다");
if (!definition.component) errors.push("component는 필수입니다");
if (!definition.defaultSize) errors.push("defaultSize는 필수입니다");
// ID 형식 검사 (kebab-case)
if (definition.id && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(definition.id)) {
errors.push("id는 kebab-case 형식이어야 합니다 (예: button-primary)");
}
// 카테고리 유효성 검사
if (definition.category && !Object.values(ComponentCategory).includes(definition.category)) {
errors.push(`유효하지 않은 카테고리: ${definition.category}`);
}
// 크기 유효성 검사
if (definition.defaultSize) {
if (definition.defaultSize.width <= 0) {
errors.push("defaultSize.width는 0보다 커야 합니다");
}
if (definition.defaultSize.height <= 0) {
errors.push("defaultSize.height는 0보다 커야 합니다");
}
}
// 경고: 권장사항 검사
if (!definition.icon) warnings.push("아이콘이 설정되지 않았습니다");
if (!definition.tags || definition.tags.length === 0) {
warnings.push("검색을 위한 태그가 설정되지 않았습니다");
}
if (!definition.author) warnings.push("작성자가 설정되지 않았습니다");
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
*
*/
static addEventListener(listener: (event: ComponentRegistryEvent) => void): void {
this.eventListeners.push(listener);
}
/**
*
*/
static removeEventListener(listener: (event: ComponentRegistryEvent) => void): void {
const index = this.eventListeners.indexOf(listener);
if (index !== -1) {
this.eventListeners.splice(index, 1);
}
}
/**
*
*/
private static emitEvent(event: ComponentRegistryEvent): void {
this.eventListeners.forEach((listener) => {
try {
listener(event);
} catch (error) {
console.error("컴포넌트 레지스트리 이벤트 리스너 오류:", error);
}
});
}
/**
* ()
*/
static clear(): void {
this.components.clear();
this.eventListeners.length = 0;
console.log("🧹 컴포넌트 레지스트리 초기화 완료");
}
/**
*
*/
private static registerGlobalDevTools(): void {
if (typeof window !== "undefined") {
(window as any).__COMPONENT_REGISTRY__ = {
// 기본 조회 기능
list: () => this.getAllComponents(),
get: (id: string) => this.getComponent(id),
has: (id: string) => this.hasComponent(id),
count: () => this.getComponentCount(),
// 검색 및 필터링
search: (query: string) => this.search({ query }),
byCategory: (category: ComponentCategory) => this.getByCategory(category),
byWebType: (webType: WebType) => this.getByWebType(webType),
// 통계 및 분석
stats: () => this.getStats(),
categories: () => Object.values(ComponentCategory),
webTypes: () => Object.values(WebType),
// 개발자 유틸리티
validate: (definition: ComponentDefinition) => this.validateComponentDefinition(definition),
clear: () => this.clear(),
// Hot Reload 제어
hotReload: {
status: async () => {
try {
const hotReload = await import("../utils/hotReload");
return {
active: hotReload.isHotReloadActive(),
componentCount: this.getComponentCount(),
timestamp: new Date(),
};
} catch (error) {
console.warn("Hot Reload 모듈 로드 실패:", error);
return {
active: false,
componentCount: this.getComponentCount(),
timestamp: new Date(),
error: "Hot Reload 모듈을 로드할 수 없습니다",
};
}
},
force: async () => {
try {
const hotReload = await import("../utils/hotReload");
hotReload.forceReloadComponents();
console.log("✅ 강제 Hot Reload 실행 완료");
} catch (error) {
console.error("❌ 강제 Hot Reload 실행 실패:", error);
}
},
},
// 도움말
help: () => {
console.log(`
🎨
:
__COMPONENT_REGISTRY__.list() -
__COMPONENT_REGISTRY__.get("button-primary") -
__COMPONENT_REGISTRY__.count() -
:
__COMPONENT_REGISTRY__.search("버튼") -
__COMPONENT_REGISTRY__.byCategory("input") -
__COMPONENT_REGISTRY__.byWebType("button") -
:
__COMPONENT_REGISTRY__.stats() -
__COMPONENT_REGISTRY__.categories() -
__COMPONENT_REGISTRY__.webTypes() -
Hot Reload ():
await __COMPONENT_REGISTRY__.hotReload.status() - Hot Reload
await __COMPONENT_REGISTRY__.hotReload.force() -
:
__COMPONENT_REGISTRY__.validate(def) -
__COMPONENT_REGISTRY__.clear() -
__COMPONENT_REGISTRY__.debug() -
__COMPONENT_REGISTRY__.export() - JSON으로
__COMPONENT_REGISTRY__.help() -
💡 :
__COMPONENT_REGISTRY__.search("input")
__COMPONENT_REGISTRY__.byCategory("input")
__COMPONENT_REGISTRY__.get("text-input")
`);
},
};
console.log("🛠️ 컴포넌트 레지스트리 개발자 도구가 등록되었습니다.");
console.log(" 사용법: __COMPONENT_REGISTRY__.help()");
}
}
/**
*
*/
static debug(): void {
const stats = this.getStats();
console.group("🎨 컴포넌트 레지스트리 디버그 정보");
console.log("📊 총 컴포넌트 수:", stats.total);
console.log("📂 카테고리별 분포:", stats.byCategory);
console.log("🏷️ 웹타입별 분포:", stats.byWebType);
console.log("👨‍💻 작성자별 분포:", stats.byAuthor);
console.log(
"🆕 최근 추가:",
stats.recentlyAdded.map((c) => `${c.id} (${c.name})`),
);
console.groupEnd();
}
/**
* JSON으로
*/
static export(): string {
const data = {
timestamp: new Date().toISOString(),
version: "1.0.0",
components: Array.from(this.components.entries()).map(([id, definition]) => ({
id,
definition: {
...definition,
// React 컴포넌트는 직렬화할 수 없으므로 제외
component: definition.component.name,
renderer: definition.renderer?.name,
configPanel: definition.configPanel?.name,
},
})),
};
return JSON.stringify(data, null, 2);
}
}

View File

@ -0,0 +1,252 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
import { ComponentRegistry } from "./ComponentRegistry";
// 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer {
(props: {
component: ComponentData;
isSelected?: boolean;
isInteractive?: boolean;
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
children?: React.ReactNode;
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void;
onZoneClick?: (zoneId: string) => void;
// 버튼 액션을 위한 추가 props
screenId?: number;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
[key: string]: any;
}): React.ReactElement;
}
// 레거시 렌더러 레지스트리 (기존 컴포넌트들용)
class LegacyComponentRegistry {
private renderers: Map<string, ComponentRenderer> = new Map();
// 컴포넌트 렌더러 등록
register(componentType: string, renderer: ComponentRenderer) {
this.renderers.set(componentType, renderer);
console.log(`🔧 레거시 컴포넌트 렌더러 등록: ${componentType}`);
}
// 컴포넌트 렌더러 조회
get(componentType: string): ComponentRenderer | undefined {
return this.renderers.get(componentType);
}
// 등록된 모든 컴포넌트 타입 조회
getRegisteredTypes(): string[] {
return Array.from(this.renderers.keys());
}
// 컴포넌트 타입이 등록되어 있는지 확인
has(componentType: string): boolean {
const result = this.renderers.has(componentType);
console.log(`🔍 LegacyComponentRegistry.has("${componentType}"):`, {
result,
availableKeys: Array.from(this.renderers.keys()),
mapSize: this.renderers.size,
});
return result;
}
}
// 전역 레거시 레지스트리 인스턴스
export const legacyComponentRegistry = new LegacyComponentRegistry();
// 하위 호환성을 위한 기존 이름 유지
export const componentRegistry = legacyComponentRegistry;
// 동적 컴포넌트 렌더러 컴포넌트
export interface DynamicComponentRendererProps {
component: ComponentData;
isSelected?: boolean;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
children?: React.ReactNode;
// 버튼 액션을 위한 추가 props
screenId?: number;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
[key: string]: any;
}
export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> = ({
component,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
children,
...props
}) => {
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
const componentType = (component as any).componentType || component.type;
console.log("🔍 컴포넌트 타입 추출:", {
componentId: component.id,
componentConfigType: component.componentConfig?.type,
componentType: component.type,
componentTypeProp: (component as any).componentType,
finalComponentType: componentType,
componentConfig: component.componentConfig,
propsScreenId: props.screenId,
propsTableName: props.tableName,
});
// 레이아웃 컴포넌트 처리
if (componentType === "layout") {
return (
<DynamicLayoutRenderer
layout={component as any}
allComponents={props.allComponents || []}
isSelected={isSelected}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onUpdateLayout={props.onUpdateLayout}
// onComponentDrop 제거 - 일반 캔버스 드롭만 사용
onZoneClick={props.onZoneClick}
{...props}
/>
);
}
console.log("🎯 DynamicComponentRenderer:", {
componentId: component.id,
componentType,
componentConfig: component.componentConfig,
newSystemRegistered: ComponentRegistry.getAllComponents().map((c) => c.id),
legacySystemRegistered: legacyComponentRegistry.getRegisteredTypes(),
hasLegacyRenderer: legacyComponentRegistry.has(componentType),
actualLegacyRenderer: legacyComponentRegistry.get(componentType),
legacyMapSize: legacyComponentRegistry.getRegisteredTypes().length,
});
// 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType);
console.log("🔍 새 컴포넌트 시스템 조회:", {
componentType,
found: !!newComponent,
component: newComponent,
registeredTypes: ComponentRegistry.getAllComponents().map((c) => c.id),
});
if (newComponent) {
console.log("✨ 새 컴포넌트 시스템에서 발견:", componentType);
// 새 컴포넌트 시스템으로 렌더링
try {
const NewComponentRenderer = newComponent.component;
if (NewComponentRenderer) {
console.log("🔧 컴포넌트 렌더링 props:", {
componentType,
componentId: component.id,
screenId: props.screenId,
tableName: props.tableName,
onRefresh: !!props.onRefresh,
onClose: !!props.onClose,
});
// React 전용 props 필터링
const {
isInteractive,
formData,
onFormDataChange,
selectedScreen,
onZoneComponentDrop,
onZoneClick,
componentConfig,
...safeProps
} = props;
return (
<NewComponentRenderer
{...safeProps}
component={component}
isSelected={isSelected}
onClick={onClick}
isInteractive={isInteractive}
formData={formData}
onFormDataChange={onFormDataChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
size={component.size || newComponent.defaultSize}
position={component.position}
style={component.style}
config={component.componentConfig}
componentConfig={component.componentConfig}
screenId={props.screenId}
tableName={props.tableName}
onRefresh={props.onRefresh}
onClose={props.onClose}
/>
);
}
} catch (error) {
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);
}
}
// 2. 레거시 시스템에서 조회
const renderer = legacyComponentRegistry.get(componentType);
if (!renderer) {
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
component: component,
componentType: componentType,
componentConfig: component.componentConfig,
availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id),
availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(),
});
// 폴백 렌더링 - 기본 플레이스홀더
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">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-gray-600">{component.label || component.id}</div>
<div className="text-xs text-gray-400"> : {componentType}</div>
</div>
</div>
);
}
// 동적 렌더링 실행
try {
return renderer({
component,
isSelected,
onClick,
onDragStart,
onDragEnd,
children,
...props,
});
} catch (error) {
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);
// 오류 발생 시 폴백 렌더링
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-red-600"> </div>
<div className="text-xs text-red-400">
{componentType}: {error instanceof Error ? error.message : "알 수 없는 오류"}
</div>
</div>
</div>
);
}
};
export default DynamicComponentRenderer;

View File

@ -0,0 +1,111 @@
"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) => void;
onComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
onUpdateLayout?: (updatedLayout: LayoutComponent) => void; // 레이아웃 업데이트 콜백
className?: string;
style?: React.CSSProperties;
[key: string]: any; // 추가 props 허용
}
export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
layout,
allComponents,
isDesignMode = false,
isSelected = false,
onClick,
onZoneClick,
onComponentDrop,
onDragStart,
onDragEnd,
onUpdateLayout,
className,
style,
...restProps
}) => {
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 제거
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onUpdateLayout={onUpdateLayout}
className={className}
style={style}
{...restProps}
/>
);
} 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,57 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info, AlertTriangle, CheckCircle, XCircle } from "lucide-react";
// 알림 컴포넌트 렌더러
const AlertRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
title = "알림 제목",
message = "알림 메시지입니다.",
type = "info", // info, warning, success, error
showIcon = true,
style = {},
} = config;
const getAlertIcon = () => {
switch (type) {
case "warning":
return <AlertTriangle className="h-4 w-4" />;
case "success":
return <CheckCircle className="h-4 w-4" />;
case "error":
return <XCircle className="h-4 w-4" />;
default:
return <Info className="h-4 w-4" />;
}
};
const getAlertVariant = () => {
switch (type) {
case "error":
return "destructive";
default:
return "default";
}
};
return (
<div className="flex h-full w-full items-center p-4" style={style}>
<Alert variant={getAlertVariant() as any} className="w-full">
{showIcon && getAlertIcon()}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("alert", AlertRenderer);
componentRegistry.register("alert-info", AlertRenderer);
export { AlertRenderer };

View File

@ -0,0 +1,54 @@
"use client";
import React from "react";
import { ComponentData, AreaComponent, AreaLayoutType } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Square, CreditCard, Layout, Grid3x3, Columns, Rows, SidebarOpen, Folder } from "lucide-react";
// 영역 레이아웃에 따른 아이콘 반환
const getAreaIcon = (layoutType: AreaLayoutType): React.ReactNode => {
const iconMap: Record<AreaLayoutType, React.ReactNode> = {
container: <Square className="h-4 w-4" />,
card: <CreditCard className="h-4 w-4" />,
panel: <Layout className="h-4 w-4" />,
grid: <Grid3x3 className="h-4 w-4" />,
flex_row: <Columns className="h-4 w-4" />,
flex_column: <Rows className="h-4 w-4" />,
sidebar: <SidebarOpen className="h-4 w-4" />,
section: <Folder className="h-4 w-4" />,
};
return iconMap[layoutType] || <Square className="h-4 w-4" />;
};
// 영역 렌더링 함수
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
const area = component as AreaComponent;
const { title, description, layoutType = "container" } = area;
const renderPlaceholder = () => (
<div className="flex h-full flex-col items-center justify-center text-center">
{getAreaIcon(layoutType)}
<div className="mt-2 text-sm font-medium text-gray-600">{title || "영역"}</div>
{description && <div className="mt-1 text-xs text-gray-400">{description}</div>}
<div className="mt-1 text-xs text-gray-400">: {layoutType}</div>
</div>
);
return (
<div className="relative h-full w-full rounded border border-dashed border-gray-300 bg-gray-50 p-2">
<div className="relative h-full w-full">
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
</div>
</div>
);
};
// 영역 컴포넌트 렌더러
const AreaRenderer: ComponentRenderer = ({ component, children, ...props }) => {
return renderArea(component, children);
};
// 레지스트리에 등록
componentRegistry.register("area", AreaRenderer);
export { AreaRenderer };

View File

@ -0,0 +1,33 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Badge } from "@/components/ui/badge";
// 뱃지 컴포넌트 렌더러
const BadgeRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
text = "상태",
variant = "default", // default, secondary, destructive, outline
size = "default",
style = {},
} = config;
const badgeVariant = variant as "default" | "secondary" | "destructive" | "outline";
return (
<div className="flex h-full w-full items-center justify-center" style={style}>
<Badge variant={badgeVariant} className="pointer-events-none">
{text}
</Badge>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("badge", BadgeRenderer);
componentRegistry.register("badge-status", BadgeRenderer);
export { BadgeRenderer };

View File

@ -0,0 +1,51 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
// 브레드크럼 컴포넌트 렌더러
const BreadcrumbRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
items = [{ label: "홈", href: "/" }, { label: "관리자", href: "/admin" }, { label: "현재 페이지" }],
separator = "/",
style = {},
} = config;
return (
<div className="flex h-full w-full items-center p-2" style={style}>
<Breadcrumb>
<BreadcrumbList>
{items.map((item: any, index: number) => (
<React.Fragment key={index}>
<BreadcrumbItem>
{index === items.length - 1 ? (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href || "#"} className="pointer-events-none">
{item.label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{index < items.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("breadcrumb", BreadcrumbRenderer);
export { BreadcrumbRenderer };

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Button } from "@/components/ui/button";
// 버튼 컴포넌트 렌더러
const ButtonRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const { text = "버튼", variant = "default", size = "default", action = "custom", style = {} } = config;
// 버튼 변형 매핑
const variantMap: Record<string, any> = {
primary: "default",
secondary: "secondary",
danger: "destructive",
success: "default",
outline: "outline",
ghost: "ghost",
link: "link",
};
// 크기 매핑
const sizeMap: Record<string, any> = {
small: "sm",
default: "default",
large: "lg",
};
const buttonVariant = variantMap[variant] || "default";
const buttonSize = sizeMap[size] || "default";
return (
<div className="flex h-full w-full items-center justify-center">
<Button variant={buttonVariant} size={buttonSize} style={style} className="pointer-events-none" disabled>
{text}
</Button>
</div>
);
};
// 레지스트리에 등록 - 기본 버튼 타입만 (button-primary는 새 컴포넌트 시스템 사용)
componentRegistry.register("button", ButtonRenderer);
componentRegistry.register("button-secondary", ButtonRenderer);
export { ButtonRenderer };

View File

@ -0,0 +1,61 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
// 카드 컴포넌트 렌더러
const CardRenderer: ComponentRenderer = ({ component, children, isInteractive = false, ...props }) => {
const config = component.componentConfig || {};
const { title = "카드 제목", content = "카드 내용 영역", showHeader = true, showFooter = false, style = {} } = config;
console.log("🃏 CardRenderer 렌더링:", {
componentId: component.id,
isInteractive,
config,
title,
content,
});
return (
<Card className="h-full w-full" style={style}>
{showHeader && (
<CardHeader>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
)}
<CardContent className="flex-1 p-4">
{children && React.Children.count(children) > 0 ? (
children
) : isInteractive ? (
// 실제 할당된 화면에서는 설정된 내용 표시
<div className="flex h-full items-start text-sm text-gray-700">
<div className="w-full">
<div className="mb-2 font-medium">{content}</div>
<div className="text-xs text-gray-500"> .</div>
</div>
</div>
) : (
// 디자이너에서는 플레이스홀더 표시
<div className="flex h-full items-center justify-center text-center">
<div>
<div className="text-sm text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
)}
</CardContent>
{showFooter && (
<CardFooter>
<div className="text-sm text-gray-500"> </div>
</CardFooter>
)}
</Card>
);
};
// 레지스트리에 등록
componentRegistry.register("card", CardRenderer);
export { CardRenderer };

View File

@ -0,0 +1,62 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BarChart3, LineChart, PieChart } from "lucide-react";
// 차트 컴포넌트 렌더러
const ChartRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
title = "차트 제목",
chartType = "bar", // bar, line, pie
data = [],
style = {},
} = config;
const getChartIcon = () => {
switch (chartType) {
case "line":
return <LineChart className="h-8 w-8 text-blue-500" />;
case "pie":
return <PieChart className="h-8 w-8 text-green-500" />;
default:
return <BarChart3 className="h-8 w-8 text-purple-500" />;
}
};
const getChartTypeName = () => {
switch (chartType) {
case "line":
return "라인 차트";
case "pie":
return "파이 차트";
default:
return "바 차트";
}
};
return (
<Card className="h-full w-full" style={style}>
<CardHeader>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 items-center justify-center">
<div className="text-center">
{getChartIcon()}
<div className="mt-2 text-sm text-gray-600">{getChartTypeName()}</div>
<div className="mt-1 text-xs text-gray-400"> </div>
{data.length > 0 && <div className="mt-2 text-xs text-gray-500"> {data.length} </div>}
</div>
</CardContent>
</Card>
);
};
// 레지스트리에 등록
componentRegistry.register("chart", ChartRenderer);
componentRegistry.register("chart-basic", ChartRenderer);
export { ChartRenderer };

View File

@ -0,0 +1,67 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { LayoutGrid } from "lucide-react";
// 대시보드 컴포넌트 렌더러
const DashboardRenderer: ComponentRenderer = ({ component, children, isInteractive = false, ...props }) => {
const config = component.componentConfig || {};
const { columns = 3, gap = 16, items = [], style = {} } = config;
console.log("📊 DashboardRenderer 렌더링:", {
componentId: component.id,
isInteractive,
config,
columns,
gap,
});
return (
<div
className="h-full w-full overflow-hidden p-4"
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: `${gap}px`,
width: "100%",
height: "100%",
boxSizing: "border-box",
...style,
}}
>
{children && React.Children.count(children) > 0
? children
: // 플레이스홀더 그리드 아이템들
Array.from({ length: columns * 2 }).map((_, index) => (
<div
key={index}
className={`flex min-h-0 items-center justify-center rounded p-2 ${
isInteractive
? "border border-gray-200 bg-white shadow-sm"
: "border-2 border-dashed border-gray-300 bg-gray-50"
}`}
style={{
minWidth: 0,
minHeight: "60px",
maxWidth: "100%",
}}
>
<div className="text-center">
<LayoutGrid className={`mx-auto mb-2 h-6 w-6 ${isInteractive ? "text-blue-500" : "text-gray-400"}`} />
<div className={`text-xs ${isInteractive ? "font-medium text-gray-700" : "text-gray-400"}`}>
{index + 1}
</div>
{isInteractive && <div className="mt-1 text-xs text-gray-500"> </div>}
</div>
</div>
))}
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("dashboard", DashboardRenderer);
export { DashboardRenderer };

View File

@ -0,0 +1,43 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
// 데이터 테이블 컴포넌트 렌더러
const DataTableRenderer: ComponentRenderer = ({ component, ...props }) => {
const dataTableComponent = component as any; // DataTableComponent 타입
return (
<DataTableTemplate
title={dataTableComponent.title || dataTableComponent.label}
description={`${dataTableComponent.label}을 표시하는 데이터 테이블`}
columns={dataTableComponent.columns}
filters={dataTableComponent.filters}
pagination={dataTableComponent.pagination}
actions={
dataTableComponent.actions || {
showSearchButton: dataTableComponent.showSearchButton ?? true,
searchButtonText: dataTableComponent.searchButtonText || "검색",
enableExport: dataTableComponent.enableExport ?? true,
enableRefresh: dataTableComponent.enableRefresh ?? true,
enableAdd: dataTableComponent.enableAdd ?? true,
enableEdit: dataTableComponent.enableEdit ?? true,
enableDelete: dataTableComponent.enableDelete ?? true,
addButtonText: dataTableComponent.addButtonText || "추가",
editButtonText: dataTableComponent.editButtonText || "수정",
deleteButtonText: dataTableComponent.deleteButtonText || "삭제",
}
}
style={component.style}
className="h-full w-full"
isPreview={true}
/>
);
};
// 레지스트리에 등록
componentRegistry.register("datatable", DataTableRenderer);
export { DataTableRenderer };

View File

@ -0,0 +1,26 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { File } from "lucide-react";
// 파일 컴포넌트 렌더러
const FileRenderer: ComponentRenderer = ({ component, ...props }) => {
return (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex h-full flex-col items-center justify-center text-center">
<File className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm text-gray-600"> </p>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</div>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("file", FileRenderer);
export { FileRenderer };

View File

@ -0,0 +1,49 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Filter } from "lucide-react";
// 필터 드롭다운 컴포넌트 렌더러
const FilterDropdownRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
label = "필터",
placeholder = "필터를 선택하세요",
options = [
{ label: "전체", value: "all" },
{ label: "활성", value: "active" },
{ label: "비활성", value: "inactive" },
],
showIcon = true,
style = {},
} = config;
return (
<div className="flex h-full w-full items-center gap-2 p-2" style={style}>
{showIcon && <Filter className="h-4 w-4 text-gray-500" />}
<div className="flex-1">
<Select disabled>
<SelectTrigger className="pointer-events-none">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option: any, index: number) => (
<SelectItem key={option.value || index} value={option.value || index.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("filter", FilterDropdownRenderer);
componentRegistry.register("filter-dropdown", FilterDropdownRenderer);
export { FilterDropdownRenderer };

View File

@ -0,0 +1,19 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
// 그룹 컴포넌트 렌더러
const GroupRenderer: ComponentRenderer = ({ component, children, ...props }) => {
return (
<div className="relative h-full w-full">
<div className="absolute inset-0">{children}</div>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("group", GroupRenderer);
export { GroupRenderer };

View File

@ -0,0 +1,41 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Loader2 } from "lucide-react";
// 로딩 스피너 컴포넌트 렌더러
const LoadingRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
text = "로딩 중...",
size = "default", // small, default, large
showText = true,
style = {},
} = config;
const getSizeClass = () => {
switch (size) {
case "small":
return "h-4 w-4";
case "large":
return "h-8 w-8";
default:
return "h-6 w-6";
}
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2" style={style}>
<Loader2 className={`animate-spin text-blue-600 ${getSizeClass()}`} />
{showText && <div className="text-sm text-gray-600">{text}</div>}
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("loading", LoadingRenderer);
componentRegistry.register("loading-spinner", LoadingRenderer);
export { LoadingRenderer };

View File

@ -0,0 +1,89 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
// 페이지네이션 컴포넌트 렌더러
const PaginationRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const { currentPage = 1, totalPages = 10, showPrevNext = true, showEllipsis = true, style = {} } = config;
const generatePageNumbers = () => {
const pages = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (currentPage > 3) {
pages.push("ellipsis1");
}
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push("ellipsis2");
}
pages.push(totalPages);
}
return pages;
};
const pageNumbers = generatePageNumbers();
return (
<div className="flex h-full w-full items-center justify-center" style={style}>
<Pagination>
<PaginationContent>
{showPrevNext && (
<PaginationItem>
<PaginationPrevious href="#" className="pointer-events-none" />
</PaginationItem>
)}
{pageNumbers.map((page, index) => (
<PaginationItem key={index}>
{typeof page === "string" && page.startsWith("ellipsis") ? (
showEllipsis && <PaginationEllipsis />
) : (
<PaginationLink href="#" isActive={page === currentPage} className="pointer-events-none">
{page}
</PaginationLink>
)}
</PaginationItem>
))}
{showPrevNext && (
<PaginationItem>
<PaginationNext href="#" className="pointer-events-none" />
</PaginationItem>
)}
</PaginationContent>
</Pagination>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("pagination", PaginationRenderer);
export { PaginationRenderer };

View File

@ -0,0 +1,57 @@
"use client";
import React, { useState } from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp } from "lucide-react";
// 접을 수 있는 패널 컴포넌트 렌더러
const PanelRenderer: ComponentRenderer = ({ component, children, ...props }) => {
const config = component.componentConfig || {};
const { title = "패널 제목", collapsible = true, defaultExpanded = true, style = {} } = config;
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<Card className="h-full w-full" style={style}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{title}</CardTitle>
{collapsible && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="pointer-events-none h-6 w-6 p-0"
disabled
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
)}
</div>
</CardHeader>
{isExpanded && (
<CardContent className="flex-1">
{children && React.Children.count(children) > 0 ? (
children
) : (
<div className="flex h-full items-center justify-center text-center">
<div>
<div className="text-sm text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
)}
</CardContent>
)}
</Card>
);
};
// 레지스트리에 등록
componentRegistry.register("panel", PanelRenderer);
componentRegistry.register("panel-collapsible", PanelRenderer);
export { PanelRenderer };

View File

@ -0,0 +1,55 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Progress } from "@/components/ui/progress";
// 진행률 바 컴포넌트 렌더러
const ProgressBarRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
label = "진행률",
value = 65,
max = 100,
showPercentage = true,
showValue = true,
color = "#3b82f6",
style = {},
} = config;
const percentage = Math.round((value / max) * 100);
return (
<div className="flex h-full w-full flex-col justify-center p-4" style={style}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{label}</span>
<div className="flex items-center gap-2 text-sm text-gray-600">
{showValue && (
<span>
{value}/{max}
</span>
)}
{showPercentage && <span>({percentage}%)</span>}
</div>
</div>
<Progress
value={percentage}
className="h-2"
style={
{
"--progress-background": color,
} as React.CSSProperties
}
/>
</div>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("progress", ProgressBarRenderer);
componentRegistry.register("progress-bar", ProgressBarRenderer);
export { ProgressBarRenderer };

View File

@ -0,0 +1,34 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
// 검색 박스 컴포넌트 렌더러
const SearchBoxRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const { placeholder = "검색어를 입력하세요...", showButton = true, buttonText = "검색", style = {} } = config;
return (
<div className="flex h-full w-full items-center gap-2 p-2" style={style}>
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input placeholder={placeholder} className="pointer-events-none pl-10" disabled />
</div>
{showButton && (
<Button className="pointer-events-none" disabled>
{buttonText}
</Button>
)}
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("search", SearchBoxRenderer);
componentRegistry.register("search-box", SearchBoxRenderer);
export { SearchBoxRenderer };

View File

@ -0,0 +1,68 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
// 통계 카드 컴포넌트 렌더러
const StatsCardRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
title = "통계 제목",
value = "1,234",
change = "+12.5%",
trend = "up", // up, down, neutral
description = "전월 대비",
style = {},
} = config;
const getTrendIcon = () => {
switch (trend) {
case "up":
return <TrendingUp className="h-4 w-4 text-green-600" />;
case "down":
return <TrendingDown className="h-4 w-4 text-red-600" />;
default:
return <Minus className="h-4 w-4 text-gray-600" />;
}
};
const getTrendColor = () => {
switch (trend) {
case "up":
return "text-green-600";
case "down":
return "text-red-600";
default:
return "text-gray-600";
}
};
return (
<Card className="h-full w-full" style={style}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<div className="text-2xl font-bold">{value}</div>
<div className={`flex items-center gap-1 text-sm ${getTrendColor()}`}>
{getTrendIcon()}
<span>{change}</span>
<span className="text-gray-500">{description}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
};
// 레지스트리에 등록
componentRegistry.register("stats", StatsCardRenderer);
componentRegistry.register("stats-card", StatsCardRenderer);
export { StatsCardRenderer };

View File

@ -0,0 +1,55 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// 탭 컴포넌트 렌더러
const TabsRenderer: ComponentRenderer = ({ component, children, ...props }) => {
const config = component.componentConfig || {};
const {
tabs = [
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
{ id: "tab3", label: "탭 3", content: "세 번째 탭 내용" },
],
defaultTab = "tab1",
orientation = "horizontal", // horizontal, vertical
style = {},
} = config;
return (
<div className="h-full w-full p-2" style={style}>
<Tabs defaultValue={defaultTab} orientation={orientation} className="h-full">
<TabsList className="grid w-full grid-cols-3">
{tabs.map((tab: any) => (
<TabsTrigger key={tab.id} value={tab.id} className="pointer-events-none" disabled>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab: any) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4 flex-1">
{children && React.Children.count(children) > 0 ? (
children
) : (
<div className="flex h-full items-center justify-center rounded border border-dashed border-gray-300 bg-gray-50">
<div className="text-center">
<div className="text-sm text-gray-600">{tab.content}</div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("tabs", TabsRenderer);
componentRegistry.register("tabs-horizontal", TabsRenderer);
export { TabsRenderer };

View File

@ -0,0 +1,80 @@
"use client";
import React from "react";
import { ComponentData, WidgetComponent } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Input } from "@/components/ui/input";
import { DynamicWebTypeRenderer } from "../DynamicWebTypeRenderer";
// 위젯 컴포넌트 렌더러
const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (component.type !== "widget") {
return <div className="text-xs text-gray-500"> </div>;
}
const widget = component as WidgetComponent;
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
// 디버깅: 실제 widgetType 값 확인
console.log("WidgetRenderer - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
const borderClass = hasCustomBorder ? "!border-0" : "";
const commonProps = {
placeholder: placeholder || "입력하세요...",
disabled: readonly,
required: required,
className: `w-full h-full ${borderClass}`,
};
// 동적 웹타입 렌더링 사용
if (widgetType) {
try {
return (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">
<DynamicWebTypeRenderer
webType={widgetType}
props={{
...commonProps,
component: widget,
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
}}
config={widget.webTypeConfig}
/>
</div>
</div>
);
} catch (error) {
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
// 오류 발생 시 폴백으로 기본 input 렌더링
return (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">
<Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />
</div>
</div>
);
}
}
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
return (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">
<Input type="text" {...commonProps} />
</div>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("widget", WidgetRenderer);
export { WidgetRenderer };

View File

@ -0,0 +1,717 @@
"use client";
import React, { useState, useEffect } from "react";
import { ComponentRendererProps } from "../../types";
import { AccordionBasicConfig, AccordionItem, DataSourceConfig, ContentFieldConfig } from "./types";
import { apiClient } from "@/lib/api/client";
// 커스텀 아코디언 컴포넌트
interface CustomAccordionProps {
items: AccordionItem[];
type: "single" | "multiple";
collapsible?: boolean;
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
className?: string;
style?: React.CSSProperties;
onClick?: (e: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
}
const CustomAccordion: React.FC<CustomAccordionProps> = ({
items,
type,
collapsible = true,
defaultValue,
onValueChange,
className = "",
style,
onClick,
onDragStart,
onDragEnd,
}) => {
const [openItems, setOpenItems] = useState<Set<string>>(() => {
if (type === "single") {
return new Set(defaultValue ? [defaultValue as string] : []);
} else {
return new Set(defaultValue ? (defaultValue as string[]) : []);
}
});
const toggleItem = (itemId: string) => {
const newOpenItems = new Set(openItems);
if (type === "single") {
if (openItems.has(itemId)) {
if (collapsible) {
newOpenItems.clear();
}
} else {
newOpenItems.clear();
newOpenItems.add(itemId);
}
} else {
if (openItems.has(itemId)) {
newOpenItems.delete(itemId);
} else {
newOpenItems.add(itemId);
}
}
setOpenItems(newOpenItems);
if (onValueChange) {
if (type === "single") {
onValueChange(newOpenItems.size > 0 ? Array.from(newOpenItems)[0] : "");
} else {
onValueChange(Array.from(newOpenItems));
}
}
};
return (
<div
className={`custom-accordion ${className}`}
style={{
...style,
height: "auto",
minHeight: "0",
}}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{items.map((item, index) => (
<div key={item.id} className="accordion-item">
<button
className="accordion-trigger"
onClick={() => toggleItem(item.id)}
style={{
width: "100%",
padding: "12px 16px",
textAlign: "left",
borderTop: "1px solid #e5e7eb",
borderLeft: "1px solid #e5e7eb",
borderRight: "1px solid #e5e7eb",
borderBottom: openItems.has(item.id) ? "none" : index === items.length - 1 ? "1px solid #e5e7eb" : "none",
backgroundColor: "#f9fafb",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "14px",
fontWeight: "500",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#f3f4f6";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#f9fafb";
}}
>
<span>{item.title}</span>
<span
style={{
transform: openItems.has(item.id) ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
>
</span>
</button>
<div
className="accordion-content"
style={{
maxHeight: openItems.has(item.id) ? "200px" : "0px",
overflow: "hidden",
transition: "max-height 0.3s ease",
borderLeft: openItems.has(item.id) ? "1px solid #e5e7eb" : "none",
borderRight: openItems.has(item.id) ? "1px solid #e5e7eb" : "none",
borderTop: "none",
borderBottom: index === items.length - 1 ? "1px solid #e5e7eb" : "none",
}}
>
<div
style={{
padding: openItems.has(item.id) ? "12px 16px" : "0 16px",
fontSize: "14px",
color: "#6b7280",
transition: "padding 0.3s ease",
whiteSpace: "pre-line", // 줄바꿈 적용
lineHeight: "1.5", // 줄 간격 설정
}}
>
{/* 내용 필드가 배열이거나 복잡한 객체인 경우 처리 */}
{
typeof item.content === "string"
? item.content
: Array.isArray(item.content)
? item.content.join("\n") // 배열인 경우 줄바꿈으로 연결
: typeof item.content === "object"
? Object.entries(item.content)
.map(([key, value]) => `${key}: ${value}`)
.join("\n") // 객체인 경우 키:값 형태로 줄바꿈
: String(item.content) // 기타 타입은 문자열로 변환
}
</div>
</div>
</div>
))}
</div>
);
};
export interface AccordionBasicComponentProps extends ComponentRendererProps {
// 추가 props가 필요한 경우 여기에 정의
}
/**
* AccordionBasic
* accordion-basic
*/
/**
*
*/
const generateDummyTableData = (dataSource: DataSourceConfig, tableColumns?: any[]): AccordionItem[] => {
const limit = dataSource.limit || 5;
const items: AccordionItem[] = [];
for (let i = 0; i < limit; i++) {
// 더미 데이터 행 생성
const dummyRow: any = {};
// 테이블 컬럼을 기반으로 더미 데이터 생성
if (tableColumns && tableColumns.length > 0) {
tableColumns.forEach((column) => {
const fieldName = column.columnName;
// 필드 타입에 따른 더미 데이터 생성
if (fieldName.includes("name") || fieldName.includes("title")) {
dummyRow[fieldName] = `샘플 ${column.columnLabel || fieldName} ${i + 1}`;
} else if (fieldName.includes("price") || fieldName.includes("amount")) {
dummyRow[fieldName] = (Math.random() * 100000).toFixed(0);
} else if (fieldName.includes("date")) {
dummyRow[fieldName] = new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0];
} else if (fieldName.includes("description") || fieldName.includes("content")) {
dummyRow[fieldName] =
`이것은 ${column.columnLabel || fieldName}에 대한 샘플 설명입니다. 항목 ${i + 1}의 상세 정보가 여기에 표시됩니다.`;
} else if (fieldName.includes("id")) {
dummyRow[fieldName] = `sample_${i + 1}`;
} else if (fieldName.includes("status")) {
dummyRow[fieldName] = ["활성", "비활성", "대기", "완료"][Math.floor(Math.random() * 4)];
} else {
dummyRow[fieldName] = `샘플 데이터 ${i + 1}`;
}
});
} else {
// 기본 더미 데이터
dummyRow.id = `sample_${i + 1}`;
dummyRow.title = `샘플 항목 ${i + 1}`;
dummyRow.description = `이것은 샘플 항목 ${i + 1}에 대한 설명입니다.`;
dummyRow.price = (Math.random() * 50000).toFixed(0);
dummyRow.category = ["전자제품", "의류", "도서", "식품"][Math.floor(Math.random() * 4)];
dummyRow.status = ["판매중", "품절", "대기"][Math.floor(Math.random() * 3)];
dummyRow.created_at = new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
}
// 제목 생성
const titleFieldName = dataSource.titleField || "title";
const title = getFieldLabel(dummyRow, titleFieldName, tableColumns) || `샘플 항목 ${i + 1}`;
// 내용 생성
const content = buildContentFromFields(dummyRow, dataSource.contentFields);
// ID 생성
const idFieldName = dataSource.idField || "id";
const id = dummyRow[idFieldName] || `sample_${i + 1}`;
items.push({
id: String(id),
title,
content: content || `샘플 항목 ${i + 1}의 내용입니다.`,
defaultOpen: i === 0,
});
}
return items;
};
/**
*
*/
const buildContentFromFields = (row: any, contentFields?: ContentFieldConfig[]): string => {
if (!contentFields || contentFields.length === 0) {
return row.content || row.description || "내용이 없습니다.";
}
return contentFields
.map((field) => {
const value = row[field.fieldName];
if (!value) return "";
// 라벨이 있으면 "라벨: 값" 형식으로, 없으면 값만
return field.label ? `${field.label}: ${value}` : value;
})
.filter(Boolean) // 빈 값 제거
.join(contentFields[0]?.separator || "\n"); // 구분자로 연결 (기본값: 줄바꿈)
};
/**
* ( , )
*/
const getFieldLabel = (row: any, fieldName: string, tableColumns?: any[]): string => {
// 테이블 컬럼 정보에서 라벨 찾기
if (tableColumns) {
const column = tableColumns.find((col) => col.columnName === fieldName);
if (column && column.columnLabel) {
return column.columnLabel;
}
}
// 데이터에서 라벨 찾기 (예: title_label, name_label 등)
const labelField = `${fieldName}_label`;
if (row[labelField]) {
return row[labelField];
}
// 기본값: 필드명 그대로 또는 데이터 값
return row[fieldName] || fieldName;
};
/**
*
*/
const useAccordionData = (
dataSource?: DataSourceConfig,
isDesignMode: boolean = false,
screenTableName?: string,
tableColumns?: any[],
) => {
const [items, setItems] = useState<AccordionItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!dataSource || dataSource.sourceType === "static") {
// 정적 데이터 소스인 경우 items 사용
return;
}
const fetchData = async () => {
setLoading(true);
setError(null);
try {
if (dataSource.sourceType === "table") {
// 테이블 이름 결정: 화면 테이블 또는 직접 입력한 테이블
const targetTableName = dataSource.useScreenTable ? screenTableName : dataSource.tableName;
console.log("🔍 아코디언 테이블 디버깅:", {
sourceType: dataSource.sourceType,
useScreenTable: dataSource.useScreenTable,
screenTableName,
manualTableName: dataSource.tableName,
targetTableName,
isDesignMode,
tableColumns: tableColumns?.length || 0,
});
if (!targetTableName) {
console.warn("⚠️ 테이블이 지정되지 않음");
console.log("- screenTableName:", screenTableName);
console.log("- dataSource.tableName:", dataSource.tableName);
console.log("- useScreenTable:", dataSource.useScreenTable);
// 실제 화면에서는 에러 메시지 표시, 개발 모드에서만 더미 데이터
if (isDesignMode || process.env.NODE_ENV === "development") {
console.log("🔧 개발 환경: 더미 데이터로 대체");
const dummyData = generateDummyTableData(dataSource, tableColumns);
setItems(dummyData);
} else {
setError("테이블이 설정되지 않았습니다. 설정 패널에서 테이블을 지정해주세요.");
}
return;
}
// 개발 모드이거나 API가 없을 때 더미 데이터 사용
if (isDesignMode) {
console.log("🎨 디자인 모드: 더미 데이터 사용");
const dummyData = generateDummyTableData(dataSource, tableColumns);
setItems(dummyData);
return;
}
console.log(`🌐 실제 API 호출 시도: /api/data/${targetTableName}`);
try {
// 테이블에서 전체 데이터 가져오기 (limit 제거하여 모든 데이터 표시)
const params = new URLSearchParams({
limit: "1000", // 충분히 큰 값으로 설정하여 모든 데이터 가져오기
...(dataSource.orderBy && { orderBy: dataSource.orderBy }),
...(dataSource.filters &&
Object.entries(dataSource.filters).reduce(
(acc, [key, value]) => {
acc[key] = String(value);
return acc;
},
{} as Record<string, string>,
)),
});
const response = await apiClient.get(`/data/${targetTableName}?${params}`);
const data = response.data;
if (data && Array.isArray(data)) {
const accordionItems: AccordionItem[] = data.map((row: any, index: number) => {
// 제목: 라벨이 있으면 라벨 우선, 없으면 필드값
const titleFieldName = dataSource.titleField || "title";
const title = getFieldLabel(row, titleFieldName, tableColumns) || `아이템 ${index + 1}`;
// 내용: 여러 필드 조합 가능
const content = buildContentFromFields(row, dataSource.contentFields);
// ID: 지정된 필드 또는 기본값
const idFieldName = dataSource.idField || "id";
const id = row[idFieldName] || `item-${index}`;
return {
id: String(id),
title,
content,
defaultOpen: index === 0, // 첫 번째 아이템만 기본으로 열림
};
});
setItems(accordionItems);
}
} catch (apiError) {
console.warn("⚠️ 테이블 API 호출 실패, 실제 화면에서도 더미 데이터로 대체:", apiError);
console.log("📊 테이블 API 오류 상세:", {
targetTableName,
error: apiError.message,
dataSource,
timestamp: new Date().toISOString(),
});
// 실제 화면에서도 API 오류 시 더미 데이터로 대체
const dummyData = generateDummyTableData(dataSource, tableColumns);
setItems(dummyData);
// 사용자에게 알림 (에러는 콘솔에만 표시)
console.info("💡 임시로 샘플 데이터를 표시합니다. 백엔드 API 연결을 확인해주세요.");
}
} else if (dataSource.sourceType === "api" && dataSource.apiEndpoint) {
// 개발 모드이거나 API가 없을 때 더미 데이터 사용
if (isDesignMode) {
console.log("🎨 디자인 모드: API 더미 데이터 사용");
const dummyData = generateDummyTableData(dataSource, tableColumns);
setItems(dummyData);
return;
}
try {
// API에서 데이터 가져오기
const response = await fetch(dataSource.apiEndpoint, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data && Array.isArray(data)) {
const accordionItems: AccordionItem[] = data.map((row: any, index: number) => {
// 제목: 라벨이 있으면 라벨 우선, 없으면 필드값
const titleFieldName = dataSource.titleField || "title";
const title = getFieldLabel(row, titleFieldName, tableColumns) || `아이템 ${index + 1}`;
// 내용: 여러 필드 조합 가능
const content = buildContentFromFields(row, dataSource.contentFields);
// ID: 지정된 필드 또는 기본값
const idFieldName = dataSource.idField || "id";
const id = row[idFieldName] || `item-${index}`;
return {
id: String(id),
title,
content,
defaultOpen: index === 0,
};
});
setItems(accordionItems);
}
} catch (apiError) {
console.warn("⚠️ 엔드포인트 API 호출 실패, 실제 화면에서도 더미 데이터로 대체:", apiError);
console.log("📊 엔드포인트 API 오류 상세:", {
apiEndpoint: dataSource.apiEndpoint,
error: apiError.message,
dataSource,
timestamp: new Date().toISOString(),
});
// 실제 화면에서도 API 오류 시 더미 데이터로 대체
const dummyData = generateDummyTableData(dataSource, tableColumns);
setItems(dummyData);
// 사용자에게 알림 (에러는 콘솔에만 표시)
console.info("💡 임시로 샘플 데이터를 표시합니다. 백엔드 API 연결을 확인해주세요.");
}
}
} catch (err) {
console.error("아코디언 데이터 로드 실패:", err);
// 디자인 모드이거나 개발 환경에서는 더미 데이터로 대체
if (isDesignMode || process.env.NODE_ENV === "development") {
console.log("🔧 개발 환경: 더미 데이터로 대체");
const dummyData = dataSource
? generateDummyTableData(dataSource, tableColumns)
: [
{
id: "demo-1",
title: "데모 아이템 1",
content: "이것은 데모용 내용입니다.",
defaultOpen: true,
},
{
id: "demo-2",
title: "데모 아이템 2",
content: "두 번째 데모 아이템의 내용입니다.",
defaultOpen: false,
},
];
setItems(dummyData);
} else {
setError("데이터를 불러오는데 실패했습니다.");
}
} finally {
setLoading(false);
}
};
fetchData();
}, [dataSource, isDesignMode, screenTableName, tableColumns]);
return { items, loading, error };
};
export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
...props
}) => {
const componentConfig = (component.componentConfig || {}) as AccordionBasicConfig;
// 화면 테이블 정보 추출
const screenTableName = (component as any).tableName || props.tableName;
const tableColumns = (component as any).tableColumns || props.tableColumns;
console.log("🔍 아코디언 컴포넌트 테이블 정보:", {
componentTableName: (component as any).tableName,
propsTableName: props.tableName,
finalScreenTableName: screenTableName,
tableColumnsCount: tableColumns?.length || 0,
componentConfig,
dataSource: componentConfig.dataSource,
isDesignMode,
});
// 데이터 소스에서 데이터 가져오기
const {
items: dataItems,
loading,
error,
} = useAccordionData(componentConfig.dataSource, isDesignMode, screenTableName, tableColumns);
// 컴포넌트 스타일 계산
const componentStyle: React.CSSProperties = {
position: "absolute",
left: `${component.style?.positionX || 0}px`,
top: `${component.style?.positionY || 0}px`,
width: `${component.size?.width || 300}px`,
height: `${component.size?.height || 200}px`,
zIndex: component.style?.positionZ || 1,
cursor: isDesignMode ? "pointer" : "default",
border: isSelected ? "2px solid #3b82f6" : "none",
outline: isSelected ? "none" : undefined,
};
// 디버깅용 로그
if (isDesignMode) {
console.log("🎯 Accordion 높이 디버깅:", {
componentSizeHeight: component.size?.height,
componentStyleHeight: component.style?.height,
finalHeight: componentStyle.height,
});
}
// 클릭 핸들러
const handleClick = (e: React.MouseEvent) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
}
};
// className 생성
const className = [
"accordion-basic-component",
isSelected ? "selected" : "",
componentConfig.disabled ? "disabled" : "",
]
.filter(Boolean)
.join(" ");
// DOM props 필터링 (React 관련 props 제거)
const {
component: _component,
isDesignMode: _isDesignMode,
isSelected: _isSelected,
isInteractive: _isInteractive,
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
formData: _formData,
onFormDataChange: _onFormDataChange,
componentConfig: _componentConfig,
...domProps
} = props;
// 사용할 아이템들 결정 (우선순위: 데이터소스 > 정적아이템 > 기본아이템)
const finalItems = (() => {
// 데이터 소스가 설정되어 있고 데이터가 있으면 데이터 소스 아이템 사용
if (componentConfig.dataSource && componentConfig.dataSource.sourceType !== "static" && dataItems.length > 0) {
return dataItems;
}
// 정적 아이템이 설정되어 있으면 사용
if (componentConfig.items && componentConfig.items.length > 0) {
return componentConfig.items;
}
// 기본 아이템들 (데모용)
return [
{
id: "item-1",
title: "제품 정보",
content:
"우리의 주력 제품은 최첨단 기술과 세련된 디자인을 결합합니다. 프리미엄 소재로 제작되어 탁월한 성능과 신뢰성을 제공합니다.",
defaultOpen: true,
},
{
id: "item-2",
title: "배송 정보",
content:
"신뢰할 수 있는 택배 파트너를 통해 전 세계 배송을 제공합니다. 일반 배송은 3-5 영업일, 특급 배송은 1-2 영업일 내 배송됩니다.",
},
{
id: "item-3",
title: "반품 정책",
content:
"포괄적인 30일 반품 정책으로 제품을 보장합니다. 완전히 만족하지 않으시면 원래 상태로 제품을 반품하시면 됩니다.",
},
];
})();
const items = finalItems;
const accordionType = componentConfig.type || "single";
const collapsible = componentConfig.collapsible !== false;
const defaultValue = componentConfig.defaultValue || items.find((item) => item.defaultOpen)?.id;
// 값 변경 핸들러
const handleValueChange = (value: string | string[]) => {
if (!isDesignMode && componentConfig.onValueChange) {
componentConfig.onValueChange(value);
}
};
return (
<div
style={{
...componentStyle,
position: "relative",
height: componentStyle.height, // 명시적 높이 설정
maxHeight: componentStyle.height, // 최대 높이 제한
overflow: "visible", // 자식 요소에서 스크롤 처리
flex: "none", // flex 비활성화
display: "block",
}}
className={className}
{...domProps}
>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: "500",
}}
>
{component.label}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
</label>
)}
{loading ? (
<div className="flex h-full w-full items-center justify-center">
<div className="text-sm text-gray-500"> ...</div>
</div>
) : error && !isDesignMode ? (
<div className="flex h-full w-full items-center justify-center">
<div className="text-sm text-red-500">{error}</div>
</div>
) : (
<div
style={{
height: "100%",
width: "100%",
overflow: "auto",
position: "absolute",
top: "0",
left: "0",
right: "0",
bottom: "0",
}}
>
<CustomAccordion
items={items}
type={accordionType}
collapsible={collapsible}
defaultValue={defaultValue}
onValueChange={handleValueChange}
className="w-full"
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
</div>
)}
</div>
);
};
/**
* AccordionBasic
*
*/
export const AccordionBasicWrapper: React.FC<AccordionBasicComponentProps> = (props) => {
return <AccordionBasicComponent {...props} />;
};

View File

@ -0,0 +1,533 @@
"use client";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Trash2, Plus } from "lucide-react";
import { AccordionBasicConfig, AccordionItem, DataSourceConfig, ContentFieldConfig } from "./types";
export interface AccordionBasicConfigPanelProps {
config: AccordionBasicConfig;
onChange: (config: Partial<AccordionBasicConfig>) => void;
screenTableName?: string; // 화면에서 지정한 테이블명
tableColumns?: any[]; // 테이블 컬럼 정보
}
/**
* AccordionBasic
* UI
*/
export const AccordionBasicConfigPanel: React.FC<AccordionBasicConfigPanelProps> = ({
config,
onChange,
screenTableName,
tableColumns,
}) => {
const [localItems, setLocalItems] = useState<AccordionItem[]>(
config.items || [
{
id: "item-1",
title: "제품 정보",
content: "우리의 주력 제품은 최첨단 기술과 세련된 디자인을 결합합니다.",
defaultOpen: true,
},
],
);
const handleChange = (key: keyof AccordionBasicConfig, value: any) => {
onChange({ [key]: value });
};
const handleItemsChange = (newItems: AccordionItem[]) => {
setLocalItems(newItems);
handleChange("items", newItems);
};
const addItem = () => {
const newItem: AccordionItem = {
id: `item-${Date.now()}`,
title: "새 아이템",
content: "새 아이템의 내용을 입력하세요.",
defaultOpen: false,
};
handleItemsChange([...localItems, newItem]);
};
const removeItem = (itemId: string) => {
handleItemsChange(localItems.filter((item) => item.id !== itemId));
};
const updateItem = (itemId: string, updates: Partial<AccordionItem>) => {
handleItemsChange(localItems.map((item) => (item.id === itemId ? { ...item, ...updates } : item)));
};
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 데이터 소스 설정 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 데이터 소스 타입 */}
<div className="space-y-2">
<Label htmlFor="sourceType"> </Label>
<Select
value={config.dataSource?.sourceType || "static"}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
sourceType: value as "static" | "table" | "api",
})
}
>
<SelectTrigger>
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> ( )</SelectItem>
<SelectItem value="table"> </SelectItem>
<SelectItem value="api">API </SelectItem>
</SelectContent>
</Select>
</div>
{/* 테이블 데이터 설정 */}
{config.dataSource?.sourceType === "table" && (
<>
{/* 테이블 선택 방식 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="useScreenTable"
checked={config.dataSource?.useScreenTable !== false}
onCheckedChange={(checked) =>
handleChange("dataSource", {
...config.dataSource,
useScreenTable: checked as boolean,
})
}
/>
<Label htmlFor="useScreenTable" className="text-sm">
{screenTableName && `(${screenTableName})`}
</Label>
</div>
</div>
{/* 직접 테이블명 입력 (화면 테이블을 사용하지 않을 때) */}
{config.dataSource?.useScreenTable === false && (
<div className="space-y-2">
<Label htmlFor="tableName"></Label>
<Input
id="tableName"
value={config.dataSource?.tableName || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
tableName: e.target.value,
})
}
placeholder="테이블명을 입력하세요"
/>
</div>
)}
{/* 필드 선택 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="titleField"> </Label>
<Select
value={config.dataSource?.titleField || ""}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
titleField: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="제목 필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="idField">ID </Label>
<Select
value={config.dataSource?.idField || ""}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
idField: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="ID 필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 내용 필드들 (여러개 가능) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> </Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newContentFields = [
...(config.dataSource?.contentFields || []),
{ fieldName: "", label: "", separator: "\n" },
];
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
className="h-8"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
{config.dataSource?.contentFields?.map((field, index) => (
<Card key={index} className="p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm"> {index + 1}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newContentFields =
config.dataSource?.contentFields?.filter((_, i) => i !== index) || [];
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
className="h-8 w-8 p-0 text-red-500"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Select
value={field.fieldName}
onValueChange={(value) => {
const newContentFields = [...(config.dataSource?.contentFields || [])];
newContentFields[index] = { ...field, fieldName: value };
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> ()</Label>
<Input
value={field.label || ""}
onChange={(e) => {
const newContentFields = [...(config.dataSource?.contentFields || [])];
newContentFields[index] = { ...field, label: e.target.value };
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
placeholder="예: 설명"
className="text-xs"
/>
</div>
</div>
</div>
</Card>
))}
{(!config.dataSource?.contentFields || config.dataSource.contentFields.length === 0) && (
<div className="py-4 text-center text-sm text-gray-500"> </div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="orderBy"> </Label>
<Select
value={config.dataSource?.orderBy || ""}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
orderBy: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="정렬 필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={`${column.columnName}_asc`} value={`${column.columnName} ASC`}>
{column.columnLabel || column.columnName} ()
</SelectItem>
))}
{tableColumns?.map((column) => (
<SelectItem key={`${column.columnName}_desc`} value={`${column.columnName} DESC`}>
{column.columnLabel || column.columnName} ()
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-muted-foreground rounded-lg bg-blue-50 p-3 text-sm">
💡 <strong> :</strong> ,
.
</div>
</>
)}
{/* API 데이터 설정 */}
{config.dataSource?.sourceType === "api" && (
<>
<div className="space-y-2">
<Label htmlFor="apiEndpoint">API </Label>
<Input
id="apiEndpoint"
value={config.dataSource?.apiEndpoint || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
apiEndpoint: e.target.value,
})
}
placeholder="/api/data/accordion-items"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="titleField"> </Label>
<Input
id="titleField"
value={config.dataSource?.titleField || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
titleField: e.target.value,
})
}
placeholder="title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contentField"> </Label>
<Input
id="contentField"
value={config.dataSource?.contentField || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
contentField: e.target.value,
})
}
placeholder="content"
/>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 기본 설정 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 타입 설정 */}
<div className="space-y-2">
<Label htmlFor="type"> </Label>
<Select
value={config.type || "single"}
onValueChange={(value) => handleChange("type", value as "single" | "multiple")}
>
<SelectTrigger>
<SelectValue placeholder="선택 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="single"> </SelectItem>
<SelectItem value="multiple"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 접을 수 있는지 설정 */}
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible !== false}
onCheckedChange={(checked) => handleChange("collapsible", checked)}
/>
<Label htmlFor="collapsible"> </Label>
</div>
{/* 기본값 설정 */}
<div className="space-y-2">
<Label htmlFor="defaultValue"> </Label>
<Select
value={config.defaultValue || "none"}
onValueChange={(value) => handleChange("defaultValue", value === "none" ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="기본으로 열린 아이템 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{localItems.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비활성화 */}
<div className="flex items-center space-x-2">
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
<Label htmlFor="disabled"></Label>
</div>
</CardContent>
</Card>
{/* 아이템 관리 (정적 데이터일 때만 표시) */}
{(!config.dataSource || config.dataSource.sourceType === "static") && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm">
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-8">
<Plus className="mr-1 h-4 w-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{localItems.map((item, index) => (
<Card key={item.id} className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"> {index + 1}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 제목 */}
<div className="space-y-1">
<Label htmlFor={`title-${item.id}`} className="text-xs">
</Label>
<Input
id={`title-${item.id}`}
value={item.title}
onChange={(e) => updateItem(item.id, { title: e.target.value })}
placeholder="아이템 제목"
/>
</div>
{/* 내용 */}
<div className="space-y-1">
<Label htmlFor={`content-${item.id}`} className="text-xs">
</Label>
<Textarea
id={`content-${item.id}`}
value={item.content}
onChange={(e) => updateItem(item.id, { content: e.target.value })}
placeholder="아이템 내용"
rows={3}
/>
</div>
{/* 기본으로 열림 */}
<div className="flex items-center space-x-2">
<Checkbox
id={`defaultOpen-${item.id}`}
checked={item.defaultOpen || false}
onCheckedChange={(checked) => updateItem(item.id, { defaultOpen: checked as boolean })}
/>
<Label htmlFor={`defaultOpen-${item.id}`} className="text-xs">
</Label>
</div>
</div>
</Card>
))}
{localItems.length === 0 && (
<div className="py-8 text-center text-gray-500">
<p className="text-sm"> .</p>
<p className="text-xs"> .</p>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
};

View File

@ -0,0 +1,28 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AccordionBasicDefinition } from "./index";
/**
* AccordionBasic
*
*/
export class AccordionBasicRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = AccordionBasicDefinition;
constructor(props: any) {
super(props);
}
render(): React.ReactElement {
const { component, ...restProps } = this.props;
return React.createElement(AccordionBasicDefinition.component, {
component,
...restProps,
});
}
}
// 렌더러 인스턴스 생성 및 자동 등록
AccordionBasicRenderer.registerSelf();

View File

@ -0,0 +1,74 @@
# AccordionBasic 컴포넌트
접을 수 있는 콘텐츠 섹션을 제공하는 아코디언 컴포넌트입니다.
## 컴포넌트 정보
- **ID**: `accordion-basic`
- **카테고리**: `display`
- **웹타입**: `text`
- **기본 크기**: 300x200
## 주요 기능
- **다중 아이템 지원**: 여러 개의 접을 수 있는 섹션 제공
- **단일/다중 선택**: 한 번에 하나만 열거나 여러 개를 동시에 열 수 있음
- **기본값 설정**: 초기에 열려있을 아이템 지정 가능
- **완전 접기**: 모든 아이템을 닫을 수 있는 옵션
- **동적 아이템 관리**: 상세설정에서 아이템 추가/삭제/편집 가능
## 설정 옵션
### 기본 설정
- `type`: 선택 타입 ("single" | "multiple")
- `collapsible`: 모든 아이템 접기 가능 여부
- `defaultValue`: 기본으로 열린 아이템 ID
- `disabled`: 비활성화 상태
### 아이템 설정
각 아이템은 다음 속성을 가집니다:
- `id`: 고유 식별자
- `title`: 아이템 제목 (헤더에 표시)
- `content`: 아이템 내용 (접었다 펼 수 있는 부분)
- `defaultOpen`: 기본으로 열림 상태
## 사용 예시
```tsx
// 기본 사용
<AccordionBasic
items={[
{
id: "item-1",
title: "제품 정보",
content: "제품에 대한 상세 정보...",
defaultOpen: true,
},
{
id: "item-2",
title: "배송 정보",
content: "배송에 대한 상세 정보...",
},
]}
type="single"
collapsible={true}
/>
```
## 이벤트
- `onValueChange`: 아이템 선택 상태가 변경될 때 호출
## 스타일링
- shadcn/ui의 Accordion 컴포넌트를 기반으로 구현
- 기본 스타일과 함께 커스텀 스타일링 지원
- 반응형 디자인 지원
## 참고 자료
- [shadcn/ui Accordion](https://ui.shadcn.com/docs/components/accordion)
- [Radix UI Accordion](https://www.radix-ui.com/primitives/docs/components/accordion)

View File

@ -0,0 +1,67 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { AccordionBasicWrapper } from "./AccordionBasicComponent";
import { AccordionBasicConfigPanel } from "./AccordionBasicConfigPanel";
import { AccordionBasicConfig } from "./types";
/**
* AccordionBasic
*
*/
export const AccordionBasicDefinition = createComponentDefinition({
id: "accordion-basic",
name: "아코디언",
nameEng: "AccordionBasic Component",
description: "접을 수 있는 콘텐츠 섹션을 제공하는 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: AccordionBasicWrapper,
defaultConfig: {
dataSource: {
sourceType: "static" as const,
useScreenTable: true,
},
items: [
{
id: "item-1",
title: "제품 정보",
content:
"우리의 주력 제품은 최첨단 기술과 세련된 디자인을 결합합니다. 프리미엄 소재로 제작되어 탁월한 성능과 신뢰성을 제공합니다.",
defaultOpen: true,
},
{
id: "item-2",
title: "배송 정보",
content:
"신뢰할 수 있는 택배 파트너를 통해 전 세계 배송을 제공합니다. 일반 배송은 3-5 영업일, 특급 배송은 1-2 영업일 내 배송됩니다.",
},
{
id: "item-3",
title: "반품 정책",
content:
"포괄적인 30일 반품 정책으로 제품을 보장합니다. 완전히 만족하지 않으시면 원래 상태로 제품을 반품하시면 됩니다.",
},
],
type: "single",
collapsible: true,
defaultValue: "item-1",
},
defaultSize: { width: 300, height: 200 },
configPanel: AccordionBasicConfigPanel,
icon: "ChevronDown",
tags: ["아코디언", "접기", "펼치기", "콘텐츠", "섹션"],
version: "1.0.0",
author: "Developer",
documentation: "https://ui.shadcn.com/docs/components/accordion",
});
// 타입 내보내기
export type { AccordionBasicConfig } from "./types";
// 컴포넌트 내보내기
export { AccordionBasicComponent } from "./AccordionBasicComponent";
export { AccordionBasicRenderer } from "./AccordionBasicRenderer";

View File

@ -0,0 +1,82 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Accordion
*/
export interface AccordionItem {
id: string;
title: string;
content: string;
defaultOpen?: boolean;
}
/**
*
*/
export interface ContentFieldConfig {
fieldName: string; // 필드명
label?: string; // 표시할 라벨 (선택사항)
separator?: string; // 구분자 (기본값: 줄바꿈)
}
/**
*
*/
export interface DataSourceConfig {
sourceType: "static" | "table" | "api"; // 데이터 소스 타입
useScreenTable?: boolean; // 화면 테이블 사용 여부 (table 타입일 때)
tableName?: string; // 직접 입력한 테이블명 (useScreenTable이 false일 때)
apiEndpoint?: string; // API 엔드포인트 (api 타입일 때)
titleField?: string; // 제목으로 사용할 필드명
contentFields?: ContentFieldConfig[]; // 내용으로 사용할 필드들 (여러개 가능)
idField?: string; // ID로 사용할 필드명
filters?: Record<string, any>; // 필터 조건
orderBy?: string; // 정렬 기준
limit?: number; // ⚠️ 더 이상 사용되지 않음. 모든 데이터가 표시되고 스크롤로 처리됨
}
/**
* Accordion
*/
export interface AccordionBasicConfig extends ComponentConfig {
// 데이터 소스 설정
dataSource?: DataSourceConfig;
// 정적 아코디언 아이템들 (기존 방식)
items?: AccordionItem[];
// 동작 설정
type?: "single" | "multiple"; // 단일 선택 또는 다중 선택
collapsible?: boolean; // 모든 아이템을 닫을 수 있는지
defaultValue?: string; // 기본으로 열려있을 아이템 ID
// 스타일 설정
variant?: "default" | "bordered" | "ghost";
size?: "sm" | "md" | "lg";
// 애니메이션 설정
animationDuration?: number; // ms 단위
// 공통 설정
disabled?: boolean;
// 이벤트 관련
onValueChange?: (value: string | string[]) => void;
}
/**
* Accordion Props
*/
export interface AccordionBasicProps {
id?: string;
name?: string;
value?: any;
config?: AccordionBasicConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onValueChange?: (value: string | string[]) => void;
}

View File

@ -0,0 +1,344 @@
"use client";
import React, { useState } from "react";
import { ComponentRendererProps } from "@/types/component";
import { ButtonPrimaryConfig } from "./types";
import {
ButtonActionExecutor,
ButtonActionContext,
ButtonActionType,
DEFAULT_BUTTON_ACTIONS,
} from "@/lib/utils/buttonActions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig;
// 추가 props
screenId?: number;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
}
/**
* ButtonPrimary
* button-primary
*/
export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
screenId,
tableName,
onRefresh,
onClose,
...props
}) => {
// 확인 다이얼로그 상태
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<{
type: ButtonActionType;
config: any;
context: ButtonActionContext;
} | null>(null);
// 컴포넌트 설정
const componentConfig = {
...config,
...component.config,
} as ButtonPrimaryConfig;
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
const processedConfig = { ...componentConfig };
if (componentConfig.action && typeof componentConfig.action === "string") {
const actionType = componentConfig.action as ButtonActionType;
processedConfig.action = {
...DEFAULT_BUTTON_ACTIONS[actionType],
type: actionType,
};
}
console.log("🔧 버튼 컴포넌트 설정:", {
originalConfig: componentConfig,
processedConfig,
component: component,
screenId,
tableName,
onRefresh,
onClose,
});
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
// 확인 다이얼로그가 필요한 액션 타입들
const confirmationRequiredActions: ButtonActionType[] = ["save", "submit", "delete"];
// 실제 액션 실행 함수
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
console.log("🚀 executeAction 시작:", { actionConfig, context });
let loadingToast: string | number | undefined;
try {
console.log("📱 로딩 토스트 표시 시작");
// 로딩 토스트 표시
loadingToast = toast.loading(
actionConfig.type === "save"
? "저장 중..."
: actionConfig.type === "delete"
? "삭제 중..."
: actionConfig.type === "submit"
? "제출 중..."
: "처리 중...",
);
console.log("📱 로딩 토스트 ID:", loadingToast);
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
const success = await ButtonActionExecutor.executeAction(actionConfig, context);
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
// 로딩 토스트 제거
console.log("📱 로딩 토스트 제거");
toast.dismiss(loadingToast);
// 성공 시 토스트 표시
const successMessage =
actionConfig.successMessage ||
(actionConfig.type === "save"
? "저장되었습니다."
: actionConfig.type === "delete"
? "삭제되었습니다."
: actionConfig.type === "submit"
? "제출되었습니다."
: "완료되었습니다.");
console.log("🎉 성공 토스트 표시:", successMessage);
toast.success(successMessage);
console.log("✅ 버튼 액션 실행 성공:", actionConfig.type);
} catch (error) {
console.log("❌ executeAction catch 블록 진입:", error);
// 로딩 토스트 제거
if (loadingToast) {
console.log("📱 오류 시 로딩 토스트 제거");
toast.dismiss(loadingToast);
}
console.error("❌ 버튼 액션 실행 오류:", error);
// 오류 토스트 표시
const errorMessage =
actionConfig.errorMessage ||
(actionConfig.type === "save"
? "저장 중 오류가 발생했습니다."
: actionConfig.type === "delete"
? "삭제 중 오류가 발생했습니다."
: actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다.");
console.log("💥 오류 토스트 표시:", errorMessage);
toast.error(errorMessage);
}
};
// 이벤트 핸들러
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
// 디자인 모드에서는 기본 onClick만 실행
if (isDesignMode) {
onClick?.();
return;
}
// 인터랙티브 모드에서 액션 실행
if (isInteractive && processedConfig.action) {
const context: ButtonActionContext = {
formData: formData || {},
screenId,
tableName,
onFormDataChange,
onRefresh,
onClose,
};
// 확인이 필요한 액션인지 확인
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
// 확인 다이얼로그 표시
setPendingAction({
type: processedConfig.action.type,
config: processedConfig.action,
context,
});
setShowConfirmDialog(true);
} else {
// 확인이 필요하지 않은 액션은 바로 실행
await executeAction(processedConfig.action, context);
}
} else {
// 액션이 설정되지 않은 경우 기본 onClick 실행
onClick?.();
}
};
// 확인 다이얼로그에서 확인 버튼 클릭 시
const handleConfirmAction = async () => {
if (pendingAction) {
await executeAction(pendingAction.config, pendingAction.context);
}
setShowConfirmDialog(false);
setPendingAction(null);
};
// 확인 다이얼로그에서 취소 버튼 클릭 시
const handleCancelAction = () => {
setShowConfirmDialog(false);
setPendingAction(null);
};
// DOM에 전달하면 안 되는 React-specific props 필터링
const {
selectedScreen,
onZoneComponentDrop,
onZoneClick,
componentConfig: _componentConfig,
component: _component,
isSelected: _isSelected,
onClick: _onClick,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
size: _size,
position: _position,
style: _style,
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
...domProps
} = props;
// 다이얼로그 메시지 생성
const getConfirmMessage = () => {
if (!pendingAction) return "";
const customMessage = pendingAction.config.confirmMessage;
if (customMessage) return customMessage;
switch (pendingAction.type) {
case "save":
return "변경사항을 저장하시겠습니까?";
case "delete":
return "정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.";
case "submit":
return "제출하시겠습니까?";
default:
return "이 작업을 실행하시겠습니까?";
}
};
const getConfirmTitle = () => {
if (!pendingAction) return "";
switch (pendingAction.type) {
case "save":
return "저장 확인";
case "delete":
return "삭제 확인";
case "submit":
return "제출 확인";
default:
return "작업 확인";
}
};
return (
<>
<div style={componentStyle} className={className} {...domProps}>
<button
type={componentConfig.actionType || "button"}
disabled={componentConfig.disabled || false}
style={{
width: "100%",
height: "100%",
border: "1px solid #3b82f6",
borderRadius: "4px",
backgroundColor: "#3b82f6",
color: "white",
fontSize: "14px",
fontWeight: "500",
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
outline: "none",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{processedConfig.text || component.label || "버튼"}
</button>
</div>
{/* 확인 다이얼로그 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelAction}></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmAction}>
{pendingAction?.type === "save"
? "저장"
: pendingAction?.type === "delete"
? "삭제"
: pendingAction?.type === "submit"
? "제출"
: "확인"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
/**
* ButtonPrimary
*
*/
export const ButtonPrimaryWrapper: React.FC<ButtonPrimaryComponentProps> = (props) => {
return <ButtonPrimaryComponent {...props} />;
};

View File

@ -0,0 +1,77 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ButtonPrimaryConfig } from "./types";
export interface ButtonPrimaryConfigPanelProps {
config: ButtonPrimaryConfig;
onChange: (config: Partial<ButtonPrimaryConfig>) => void;
}
/**
* ButtonPrimary
* UI
*/
export const ButtonPrimaryConfigPanel: React.FC<ButtonPrimaryConfigPanelProps> = ({ config, onChange }) => {
const handleChange = (key: keyof ButtonPrimaryConfig, value: any) => {
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">button-primary </div>
{/* 버튼 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="text"> </Label>
<Input id="text" value={config.text || ""} onChange={(e) => handleChange("text", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="actionType"> </Label>
<Select value={config.actionType || "button"} onValueChange={(value) => handleChange("actionType", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="button">Button</SelectItem>
<SelectItem value="submit">Submit</SelectItem>
<SelectItem value="reset">Reset</SelectItem>
</SelectContent>
</Select>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,56 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ButtonPrimaryDefinition } from "./index";
import { ButtonPrimaryComponent } from "./ButtonPrimaryComponent";
/**
* ButtonPrimary
*
*/
export class ButtonPrimaryRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = ButtonPrimaryDefinition;
render(): React.ReactElement {
return <ButtonPrimaryComponent {...this.props} renderer={this} />;
}
/**
*
*/
// button 타입 특화 속성 처리
protected getButtonPrimaryProps() {
const baseProps = this.getWebTypeProps();
// button 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 button 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
ButtonPrimaryRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
ButtonPrimaryRenderer.enableHotReload();
}

View File

@ -0,0 +1,93 @@
# ButtonPrimary 컴포넌트
button-primary 컴포넌트입니다
## 개요
- **ID**: `button-primary`
- **카테고리**: action
- **웹타입**: button
- **작성자**: 개발팀
- **버전**: 1.0.0
## 특징
- ✅ 자동 등록 시스템
- ✅ 타입 안전성
- ✅ Hot Reload 지원
- ✅ 설정 패널 제공
- ✅ 반응형 디자인
## 사용법
### 기본 사용법
```tsx
import { ButtonPrimaryComponent } from "@/lib/registry/components/button-primary";
<ButtonPrimaryComponent
component={{
id: "my-button-primary",
type: "widget",
webType: "button",
position: { x: 100, y: 100, z: 1 },
size: { width: 120, height: 36 },
config: {
// 설정값들
}
}}
isDesignMode={false}
/>
```
### 설정 옵션
| 속성 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| text | string | "버튼" | 버튼 텍스트 |
| actionType | string | "button" | 버튼 타입 |
| variant | string | "primary" | 버튼 스타일 |
| disabled | boolean | false | 비활성화 여부 |
| required | boolean | false | 필수 입력 여부 |
| readonly | boolean | false | 읽기 전용 여부 |
## 이벤트
- `onChange`: 값 변경 시
- `onFocus`: 포커스 시
- `onBlur`: 포커스 해제 시
- `onClick`: 클릭 시
## 스타일링
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
- `variant`: "default" | "outlined" | "filled"
- `size`: "sm" | "md" | "lg"
## 예시
```tsx
// 기본 예시
<ButtonPrimaryComponent
component={{
id: "sample-button-primary",
config: {
placeholder: "입력하세요",
required: true,
variant: "outlined"
}
}}
/>
```
## 개발자 정보
- **생성일**: 2025-09-11
- **CLI 명령어**: `node scripts/create-component.js button-primary --category=action --webType=button`
- **경로**: `lib/registry/components/button-primary/`
## 관련 문서
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
- [개발자 문서](https://docs.example.com/components/button-primary)

View File

@ -0,0 +1,52 @@
"use client";
import { ButtonPrimaryConfig } from "./types";
/**
* ButtonPrimary
*/
export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = {
text: "버튼",
actionType: "button",
variant: "primary",
// 공통 기본값
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
};
/**
* ButtonPrimary
*
*/
export const ButtonPrimaryConfigSchema = {
text: { type: "string", default: "버튼" },
actionType: {
type: "enum",
values: ["button", "submit", "reset"],
default: "button"
},
variant: {
type: "enum",
values: ["primary", "secondary", "danger"],
default: "primary"
},
// 공통 스키마
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
},
};

View File

@ -0,0 +1,49 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ButtonPrimaryWrapper } from "./ButtonPrimaryComponent";
import { ButtonPrimaryConfigPanel } from "./ButtonPrimaryConfigPanel";
import { ButtonPrimaryConfig } from "./types";
/**
* ButtonPrimary
* button-primary
*/
export const ButtonPrimaryDefinition = createComponentDefinition({
id: "button-primary",
name: "기본 버튼",
nameEng: "ButtonPrimary Component",
description: "일반적인 액션을 위한 기본 버튼 컴포넌트",
category: ComponentCategory.ACTION,
webType: "button",
component: ButtonPrimaryWrapper,
defaultConfig: {
text: "저장",
actionType: "button",
variant: "primary",
action: {
type: "save",
successMessage: "저장되었습니다.",
errorMessage: "저장 중 오류가 발생했습니다.",
},
},
defaultSize: { width: 120, height: 36 },
configPanel: ButtonPrimaryConfigPanel,
icon: "MousePointer",
tags: ["버튼", "액션", "클릭"],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/button-primary",
});
// 컴포넌트는 ButtonPrimaryRenderer에서 자동 등록됩니다
// 타입 내보내기
export type { ButtonPrimaryConfig } from "./types";
// 컴포넌트 내보내기
export { ButtonPrimaryComponent } from "./ButtonPrimaryComponent";
export { ButtonPrimaryRenderer } from "./ButtonPrimaryRenderer";

View File

@ -0,0 +1,51 @@
"use client";
import { ComponentConfig } from "@/types/component";
import { ButtonActionConfig } from "@/lib/utils/buttonActions";
/**
* ButtonPrimary
*/
export interface ButtonPrimaryConfig extends ComponentConfig {
// 버튼 관련 설정
text?: string;
actionType?: "button" | "submit" | "reset";
variant?: "primary" | "secondary" | "danger";
// 버튼 액션 설정
action?: ButtonActionConfig;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 스타일 관련
size?: "sm" | "md" | "lg";
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
}
/**
* ButtonPrimary Props
*/
export interface ButtonPrimaryProps {
id?: string;
name?: string;
value?: any;
config?: ButtonPrimaryConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
}

View File

@ -0,0 +1,150 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { CheckboxBasicConfig } from "./types";
export interface CheckboxBasicComponentProps extends ComponentRendererProps {
config?: CheckboxBasicConfig;
}
/**
* CheckboxBasic
* checkbox-basic
*/
export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
...props
}) => {
// 컴포넌트 설정
const componentConfig = {
...config,
...component.config,
} as CheckboxBasicConfig;
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// DOM에 전달하면 안 되는 React-specific props 필터링
const {
selectedScreen,
onZoneComponentDrop,
onZoneClick,
componentConfig: _componentConfig,
component: _component,
isSelected: _isSelected,
onClick: _onClick,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
size: _size,
position: _position,
style: _style,
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
...domProps
} = props;
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
{component.label}
{component.required && (
<span style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
*
</span>
)}
</label>
)}
<label
style={{display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
width: "100%",
height: "100%",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<input
type="checkbox"
checked={component.value === true || component.value === "true"}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
style={{width: "16px",
height: "16px",
accentColor: "#3b82f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}
onChange={(e) => {
if (component.onChange) {
component.onChange(e.target.checked);
}
}}
/>
<span style={{color: "#374151",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}>{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
</label>
</div>
);
};
/**
* CheckboxBasic
*
*/
export const CheckboxBasicWrapper: React.FC<CheckboxBasicComponentProps> = (props) => {
return <CheckboxBasicComponent {...props} />;
};

View File

@ -0,0 +1,72 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CheckboxBasicConfig } from "./types";
export interface CheckboxBasicConfigPanelProps {
config: CheckboxBasicConfig;
onChange: (config: Partial<CheckboxBasicConfig>) => void;
}
/**
* CheckboxBasic
* UI
*/
export const CheckboxBasicConfigPanel: React.FC<CheckboxBasicConfigPanelProps> = ({
config,
onChange,
}) => {
const handleChange = (key: keyof CheckboxBasicConfig, value: any) => {
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">
checkbox-basic
</div>
{/* checkbox 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
/>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More