dev #46
|
|
@ -5020,6 +5020,10 @@ model screen_layouts {
|
|||
height Int
|
||||
properties Json?
|
||||
display_order Int @default(0)
|
||||
layout_type String? @db.VarChar(50)
|
||||
layout_config Json?
|
||||
zones_config Json?
|
||||
zone_id String? @db.VarChar(100)
|
||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
|
||||
widgets screen_widgets[]
|
||||
|
|
@ -5255,6 +5259,33 @@ model component_standards {
|
|||
@@index([category], map: "idx_component_standards_category")
|
||||
@@index([company_code], map: "idx_component_standards_company")
|
||||
}
|
||||
|
||||
// 레이아웃 표준 관리 테이블
|
||||
model layout_standards {
|
||||
layout_code String @id @db.VarChar(50)
|
||||
layout_name String @db.VarChar(100)
|
||||
layout_name_eng String? @db.VarChar(100)
|
||||
description String? @db.Text
|
||||
layout_type String @db.VarChar(50)
|
||||
category String @db.VarChar(50)
|
||||
icon_name String? @db.VarChar(50)
|
||||
default_size Json? // { width: number, height: number }
|
||||
layout_config Json // 레이아웃 설정 (그리드, 플렉스박스 등)
|
||||
zones_config Json // 존 설정 (영역 정의)
|
||||
preview_image String? @db.VarChar(255)
|
||||
sort_order Int? @default(0)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
is_public String? @default("Y") @db.Char(1)
|
||||
company_code String @db.VarChar(50)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([layout_type], map: "idx_layout_standards_type")
|
||||
@@index([category], map: "idx_layout_standards_category")
|
||||
@@index([company_code], map: "idx_layout_standards_company")
|
||||
}
|
||||
model table_relationships {
|
||||
relationship_id Int @id @default(autoincrement())
|
||||
diagram_id Int // 관계도 그룹 식별자
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
@ -26,6 +26,8 @@ import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
|||
import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||
import layoutRoutes from "./routes/layoutRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// 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/template-standards", templateStandardRoutes);
|
||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||
app.use("/api/layouts", layoutRoutes);
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,15 @@ class ComponentStandardController {
|
|||
});
|
||||
return;
|
||||
} 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({
|
||||
success: false,
|
||||
message: "컴포넌트 수정에 실패했습니다.",
|
||||
|
|
@ -382,6 +390,52 @@ class ComponentStandardController {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { Request, Response } from "express";
|
||||
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 {
|
||||
const {
|
||||
active = "Y",
|
||||
category,
|
||||
search,
|
||||
companyCode,
|
||||
company_code,
|
||||
is_public = "Y",
|
||||
page = "1",
|
||||
limit = "50",
|
||||
} = req.query;
|
||||
|
||||
const user = req.user;
|
||||
const userCompanyCode = user?.companyCode || "DEFAULT";
|
||||
const userCompanyCode = user?.company_code || "DEFAULT";
|
||||
|
||||
const result = await templateStandardService.getTemplates({
|
||||
active: active as string,
|
||||
category: category 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,
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
|
|
@ -47,23 +53,24 @@ export class TemplateStandardController {
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(
|
||||
res,
|
||||
error,
|
||||
"템플릿 목록 조회 중 오류가 발생했습니다."
|
||||
);
|
||||
console.error("템플릿 목록 조회 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 상세 조회
|
||||
*/
|
||||
async getTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async getTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
|
|
@ -72,7 +79,7 @@ export class TemplateStandardController {
|
|||
const template = await templateStandardService.getTemplate(templateCode);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
|
|
@ -83,40 +90,46 @@ export class TemplateStandardController {
|
|||
data: template,
|
||||
});
|
||||
} 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 {
|
||||
const user = req.user;
|
||||
const templateData = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
const requiredFields = [
|
||||
"template_code",
|
||||
"template_name",
|
||||
"category",
|
||||
"layout_config",
|
||||
];
|
||||
const missingFields = checkMissingFields(templateData, requiredFields);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return res.status(400).json({
|
||||
if (
|
||||
!templateData.template_code ||
|
||||
!templateData.template_name ||
|
||||
!templateData.category ||
|
||||
!templateData.layout_config
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `필수 필드가 누락되었습니다: ${missingFields.join(", ")}`,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (template_code, template_name, category, layout_config)",
|
||||
});
|
||||
}
|
||||
|
||||
// 회사 코드와 생성자 정보 추가
|
||||
const templateWithMeta = {
|
||||
...templateData,
|
||||
company_code: user?.companyCode || "DEFAULT",
|
||||
created_by: user?.userId || "system",
|
||||
updated_by: user?.userId || "system",
|
||||
company_code: user?.company_code || "DEFAULT",
|
||||
created_by: user?.user_id || "system",
|
||||
updated_by: user?.user_id || "system",
|
||||
};
|
||||
|
||||
const newTemplate =
|
||||
|
|
@ -128,21 +141,29 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} 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 {
|
||||
const { templateCode } = req.params;
|
||||
const templateData = req.body;
|
||||
const user = req.user;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
|
|
@ -151,7 +172,7 @@ export class TemplateStandardController {
|
|||
// 수정자 정보 추가
|
||||
const templateWithMeta = {
|
||||
...templateData,
|
||||
updated_by: user?.userId || "system",
|
||||
updated_by: user?.user_id || "system",
|
||||
};
|
||||
|
||||
const updatedTemplate = await templateStandardService.updateTemplate(
|
||||
|
|
@ -160,7 +181,7 @@ export class TemplateStandardController {
|
|||
);
|
||||
|
||||
if (!updatedTemplate) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
|
|
@ -172,19 +193,27 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} 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 {
|
||||
const { templateCode } = req.params;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
|
|
@ -194,7 +223,7 @@ export class TemplateStandardController {
|
|||
await templateStandardService.deleteTemplate(templateCode);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
|
|
@ -205,19 +234,27 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} 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 {
|
||||
const { templates } = req.body;
|
||||
|
||||
if (!Array.isArray(templates)) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "templates는 배열이어야 합니다.",
|
||||
});
|
||||
|
|
@ -230,25 +267,29 @@ export class TemplateStandardController {
|
|||
message: "템플릿 정렬 순서가 성공적으로 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(
|
||||
res,
|
||||
error,
|
||||
"템플릿 정렬 순서 업데이트 중 오류가 발생했습니다."
|
||||
);
|
||||
console.error("템플릿 정렬 순서 업데이트 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 정렬 순서 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 복제
|
||||
*/
|
||||
async duplicateTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async duplicateTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
const { new_template_code, new_template_name } = req.body;
|
||||
const user = req.user;
|
||||
|
||||
if (!templateCode || !new_template_code || !new_template_name) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
|
|
@ -259,8 +300,8 @@ export class TemplateStandardController {
|
|||
originalCode: templateCode,
|
||||
newCode: new_template_code,
|
||||
newName: new_template_name,
|
||||
company_code: user?.companyCode || "DEFAULT",
|
||||
created_by: user?.userId || "system",
|
||||
company_code: user?.company_code || "DEFAULT",
|
||||
created_by: user?.user_id || "system",
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
|
@ -269,17 +310,22 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 복제되었습니다.",
|
||||
});
|
||||
} 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 {
|
||||
const user = req.user;
|
||||
const companyCode = user?.companyCode || "DEFAULT";
|
||||
const companyCode = user?.company_code || "DEFAULT";
|
||||
|
||||
const categories =
|
||||
await templateStandardService.getCategories(companyCode);
|
||||
|
|
@ -289,24 +335,28 @@ export class TemplateStandardController {
|
|||
data: categories,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(
|
||||
res,
|
||||
error,
|
||||
"템플릿 카테고리 조회 중 오류가 발생했습니다."
|
||||
);
|
||||
console.error("템플릿 카테고리 조회 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 가져오기 (JSON 파일에서)
|
||||
*/
|
||||
async importTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async importTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = req.user;
|
||||
const templateData = req.body;
|
||||
|
||||
if (!templateData.layout_config) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "유효한 템플릿 데이터가 아닙니다.",
|
||||
});
|
||||
|
|
@ -315,9 +365,9 @@ export class TemplateStandardController {
|
|||
// 회사 코드와 생성자 정보 추가
|
||||
const templateWithMeta = {
|
||||
...templateData,
|
||||
company_code: user?.companyCode || "DEFAULT",
|
||||
created_by: user?.userId || "system",
|
||||
updated_by: user?.userId || "system",
|
||||
company_code: user?.company_code || "DEFAULT",
|
||||
created_by: user?.user_id || "system",
|
||||
updated_by: user?.user_id || "system",
|
||||
};
|
||||
|
||||
const importedTemplate =
|
||||
|
|
@ -329,31 +379,41 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 가져왔습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 가져오기 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 가져오기 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 가져오기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 내보내기 (JSON 형태로)
|
||||
*/
|
||||
async exportTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async exportTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await templateStandardService.getTemplate(templateCode);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 내보내기용 데이터 (메타데이터 제외)
|
||||
|
|
@ -373,7 +433,12 @@ export class TemplateStandardController {
|
|||
data: exportData,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 내보내기 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 내보내기 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 내보내기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ router.get(
|
|||
componentStandardController.getStatistics.bind(componentStandardController)
|
||||
);
|
||||
|
||||
// 컴포넌트 코드 중복 체크
|
||||
router.get(
|
||||
"/check-duplicate/:component_code",
|
||||
componentStandardController.checkDuplicate.bind(componentStandardController)
|
||||
);
|
||||
|
||||
// 컴포넌트 상세 조회
|
||||
router.get(
|
||||
"/:component_code",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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({
|
||||
data: {
|
||||
...data,
|
||||
...createData,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
|
|
@ -151,10 +158,17 @@ class ComponentStandardService {
|
|||
) {
|
||||
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({
|
||||
where: { component_code },
|
||||
data: {
|
||||
...data,
|
||||
...updateData,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
|
@ -216,21 +230,19 @@ class ComponentStandardService {
|
|||
data: {
|
||||
component_code: new_code,
|
||||
component_name: new_name,
|
||||
component_name_eng: source.component_name_eng,
|
||||
description: source.description,
|
||||
category: source.category,
|
||||
icon_name: source.icon_name,
|
||||
default_size: source.default_size as any,
|
||||
component_config: source.component_config as any,
|
||||
preview_image: source.preview_image,
|
||||
sort_order: source.sort_order,
|
||||
is_active: source.is_active,
|
||||
is_public: source.is_public,
|
||||
company_code: source.company_code,
|
||||
component_name_eng: source?.component_name_eng,
|
||||
description: source?.description,
|
||||
category: source?.category,
|
||||
icon_name: source?.icon_name,
|
||||
default_size: source?.default_size as any,
|
||||
component_config: source?.component_config as any,
|
||||
preview_image: source?.preview_image,
|
||||
sort_order: source?.sort_order,
|
||||
is_active: source?.is_active,
|
||||
is_public: source?.is_public,
|
||||
company_code: source?.company_code || "DEFAULT",
|
||||
created_date: new Date(),
|
||||
created_by: source.created_by,
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
회사 코드: COMPANY_2
|
||||
생성일: 2025-09-11T02:07:40.033Z
|
||||
폴더 구조: YYYY/MM/DD/파일명
|
||||
관리자: 시스템 자동 생성
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
회사 코드: COMPANY_3
|
||||
생성일: 2025-09-11T02:08:06.303Z
|
||||
폴더 구조: YYYY/MM/DD/파일명
|
||||
관리자: 시스템 자동 생성
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -14,7 +14,10 @@ import {
|
|||
useComponentCategories,
|
||||
useComponentStatistics,
|
||||
useDeleteComponent,
|
||||
useCreateComponent,
|
||||
useUpdateComponent,
|
||||
} from "@/hooks/admin/useComponents";
|
||||
import { ComponentFormModal } from "@/components/admin/ComponentFormModal";
|
||||
|
||||
// 컴포넌트 카테고리 정의
|
||||
const COMPONENT_CATEGORIES = [
|
||||
|
|
@ -32,6 +35,8 @@ export default function ComponentManagementPage() {
|
|||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedComponent, setSelectedComponent] = useState<any>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showNewComponentModal, setShowNewComponentModal] = useState(false);
|
||||
const [showEditComponentModal, setShowEditComponentModal] = useState(false);
|
||||
|
||||
// 컴포넌트 데이터 가져오기
|
||||
const {
|
||||
|
|
@ -51,8 +56,10 @@ export default function ComponentManagementPage() {
|
|||
const { data: categories } = useComponentCategories();
|
||||
const { data: statistics } = useComponentStatistics();
|
||||
|
||||
// 삭제 뮤테이션
|
||||
// 뮤테이션
|
||||
const deleteComponentMutation = useDeleteComponent();
|
||||
const createComponentMutation = useCreateComponent();
|
||||
const updateComponentMutation = useUpdateComponent();
|
||||
|
||||
// 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태)
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<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">
|
||||
<Button size="sm" onClick={() => setShowNewComponentModal(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 컴포넌트
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -279,7 +295,14 @@ export default function ComponentManagementPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<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" />
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -313,6 +336,26 @@ export default function ComponentManagementPage() {
|
|||
message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,8 +7,11 @@ import { Loader2 } from "lucide-react";
|
|||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -21,6 +24,20 @@ export default function ScreenViewPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
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(() => {
|
||||
const loadScreen = async () => {
|
||||
try {
|
||||
|
|
@ -93,12 +110,13 @@ export default function ScreenViewPage() {
|
|||
{layout && layout.components.length > 0 ? (
|
||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
|
||||
}}
|
||||
>
|
||||
{layout.components
|
||||
|
|
@ -147,10 +165,19 @@ export default function ScreenViewPage() {
|
|||
allComponents={layout.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
console.log("📝 폼 데이터 변경:", { fieldName, value });
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📊 전체 폼 데이터:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
screenInfo={{
|
||||
id: screenId,
|
||||
tableName: screen?.tableName,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -202,27 +229,58 @@ export default function ScreenViewPage() {
|
|||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
width: `${component.size.width}px`,
|
||||
height: `${component.size.height}px`,
|
||||
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
|
||||
component={component}
|
||||
allComponents={layout.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
hideLabel={true} // 라벨 숨김 플래그 전달
|
||||
screenInfo={{
|
||||
id: screenId,
|
||||
tableName: screen?.tableName,
|
||||
}}
|
||||
/>
|
||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||
{component.type !== "widget" ? (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
screenId={screenId}
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { Inter, JetBrains_Mono } from "next/font/google";
|
|||
import "./globals.css";
|
||||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { RegistryProvider } from "./registry-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import ScreenModal from "@/components/common/ScreenModal";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -44,6 +46,8 @@ export default function RootLayout({
|
|||
<QueryProvider>
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
</QueryProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
<ScreenModal />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Cog,
|
||||
Layout,
|
||||
Monitor,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -33,6 +34,8 @@ interface DesignerToolbarProps {
|
|||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSaving?: boolean;
|
||||
showZoneBorders?: boolean;
|
||||
onToggleZoneBorders?: () => void;
|
||||
}
|
||||
|
||||
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||
|
|
@ -48,6 +51,8 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
|||
canUndo,
|
||||
canRedo,
|
||||
isSaving = false,
|
||||
showZoneBorders = true,
|
||||
onToggleZoneBorders,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||
|
|
@ -154,6 +159,23 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
|||
</Badge>
|
||||
</Button>
|
||||
|
||||
{/* 구역 경계 표시 토글 버튼 */}
|
||||
{onToggleZoneBorders && (
|
||||
<Button
|
||||
variant={showZoneBorders ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={onToggleZoneBorders}
|
||||
className={cn("flex items-center space-x-2", showZoneBorders && "bg-green-600 text-white")}
|
||||
title="구역 경계 표시/숨김"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<span>구역</span>
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
Z
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import { toast } from "sonner";
|
|||
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
|
||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||
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 { useParams } from "next/navigation";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
|
@ -152,9 +159,40 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return renderFileComponent(comp as FileComponent);
|
||||
}
|
||||
|
||||
// 위젯 컴포넌트가 아닌 경우
|
||||
// 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용
|
||||
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;
|
||||
|
|
@ -492,5 +530,3 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
export { InteractiveScreenViewerDynamic as InteractiveScreenViewer };
|
||||
|
||||
InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import {
|
||||
Database,
|
||||
Type,
|
||||
|
|
@ -24,210 +15,86 @@ import {
|
|||
Code,
|
||||
Building,
|
||||
File,
|
||||
Group,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Upload,
|
||||
Square,
|
||||
CreditCard,
|
||||
Layout,
|
||||
Grid3x3,
|
||||
Columns,
|
||||
Rows,
|
||||
SidebarOpen,
|
||||
Folder,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
|
||||
// 컴포넌트 렌더러들 자동 등록
|
||||
import "@/lib/registry/components";
|
||||
|
||||
interface RealtimePreviewProps {
|
||||
component: ComponentData;
|
||||
isSelected?: boolean;
|
||||
isDesignMode?: boolean; // 편집 모드 여부
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
||||
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
||||
selectedScreen?: any;
|
||||
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
|
||||
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
|
||||
}
|
||||
|
||||
// 영역 레이아웃에 따른 아이콘 반환
|
||||
const getAreaIcon = (layoutType: AreaLayoutType) => {
|
||||
switch (layoutType) {
|
||||
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 getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
|
||||
if (!widgetType) return <Type className="h-3 w-3" />;
|
||||
|
||||
// 영역 렌더링
|
||||
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||
const area = component as AreaComponent;
|
||||
const { areaType, label } = area;
|
||||
|
||||
const renderPlaceholder = () => (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
{getAreaIcon(areaType)}
|
||||
<p className="mt-2 text-sm text-gray-600">{label || `${areaType} 영역`}</p>
|
||||
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
{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}`,
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
text: <span className="text-xs">Aa</span>,
|
||||
number: <Hash className="h-3 w-3" />,
|
||||
decimal: <Hash className="h-3 w-3" />,
|
||||
date: <Calendar className="h-3 w-3" />,
|
||||
datetime: <Calendar className="h-3 w-3" />,
|
||||
select: <List className="h-3 w-3" />,
|
||||
dropdown: <List className="h-3 w-3" />,
|
||||
textarea: <AlignLeft className="h-3 w-3" />,
|
||||
boolean: <CheckSquare className="h-3 w-3" />,
|
||||
checkbox: <CheckSquare className="h-3 w-3" />,
|
||||
radio: <Radio className="h-3 w-3" />,
|
||||
code: <Code className="h-3 w-3" />,
|
||||
entity: <Building className="h-3 w-3" />,
|
||||
file: <File className="h-3 w-3" />,
|
||||
email: <span className="text-xs">@</span>,
|
||||
tel: <span className="text-xs">☎</span>,
|
||||
button: <span className="text-xs">BTN</span>,
|
||||
};
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
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" />;
|
||||
}
|
||||
return iconMap[widgetType] || <Type className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
component,
|
||||
isSelected = false,
|
||||
isDesignMode = true, // 기본값은 편집 모드
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onGroupToggle,
|
||||
children,
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { type, id, position, size, style = {} } = component;
|
||||
const { id, type, position, size, style: componentStyle } = 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
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
|
|
@ -246,166 +113,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
<div
|
||||
id={`component-${id}`}
|
||||
className="absolute cursor-pointer"
|
||||
style={{ ...componentStyle, ...selectionStyle }}
|
||||
style={{ ...baseStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
{/* 동적 컴포넌트 렌더링 */}
|
||||
<div className="h-full w-full">
|
||||
{/* 영역 타입 */}
|
||||
{type === "area" && renderArea(component, children)}
|
||||
|
||||
{/* 데이터 테이블 타입 */}
|
||||
{type === "datatable" &&
|
||||
(() => {
|
||||
const dataTableComponent = component as any; // DataTableComponent 타입
|
||||
|
||||
// 메모이제이션을 위한 계산 최적화
|
||||
const visibleColumns = React.useMemo(
|
||||
() => dataTableComponent.columns?.filter((col: any) => col.visible) || [],
|
||||
[dataTableComponent.columns],
|
||||
);
|
||||
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>
|
||||
)}
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
children={children}
|
||||
selectedScreen={selectedScreen}
|
||||
onZoneComponentDrop={onZoneComponentDrop}
|
||||
onZoneClick={onZoneClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
|
|
@ -417,7 +145,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{(component as WidgetComponent).widgetType || "widget"}
|
||||
</div>
|
||||
)}
|
||||
{type !== "widget" && type}
|
||||
{type !== "widget" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{component.componentConfig?.type || type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -426,5 +158,4 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
// 기존 RealtimePreview와의 호환성을 위한 export
|
||||
export { RealtimePreviewDynamic as RealtimePreview };
|
||||
|
||||
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
|
||||
export default RealtimePreviewDynamic;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
SCREEN_RESOLUTIONS,
|
||||
} from "@/types/screen";
|
||||
import { generateComponentId } from "@/lib/utils/generateId";
|
||||
import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping";
|
||||
import {
|
||||
createGroupComponent,
|
||||
calculateBoundingBox,
|
||||
|
|
@ -38,20 +39,30 @@ import { GroupingToolbar } from "./GroupingToolbar";
|
|||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { toast } from "sonner";
|
||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
|
||||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreview";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
import FloatingPanel from "./FloatingPanel";
|
||||
import DesignerToolbar from "./DesignerToolbar";
|
||||
import TablesPanel from "./panels/TablesPanel";
|
||||
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||
import ComponentsPanel from "./panels/ComponentsPanel";
|
||||
import LayoutsPanel from "./panels/LayoutsPanel";
|
||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||
import GridPanel from "./panels/GridPanel";
|
||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
|
||||
// 레이아웃 초기화
|
||||
import "@/lib/registry/layouts";
|
||||
|
||||
// 컴포넌트 초기화 (새 시스템)
|
||||
import "@/lib/registry/components";
|
||||
// 성능 최적화 도구 초기화 (필요시 사용)
|
||||
import "@/lib/registry/utils/performanceOptimizer";
|
||||
|
||||
interface ScreenDesignerProps {
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onBackToList: () => void;
|
||||
|
|
@ -75,6 +86,14 @@ const panelConfigs: PanelConfig[] = [
|
|||
defaultHeight: 700,
|
||||
shortcutKey: "m", // template의 m
|
||||
},
|
||||
{
|
||||
id: "layouts",
|
||||
title: "레이아웃",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 380,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "l", // layout의 l
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
title: "속성 편집",
|
||||
|
|
@ -294,9 +313,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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 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 };
|
||||
let current: any = newComp;
|
||||
|
|
@ -559,6 +631,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
[layout, gridInfo, saveToHistory],
|
||||
);
|
||||
|
||||
// 컴포넌트 시스템 초기화
|
||||
useEffect(() => {
|
||||
const initComponents = async () => {
|
||||
try {
|
||||
console.log("🚀 컴포넌트 시스템 초기화 시작...");
|
||||
await initializeComponents();
|
||||
console.log("✅ 컴포넌트 시스템 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 컴포넌트 시스템 초기화 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initComponents();
|
||||
}, []);
|
||||
|
||||
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
|
||||
|
|
@ -1212,9 +1299,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
|
||||
);
|
||||
|
||||
// 컴포넌트 드래그 처리
|
||||
const handleComponentDrop = useCallback(
|
||||
(e: React.DragEvent, component: any) => {
|
||||
// 레이아웃 드래그 처리
|
||||
const handleLayoutDrop = useCallback(
|
||||
(e: React.DragEvent, layoutData: any) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
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)
|
||||
: { x: dropX, y: dropY, z: 1 };
|
||||
|
||||
console.log("🧩 컴포넌트 드롭:", {
|
||||
componentName: component.name,
|
||||
webType: component.webType,
|
||||
console.log("🏗️ 레이아웃 드롭:", {
|
||||
layoutType: layoutData.layoutType,
|
||||
zonesCount: layoutData.zones.length,
|
||||
dropPosition: { x: dropX, y: dropY },
|
||||
snappedPosition,
|
||||
});
|
||||
|
||||
// 웹타입별 기본 설정 생성
|
||||
const getDefaultWebTypeConfig = (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 newLayoutComponent: ComponentData = {
|
||||
id: layoutData.id,
|
||||
type: "layout",
|
||||
layoutType: layoutData.layoutType,
|
||||
layoutConfig: layoutData.layoutConfig,
|
||||
zones: layoutData.zones.map((zone: any) => ({
|
||||
...zone,
|
||||
id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가
|
||||
})),
|
||||
children: [],
|
||||
position: snappedPosition,
|
||||
size: layoutData.size,
|
||||
label: layoutData.label,
|
||||
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||
dropZoneConfig: layoutData.dropZoneConfig,
|
||||
} as ComponentData;
|
||||
|
||||
// 레이아웃에 새 컴포넌트 추가
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: [...layout.components, newLayoutComponent],
|
||||
};
|
||||
|
||||
// 새 컴포넌트 생성
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
// 레이아웃 컴포넌트 선택
|
||||
setSelectedComponent(newLayoutComponent);
|
||||
openPanel("properties");
|
||||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
|
||||
);
|
||||
|
||||
// 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 = {
|
||||
id: generateComponentId(),
|
||||
type: component.webType === "button" ? "button" : "widget",
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
label: component.name,
|
||||
widgetType: component.webType,
|
||||
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
|
||||
position: snappedPosition,
|
||||
size: component.defaultSize,
|
||||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
...component.defaultConfig,
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
|
|
@ -1337,10 +1565,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
if (!dragData) return;
|
||||
console.log("🎯 드롭 이벤트:", { dragData });
|
||||
if (!dragData) {
|
||||
console.log("❌ 드래그 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(dragData);
|
||||
console.log("📋 파싱된 데이터:", parsedData);
|
||||
|
||||
// 템플릿 드래그인 경우
|
||||
if (parsedData.type === "template") {
|
||||
|
|
@ -1348,6 +1581,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return;
|
||||
}
|
||||
|
||||
// 레이아웃 드래그인 경우
|
||||
if (parsedData.type === "layout") {
|
||||
handleLayoutDrop(e, parsedData.layout);
|
||||
return;
|
||||
}
|
||||
|
||||
// 컴포넌트 드래그인 경우
|
||||
if (parsedData.type === "component") {
|
||||
handleComponentDrop(e, parsedData.component);
|
||||
|
|
@ -1387,6 +1626,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
},
|
||||
};
|
||||
} else if (type === "column") {
|
||||
console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
||||
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
|
||||
const currentGridInfo = layout.gridSettings
|
||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
|
|
@ -1551,52 +1791,68 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const relativeX = e.clientX - containerRect.left;
|
||||
const relativeY = e.clientY - containerRect.top;
|
||||
|
||||
// 웹타입을 새로운 컴포넌트 ID로 매핑
|
||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||
console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
||||
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
type: "widget",
|
||||
label: column.columnName,
|
||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||
label: column.columnLabel || column.columnName,
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
widgetType: column.widgetType,
|
||||
required: column.required,
|
||||
readonly: false,
|
||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: defaultWidth, height: 40 },
|
||||
gridColumns: 1,
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||
labelFontSize: "12px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
labelMarginBottom: "6px",
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
|
||||
componentConfig: {
|
||||
type: componentId, // text-input, number-input 등
|
||||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
|
||||
}
|
||||
} else {
|
||||
// 일반 캔버스에 드롭한 경우 (기존 로직)
|
||||
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
|
||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||
console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
||||
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
type: "widget",
|
||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
widgetType: column.widgetType,
|
||||
required: column.required,
|
||||
readonly: false,
|
||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: defaultWidth, height: 40 },
|
||||
gridColumns: 1,
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||
labelFontSize: "12px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
labelMarginBottom: "6px",
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
|
||||
componentConfig: {
|
||||
type: componentId, // text-input, number-input 등
|
||||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1744,10 +2000,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 다중 선택된 컴포넌트들 확인
|
||||
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
|
||||
const componentsToMove = isDraggedComponentSelected
|
||||
let componentsToMove = isDraggedComponentSelected
|
||||
? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))
|
||||
: [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("마우스 위치:", {
|
||||
clientX: event.clientX,
|
||||
|
|
@ -2854,8 +3126,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
startSelectionDrag(e);
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
console.log("🎯 캔버스 드롭 이벤트 발생");
|
||||
handleDrop(e);
|
||||
}}
|
||||
>
|
||||
{/* 격자 라인 */}
|
||||
{gridLines.map((line, index) => (
|
||||
|
|
@ -2937,11 +3216,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
isSelected={
|
||||
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(component, e)}
|
||||
onDragStart={(e) => startComponentDrag(component, e)}
|
||||
onDragEnd={endDrag}
|
||||
selectedScreen={selectedScreen}
|
||||
// onZoneComponentDrop 제거
|
||||
onZoneClick={handleZoneClick}
|
||||
>
|
||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 */}
|
||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
|
|
@ -3013,9 +3296,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
isSelected={
|
||||
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(child, e)}
|
||||
onDragStart={(e) => startComponentDrag(child, e)}
|
||||
onDragEnd={endDrag}
|
||||
selectedScreen={selectedScreen}
|
||||
// onZoneComponentDrop 제거
|
||||
onZoneClick={handleZoneClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -3078,11 +3365,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onDragStart={(e, table, column) => {
|
||||
console.log("🚀 드래그 시작:", { table: table.tableName, column: column?.columnName });
|
||||
const dragData = {
|
||||
type: column ? "column" : "table",
|
||||
table,
|
||||
column,
|
||||
};
|
||||
console.log("📦 드래그 데이터:", dragData);
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
}}
|
||||
selectedTableName={selectedScreen.tableName}
|
||||
|
|
@ -3120,6 +3409,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
/>
|
||||
</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
|
||||
id="components"
|
||||
title="컴포넌트"
|
||||
|
|
@ -3130,22 +3442,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
height={700}
|
||||
autoHeight={false}
|
||||
>
|
||||
<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));
|
||||
}}
|
||||
/>
|
||||
<ComponentsPanel />
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,139 +1,425 @@
|
|||
"use client";
|
||||
|
||||
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 }) => {
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
label: "버튼",
|
||||
text: "",
|
||||
tooltip: "",
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
disabled: false,
|
||||
fullWidth: false,
|
||||
...initialConfig,
|
||||
});
|
||||
interface ButtonConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
setLocalConfig({
|
||||
label: "버튼",
|
||||
text: "",
|
||||
tooltip: "",
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
disabled: false,
|
||||
fullWidth: false,
|
||||
...initialConfig,
|
||||
});
|
||||
}, [initialConfig]);
|
||||
const fetchScreens = async () => {
|
||||
try {
|
||||
setScreensLoading(true);
|
||||
console.log("🔍 화면 목록 API 호출 시작");
|
||||
const response = await apiClient.get("/screen-management/screens");
|
||||
console.log("✅ 화면 목록 API 응답:", response.data);
|
||||
|
||||
const updateConfig = (key: string, value: any) => {
|
||||
const newConfig = { ...localConfig, [key]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
if (response.data.success && Array.isArray(response.data.data)) {
|
||||
const screenList = response.data.data.map((screen: any) => ({
|
||||
id: screen.screenId,
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="button-label" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
버튼 텍스트
|
||||
</label>
|
||||
<input
|
||||
id="button-label"
|
||||
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>
|
||||
<Label htmlFor="button-text">버튼 텍스트</Label>
|
||||
<Input
|
||||
id="button-text"
|
||||
value={config.text || "버튼"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.text", e.target.value)}
|
||||
placeholder="버튼 텍스트를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">미리보기</h4>
|
||||
<button
|
||||
type="button"
|
||||
disabled={localConfig.disabled}
|
||||
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}
|
||||
<div>
|
||||
<Label htmlFor="button-variant">버튼 스타일</Label>
|
||||
<Select
|
||||
value={config.variant || "default"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.variant", value)}
|
||||
>
|
||||
{localConfig.label || "버튼"}
|
||||
</button>
|
||||
<SelectTrigger>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,283 +1,260 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Plus, Layers, Search, Filter } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useComponents } from "@/hooks/admin/useComponents";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 {
|
||||
onDragStart: (e: React.DragEvent, component: ComponentItem) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ComponentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
componentType: string;
|
||||
componentConfig: any;
|
||||
icon: React.ReactNode;
|
||||
defaultSize: { width: number; height: number };
|
||||
}
|
||||
export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<ComponentCategory | "all">("all");
|
||||
|
||||
// 컴포넌트 카테고리 정의 (실제 생성된 컴포넌트에 맞게)
|
||||
const COMPONENT_CATEGORIES = [
|
||||
{ id: "action", name: "액션", description: "사용자 동작을 처리하는 컴포넌트" },
|
||||
{ id: "layout", name: "레이아웃", description: "화면 구조를 제공하는 컴포넌트" },
|
||||
{ id: "data", name: "데이터", description: "데이터를 표시하는 컴포넌트" },
|
||||
{ id: "navigation", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" },
|
||||
{ id: "feedback", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" },
|
||||
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
|
||||
{ id: "other", name: "기타", description: "기타 컴포넌트" },
|
||||
];
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
return ComponentRegistry.getAllComponents();
|
||||
}, []);
|
||||
|
||||
export const ComponentsPanel: React.FC<ComponentsPanelProps> = ({ onDragStart }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
// 카테고리별 분류 (input 카테고리 제외)
|
||||
const componentsByCategory = useMemo(() => {
|
||||
// input 카테고리 컴포넌트들을 제외한 컴포넌트만 필터링
|
||||
const filteredComponents = allComponents.filter((component) => component.category !== "input");
|
||||
|
||||
// 데이터베이스에서 컴포넌트 가져오기
|
||||
const {
|
||||
data: componentsData,
|
||||
isLoading: loading,
|
||||
error,
|
||||
} = useComponents({
|
||||
active: "Y",
|
||||
});
|
||||
const categories: Record<ComponentCategory | "all", ComponentDefinition[]> = {
|
||||
all: filteredComponents, // input 카테고리 제외된 컴포넌트들만 포함
|
||||
input: [], // 빈 배열로 유지 (사용되지 않음)
|
||||
display: [],
|
||||
action: [],
|
||||
layout: [],
|
||||
utility: [],
|
||||
};
|
||||
|
||||
// 컴포넌트를 ComponentItem으로 변환
|
||||
const componentItems = useMemo(() => {
|
||||
if (!componentsData?.components) return [];
|
||||
filteredComponents.forEach((component) => {
|
||||
if (categories[component.category]) {
|
||||
categories[component.category].push(component);
|
||||
}
|
||||
});
|
||||
|
||||
return componentsData.components.map((component) => ({
|
||||
id: component.component_code,
|
||||
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]);
|
||||
return categories;
|
||||
}, [allComponents]);
|
||||
|
||||
// 필터링된 컴포넌트
|
||||
// 검색 및 필터링된 컴포넌트
|
||||
const filteredComponents = useMemo(() => {
|
||||
return componentItems.filter((component) => {
|
||||
const matchesSearch =
|
||||
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
component.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
let components = componentsByCategory[selectedCategory] || [];
|
||||
|
||||
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;
|
||||
});
|
||||
}, [componentItems, searchTerm, selectedCategory]);
|
||||
return components;
|
||||
}, [componentsByCategory, selectedCategory, searchQuery]);
|
||||
|
||||
// 카테고리별 그룹화
|
||||
const groupedComponents = useMemo(() => {
|
||||
const groups: Record<string, ComponentItem[]> = {};
|
||||
// 드래그 시작 핸들러
|
||||
const handleDragStart = (e: React.DragEvent, component: ComponentDefinition) => {
|
||||
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]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
// 컴포넌트 새로고침
|
||||
const handleRefresh = () => {
|
||||
// Hot Reload 트리거 (개발 모드에서만)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
ComponentRegistry.refreshComponents?.();
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
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">
|
||||
<Layers className="h-4 w-4 text-gray-600" />
|
||||
<h3 className="font-medium text-gray-900">컴포넌트</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredComponents.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">드래그하여 화면에 추가하세요</p>
|
||||
</div>
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Package className="mr-2 h-5 w-5" />
|
||||
컴포넌트 ({componentsByCategory.all.length})
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="space-y-3 border-b border-gray-200 p-4">
|
||||
{/* 검색 */}
|
||||
{/* 검색창 */}
|
||||
<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
|
||||
placeholder="컴포넌트 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-8 pl-9 text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 카테고리</SelectItem>
|
||||
{COMPONENT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
||||
>
|
||||
{/* 카테고리 탭 (input 카테고리 제외) */}
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
|
||||
<TabsTrigger value="all" className="flex items-center">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
전체
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="flex items-center">
|
||||
<Palette className="mr-1 h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="action" className="flex items-center">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
액션
|
||||
</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">
|
||||
{selectedCategory === "all" ? (
|
||||
// 카테고리별 그룹 표시
|
||||
<div className="space-y-4 p-4">
|
||||
{COMPONENT_CATEGORIES.map((category) => {
|
||||
const categoryComponents = groupedComponents[category.id];
|
||||
if (categoryComponents.length === 0) return null;
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="mt-4">
|
||||
<TabsContent value={selectedCategory} className="space-y-2">
|
||||
{filteredComponents.length > 0 ? (
|
||||
<div className="grid max-h-96 grid-cols-1 gap-2 overflow-y-auto">
|
||||
{filteredComponents.map((component) => (
|
||||
<div
|
||||
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}>
|
||||
<div className="mb-2 flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">{category.name}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{categoryComponents.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-gray-500">{category.description}</p>
|
||||
<div className="grid gap-2">
|
||||
{categoryComponents.map((component) => (
|
||||
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} />
|
||||
))}
|
||||
</div>
|
||||
{/* 새 컴포넌트 뱃지 */}
|
||||
<Badge variant="default" className="bg-green-500 text-xs">
|
||||
신규
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground truncate text-xs">{component.description}</p>
|
||||
|
||||
{/* 웹타입 및 크기 정보 */}
|
||||
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
|
||||
<span>웹타입: {component.webType}</span>
|
||||
<span>
|
||||
{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 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 className="p-4">
|
||||
<div className="grid gap-2">
|
||||
{filteredComponents.map((component) => (
|
||||
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{filteredComponents.length === 0 && (
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 컴포넌트 카드 컴포넌트
|
||||
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"><></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;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,30 @@ import { Settings } from "lucide-react";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent";
|
||||
import { ComponentData, WidgetComponent, FileComponent, WebTypeConfig, TableInfo } from "@/types/screen";
|
||||
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
||||
import {
|
||||
ComponentData,
|
||||
WidgetComponent,
|
||||
FileComponent,
|
||||
WebTypeConfig,
|
||||
TableInfo,
|
||||
LayoutComponent,
|
||||
} from "@/types/screen";
|
||||
// 레거시 ButtonConfigPanel 제거됨
|
||||
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 {
|
||||
selectedComponent?: ComponentData;
|
||||
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);
|
||||
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 사용
|
||||
const renderWebTypeConfig = (widget: WidgetComponent) => {
|
||||
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 (
|
||||
<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">
|
||||
상세 설정은 위젯, 파일, 버튼 컴포넌트에서만 사용할 수 있습니다.
|
||||
상세 설정은 위젯, 파일, 버튼, 컴포넌트, 레이아웃에서만 사용할 수 있습니다.
|
||||
<br />
|
||||
현재 선택된 컴포넌트: {selectedComponent.type}
|
||||
</p>
|
||||
|
|
@ -152,9 +932,45 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 버튼 컴포넌트인 경우 ButtonConfigPanel 렌더링
|
||||
// 레거시 버튼을 새로운 컴포넌트 시스템으로 강제 변환
|
||||
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 (
|
||||
<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="flex items-center space-x-2">
|
||||
<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 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">버튼</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">{componentId}</span>
|
||||
</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 className="flex-1 overflow-y-auto p-4">
|
||||
<ButtonConfigPanel
|
||||
component={buttonWidget}
|
||||
onUpdateComponent={(updates) => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
onUpdateProperty(buttonWidget.id, key, value);
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={componentId}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
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;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -191,9 +191,11 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
positionX: currentPosition.x.toString(),
|
||||
positionY: currentPosition.y.toString(),
|
||||
positionZ: selectedComponent?.position.z?.toString() || "1",
|
||||
width: selectedComponent?.size.width?.toString() || "0",
|
||||
height: selectedComponent?.size.height?.toString() || "0",
|
||||
gridColumns: selectedComponent?.gridColumns?.toString() || "1",
|
||||
width: selectedComponent?.size?.width?.toString() || "0",
|
||||
height: selectedComponent?.size?.height?.toString() || "0",
|
||||
gridColumns:
|
||||
selectedComponent?.gridColumns?.toString() ||
|
||||
(selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout" ? "8" : "1"),
|
||||
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
|
||||
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
|
||||
labelColor: selectedComponent?.style?.labelColor || "#374151",
|
||||
|
|
@ -244,14 +246,18 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
description: area?.description || "",
|
||||
positionX: currentPos.x.toString(),
|
||||
positionY: currentPos.y.toString(),
|
||||
positionZ: selectedComponent.position.z?.toString() || "1",
|
||||
width: selectedComponent.size.width?.toString() || "0",
|
||||
height: selectedComponent.size.height?.toString() || "0",
|
||||
gridColumns: selectedComponent.gridColumns?.toString() || "1",
|
||||
labelText: selectedComponent.style?.labelText || selectedComponent.label || "",
|
||||
labelFontSize: selectedComponent.style?.labelFontSize || "12px",
|
||||
labelColor: selectedComponent.style?.labelColor || "#374151",
|
||||
labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px",
|
||||
positionZ: selectedComponent?.position?.z?.toString() || "1",
|
||||
width: selectedComponent?.size?.width?.toString() || "0", // 안전한 접근
|
||||
height: selectedComponent?.size?.height?.toString() || "0", // 안전한 접근
|
||||
gridColumns:
|
||||
selectedComponent?.gridColumns?.toString() ||
|
||||
(selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout"
|
||||
? "8"
|
||||
: "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,
|
||||
readonly: widget?.readonly || false,
|
||||
labelDisplay: selectedComponent.style?.labelDisplay !== false,
|
||||
|
|
@ -529,7 +535,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, positionX: newValue }));
|
||||
onUpdateProperty("position", { ...selectedComponent.position, x: Number(newValue) });
|
||||
onUpdateProperty("position.x", Number(newValue));
|
||||
}}
|
||||
className={`mt-1 ${
|
||||
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
||||
|
|
@ -559,7 +565,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, positionY: newValue }));
|
||||
onUpdateProperty("position", { ...selectedComponent.position, y: Number(newValue) });
|
||||
onUpdateProperty("position.y", Number(newValue));
|
||||
}}
|
||||
className={`mt-1 ${
|
||||
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
||||
|
|
@ -570,39 +576,49 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="width" className="text-sm font-medium">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
value={localInputs.width}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, width: newValue }));
|
||||
onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) });
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
||||
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="width" className="text-sm font-medium">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
value={localInputs.width}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, width: newValue }));
|
||||
onUpdateProperty("size.width", Number(newValue));
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="height" className="text-sm font-medium">
|
||||
높이
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
value={localInputs.height}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, height: newValue }));
|
||||
onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) });
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="height" className="text-sm font-medium">
|
||||
높이
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
value={localInputs.height}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, height: newValue }));
|
||||
onUpdateProperty("size.height", Number(newValue));
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</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>
|
||||
<Label htmlFor="zIndex" className="text-sm font-medium">
|
||||
|
|
@ -617,7 +633,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, positionZ: newValue }));
|
||||
onUpdateProperty("position", { ...selectedComponent.position, z: Number(newValue) });
|
||||
onUpdateProperty("position.z", Number(newValue));
|
||||
}}
|
||||
className="mt-1"
|
||||
placeholder="1"
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import { WidgetComponent, TextTypeConfig } from "@/types/screen";
|
|||
|
||||
export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as TextTypeConfig | undefined;
|
||||
const { placeholder, required, style } = widget || {};
|
||||
const config = widget?.webTypeConfig as TextTypeConfig | undefined;
|
||||
|
||||
// 입력 타입에 따른 처리
|
||||
const isAutoInput = widget.inputType === "auto";
|
||||
const isAutoInput = widget?.inputType === "auto";
|
||||
|
||||
// 자동 값 생성 함수
|
||||
const getAutoValue = (autoValueType: string) => {
|
||||
|
|
@ -63,11 +63,11 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
|
||||
// 플레이스홀더 처리
|
||||
const finalPlaceholder = isAutoInput
|
||||
? getAutoPlaceholder(widget.autoValueType || "")
|
||||
? getAutoPlaceholder(widget?.autoValueType || "")
|
||||
: 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);
|
||||
|
|
@ -77,7 +77,7 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
|
||||
// 웹타입에 따른 input type 결정
|
||||
const getInputType = () => {
|
||||
switch (widget.widgetType) {
|
||||
switch (widget?.widgetType) {
|
||||
case "email":
|
||||
return "email";
|
||||
case "tel":
|
||||
|
|
@ -106,5 +106,3 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
};
|
||||
|
||||
TextWidget.displayName = "TextWidget";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)를 참조하세요.
|
||||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
이 가이드를 따라 새로운 컴포넌트, 웹타입, 템플릿을 성공적으로 추가할 수 있습니다. 추가 질문이나 문제가 발생하면 언제든지 문의해주세요! 🚀
|
||||
|
|
@ -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분이면 새 레이아웃 생성부터 화면편집기 등록까지 완료!**
|
||||
|
||||
---
|
||||
|
||||
📞 **문의사항이나 문제가 있으면 언제든 연락주세요!**
|
||||
|
|
@ -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
|
|
@ -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, // 실패 시 재시도 안함
|
||||
});
|
||||
};
|
||||
|
|
@ -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"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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)
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
},
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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
Loading…
Reference in New Issue