컴포넌트 화면편집기에 배치

This commit is contained in:
kjs 2025-09-10 14:09:32 +09:00
parent 3bf694ce24
commit 01860df8d7
56 changed files with 4572 additions and 778 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,8 @@ import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen"; import { ScreenDefinition, LayoutData } from "@/types/screen";
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic"; import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
@ -93,12 +95,13 @@ export default function ScreenViewPage() {
{layout && layout.components.length > 0 ? ( {layout && layout.components.length > 0 ? (
// 캔버스 컴포넌트들을 정확한 해상도로 표시 // 캔버스 컴포넌트들을 정확한 해상도로 표시
<div <div
className="relative mx-auto bg-white" className="relative bg-white"
style={{ style={{
width: `${screenWidth}px`, width: `${screenWidth}px`,
height: `${screenHeight}px`, height: `${screenHeight}px`,
minWidth: `${screenWidth}px`, minWidth: `${screenWidth}px`,
minHeight: `${screenHeight}px`, minHeight: `${screenHeight}px`,
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
}} }}
> >
{layout.components {layout.components
@ -202,27 +205,50 @@ export default function ScreenViewPage() {
position: "absolute", position: "absolute",
left: `${component.position.x}px`, left: `${component.position.x}px`,
top: `${component.position.y}px`, top: `${component.position.y}px`,
width: component.style?.width || `${component.size.width}px`, width: `${component.size.width}px`,
height: component.style?.height || `${component.size.height}px`, height: `${component.size.height}px`,
zIndex: component.position.z || 1, zIndex: component.position.z || 1,
}} }}
onMouseEnter={() => {
console.log("🎯 할당된 화면 컴포넌트:", {
id: component.id,
type: component.type,
position: component.position,
size: component.size,
styleWidth: component.style?.width,
styleHeight: component.style?.height,
finalWidth: `${component.size.width}px`,
finalHeight: `${component.size.height}px`,
});
}}
> >
<InteractiveScreenViewer {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
component={component} {component.type !== "widget" ? (
allComponents={layout.components} <DynamicComponentRenderer
formData={formData} component={component}
onFormDataChange={(fieldName, value) => { isInteractive={true}
setFormData((prev) => ({ formData={formData}
...prev, onFormDataChange={(fieldName, value) => {
[fieldName]: value, setFormData((prev) => ({
})); ...prev,
}} [fieldName]: value,
hideLabel={true} // 라벨 숨김 플래그 전달 }));
screenInfo={{ }}
id: screenId, />
tableName: screen?.tableName, ) : (
}} <DynamicWebTypeRenderer
/> webType={component.webType || "text"}
config={component.webTypeConfig}
isInteractive={true}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
/>
)}
</div> </div>
</div> </div>
); );

View File

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

View File

@ -10,6 +10,13 @@ import { toast } from "sonner";
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen"; import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable"; import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry"; import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
import "@/lib/registry/components/CardRenderer";
import "@/lib/registry/components/DashboardRenderer";
import "@/lib/registry/components/WidgetRenderer";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
@ -152,9 +159,22 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return renderFileComponent(comp as FileComponent); return renderFileComponent(comp as FileComponent);
} }
// 위젯 컴포넌트가 아닌 경우 // 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용
if (comp.type !== "widget") { if (comp.type !== "widget") {
return <div className="text-sm text-gray-500"> </div>; console.log("🎯 InteractiveScreenViewer - DynamicComponentRenderer 사용:", {
componentId: comp.id,
componentType: comp.type,
componentConfig: comp.componentConfig,
});
return (
<DynamicComponentRenderer
component={comp}
isInteractive={true}
formData={formData}
onFormDataChange={handleFormDataChange}
/>
);
} }
const widget = comp as WidgetComponent; const widget = comp as WidgetComponent;
@ -492,5 +512,3 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
export { InteractiveScreenViewerDynamic as InteractiveScreenViewer }; export { InteractiveScreenViewerDynamic as InteractiveScreenViewer };
InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic"; InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic";

View File

@ -1,17 +1,8 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen"; import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { Input } from "@/components/ui/input"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth";
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
import { import {
Database, Database,
Type, Type,
@ -24,26 +15,11 @@ import {
Code, Code,
Building, Building,
File, File,
Group,
ChevronDown,
ChevronRight,
Search,
RotateCcw,
Plus,
Edit,
Trash2,
Upload,
Square,
CreditCard,
Layout,
Grid3x3,
Columns,
Rows,
SidebarOpen,
Folder,
ChevronUp,
} from "lucide-react"; } from "lucide-react";
// 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components";
interface RealtimePreviewProps { interface RealtimePreviewProps {
component: ComponentData; component: ComponentData;
isSelected?: boolean; isSelected?: boolean;
@ -54,147 +30,31 @@ interface RealtimePreviewProps {
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
} }
// 영역 레이아웃에 따른 아이콘 반환 // 동적 위젯 타입 아이콘 (레지스트리에서 조회)
const getAreaIcon = (layoutType: AreaLayoutType) => { const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
switch (layoutType) { if (!widgetType) return <Type className="h-3 w-3" />;
case "flex":
return <Layout className="h-4 w-4 text-blue-600" />;
case "grid":
return <Grid3x3 className="h-4 w-4 text-green-600" />;
case "columns":
return <Columns className="h-4 w-4 text-purple-600" />;
case "rows":
return <Rows className="h-4 w-4 text-orange-600" />;
case "sidebar":
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
case "tabs":
return <Folder className="h-4 w-4 text-pink-600" />;
default:
return <Square className="h-4 w-4 text-gray-500" />;
}
};
// 영역 렌더링 const iconMap: Record<string, React.ReactNode> = {
const renderArea = (component: ComponentData, children?: React.ReactNode) => { text: <span className="text-xs">Aa</span>,
const area = component as AreaComponent; number: <Hash className="h-3 w-3" />,
const { areaType, label } = area; decimal: <Hash className="h-3 w-3" />,
date: <Calendar className="h-3 w-3" />,
const renderPlaceholder = () => ( datetime: <Calendar className="h-3 w-3" />,
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50"> select: <List className="h-3 w-3" />,
<div className="text-center"> dropdown: <List className="h-3 w-3" />,
{getAreaIcon(areaType)} textarea: <AlignLeft className="h-3 w-3" />,
<p className="mt-2 text-sm text-gray-600">{label || `${areaType} 영역`}</p> boolean: <CheckSquare className="h-3 w-3" />,
<p className="text-xs text-gray-400"> </p> checkbox: <CheckSquare className="h-3 w-3" />,
</div> radio: <Radio className="h-3 w-3" />,
</div> code: <Code className="h-3 w-3" />,
); entity: <Building className="h-3 w-3" />,
file: <File className="h-3 w-3" />,
return ( email: <span className="text-xs">@</span>,
<div className="relative h-full w-full"> tel: <span className="text-xs"></span>,
<div className="absolute inset-0 h-full w-full"> button: <span className="text-xs">BTN</span>,
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
</div>
</div>
);
};
// 동적 웹 타입 위젯 렌더링
const renderWidget = (component: ComponentData) => {
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (component.type !== "widget") {
return <div className="text-xs text-gray-500"> </div>;
}
const widget = component as WidgetComponent;
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
// 디버깅: 실제 widgetType 값 확인
console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
const borderClass = hasCustomBorder ? "!border-0" : "";
const commonProps = {
placeholder: placeholder || "입력하세요...",
disabled: readonly,
required: required,
className: `w-full h-full ${borderClass}`,
}; };
// 동적 웹타입 렌더링 사용 return iconMap[widgetType] || <Type className="h-3 w-3" />;
if (widgetType) {
try {
return (
<DynamicWebTypeRenderer
webType={widgetType}
props={{
...commonProps,
component: widget,
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
}}
config={widget.webTypeConfig}
/>
);
} catch (error) {
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
// 오류 발생 시 폴백으로 기본 input 렌더링
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
}
}
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
return <Input type="text" {...commonProps} />;
};
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
const getWidgetIcon = (widgetType: WebType | undefined) => {
if (!widgetType) {
return <Type className="h-4 w-4 text-gray-500" />;
}
// 레지스트리에서 웹타입 정의 조회
const webTypeDefinition = WebTypeRegistry.getWebType(widgetType);
if (webTypeDefinition && webTypeDefinition.icon) {
const IconComponent = webTypeDefinition.icon;
return <IconComponent className="h-4 w-4" />;
}
// 기본 아이콘 매핑 (하위 호환성)
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="h-4 w-4 text-blue-600" />;
case "number":
case "decimal":
return <Hash className="h-4 w-4 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-4 w-4 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-4 w-4 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="h-4 w-4 text-blue-600" />;
case "radio":
return <Radio className="h-4 w-4 text-blue-600" />;
case "code":
return <Code className="h-4 w-4 text-gray-600" />;
case "entity":
return <Building className="h-4 w-4 text-cyan-600" />;
case "file":
return <File className="h-4 w-4 text-yellow-600" />;
default:
return <Type className="h-4 w-4 text-gray-500" />;
}
}; };
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
@ -206,28 +66,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onGroupToggle, onGroupToggle,
children, children,
}) => { }) => {
const { user } = useAuth(); const { id, type, position, size, style: componentStyle } = component;
const { type, id, position, size, style = {} } = component;
// 컴포넌트 스타일 계산 // 선택 상태에 따른 스타일
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
top: position?.y || 0,
width: size?.width || 200,
height: size?.height || 40,
zIndex: position?.z || 1,
...style,
};
// 선택된 컴포넌트 스타일
const selectionStyle = isSelected const selectionStyle = isSelected
? { ? {
outline: "2px solid #3b82f6", outline: "2px solid #3b82f6",
outlineOffset: "2px", outlineOffset: "2px",
zIndex: 1000,
} }
: {}; : {};
// 컴포넌트 기본 스타일
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: position.z || 1,
...componentStyle,
};
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onClick?.(e); onClick?.(e);
@ -246,166 +105,22 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
<div <div
id={`component-${id}`} id={`component-${id}`}
className="absolute cursor-pointer" className="absolute cursor-pointer"
style={{ ...componentStyle, ...selectionStyle }} style={{ ...baseStyle, ...selectionStyle }}
onClick={handleClick} onClick={handleClick}
draggable draggable
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* 컴포넌트 타입별 렌더링 */} {/* 동적 컴포넌트 렌더링 */}
<div className="h-full w-full"> <div className="h-full w-full">
{/* 영역 타입 */} <DynamicComponentRenderer
{type === "area" && renderArea(component, children)} component={component}
isSelected={isSelected}
{/* 데이터 테이블 타입 */} onClick={onClick}
{type === "datatable" && onDragStart={onDragStart}
(() => { onDragEnd={onDragEnd}
const dataTableComponent = component as any; // DataTableComponent 타입 children={children}
/>
// 메모이제이션을 위한 계산 최적화
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>
)}
</div> </div>
{/* 선택된 컴포넌트 정보 표시 */} {/* 선택된 컴포넌트 정보 표시 */}
@ -417,7 +132,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{(component as WidgetComponent).widgetType || "widget"} {(component as WidgetComponent).widgetType || "widget"}
</div> </div>
)} )}
{type !== "widget" && type} {type !== "widget" && (
<div className="flex items-center gap-1">
<span>{component.componentConfig?.type || type}</span>
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -426,5 +145,4 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 기존 RealtimePreview와의 호환성을 위한 export // 기존 RealtimePreview와의 호환성을 위한 export
export { RealtimePreviewDynamic as RealtimePreview }; export { RealtimePreviewDynamic as RealtimePreview };
export default RealtimePreviewDynamic;
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";

View File

@ -40,7 +40,7 @@ import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { MenuAssignmentModal } from "./MenuAssignmentModal";
import StyleEditor from "./StyleEditor"; import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview"; import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel"; import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar"; import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel"; import TablesPanel from "./panels/TablesPanel";
@ -1292,13 +1292,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
// 새 컴포넌트 생성 // 새 컴포넌트 생성
console.log("🔍 ScreenDesigner handleComponentDrop:", {
componentName: component.name,
componentType: component.componentType,
webType: component.webType,
componentConfig: component.componentConfig,
finalType: component.componentType || "widget",
});
const newComponent: ComponentData = { const newComponent: ComponentData = {
id: generateComponentId(), id: generateComponentId(),
type: component.webType === "button" ? "button" : "widget", type: component.componentType || "widget", // 데이터베이스의 componentType 사용
label: component.name, label: component.name,
widgetType: component.webType, widgetType: component.webType,
position: snappedPosition, position: snappedPosition,
size: component.defaultSize, size: component.defaultSize,
componentConfig: component.componentConfig || {}, // 데이터베이스의 componentConfig 사용
webTypeConfig: getDefaultWebTypeConfig(component.webType), webTypeConfig: getDefaultWebTypeConfig(component.webType),
style: { style: {
labelDisplay: true, labelDisplay: true,
@ -3140,9 +3149,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
description: component.description, description: component.description,
category: component.category, category: component.category,
webType: component.webType, webType: component.webType,
componentType: component.componentType, // 추가!
componentConfig: component.componentConfig, // 추가!
defaultSize: component.defaultSize, defaultSize: component.defaultSize,
}, },
}; };
console.log("🚀 드래그 데이터 설정:", dragData);
e.dataTransfer.setData("application/json", JSON.stringify(dragData)); e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}} }}
/> />

View File

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

View File

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

View File

@ -1,138 +1,92 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React 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 { ComponentData } from "@/types/screen";
export const ButtonConfigPanel: React.FC<ConfigPanelProps> = ({ config: initialConfig, onConfigChange }) => { interface ButtonConfigPanelProps {
const [localConfig, setLocalConfig] = useState({ component: ComponentData;
label: "버튼", onUpdateProperty: (path: string, value: any) => void;
text: "", }
tooltip: "",
variant: "primary",
size: "medium",
disabled: false,
fullWidth: false,
...initialConfig,
});
useEffect(() => { export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
setLocalConfig({ const config = component.componentConfig || {};
label: "버튼",
text: "",
tooltip: "",
variant: "primary",
size: "medium",
disabled: false,
fullWidth: false,
...initialConfig,
});
}, [initialConfig]);
const updateConfig = (key: string, value: any) => {
const newConfig = { ...localConfig, [key]: value };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3"> <div>
<div> <Label htmlFor="button-text"> </Label>
<label htmlFor="button-label" className="mb-1 block text-sm font-medium text-gray-700"> <Input
id="button-text"
</label> value={config.text || "버튼"}
<input onChange={(e) => onUpdateProperty("componentConfig.text", e.target.value)}
id="button-label" placeholder="버튼 텍스트를 입력하세요"
type="text" />
value={localConfig.label || ""}
onChange={(e) => updateConfig("label", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="버튼에 표시될 텍스트"
/>
</div>
<div>
<label htmlFor="button-tooltip" className="mb-1 block text-sm font-medium text-gray-700">
()
</label>
<input
id="button-tooltip"
type="text"
value={localConfig.tooltip || ""}
onChange={(e) => updateConfig("tooltip", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="마우스 오버 시 표시될 텍스트"
/>
</div>
<div>
<label htmlFor="button-variant" className="mb-1 block text-sm font-medium text-gray-700">
</label>
<select
id="button-variant"
value={localConfig.variant || "primary"}
onChange={(e) => updateConfig("variant", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="primary"> ()</option>
<option value="secondary"> ()</option>
<option value="success"> ()</option>
<option value="warning"> ()</option>
<option value="danger"> ()</option>
<option value="outline"></option>
</select>
</div>
<div>
<label htmlFor="button-size" className="mb-1 block text-sm font-medium text-gray-700">
</label>
<select
id="button-size"
value={localConfig.size || "medium"}
onChange={(e) => updateConfig("size", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="small"></option>
<option value="medium"></option>
<option value="large"></option>
</select>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={localConfig.disabled || false}
onChange={(e) => updateConfig("disabled", e.target.checked)}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={localConfig.fullWidth || false}
onChange={(e) => updateConfig("fullWidth", e.target.checked)}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<span className="text-sm font-medium text-gray-700"> </span>
</label>
</div>
</div> </div>
<div className="border-t border-gray-200 pt-3"> <div>
<h4 className="mb-2 text-sm font-medium text-gray-700"></h4> <Label htmlFor="button-variant"> </Label>
<button <Select
type="button" value={config.variant || "default"}
disabled={localConfig.disabled} onValueChange={(value) => onUpdateProperty("componentConfig.variant", value)}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors duration-200 ${localConfig.size === "small" ? "px-3 py-1 text-xs" : ""} ${localConfig.size === "large" ? "px-6 py-3 text-base" : ""} ${localConfig.variant === "primary" ? "bg-blue-600 text-white hover:bg-blue-700" : ""} ${localConfig.variant === "secondary" ? "bg-gray-600 text-white hover:bg-gray-700" : ""} ${localConfig.variant === "success" ? "bg-green-600 text-white hover:bg-green-700" : ""} ${localConfig.variant === "warning" ? "bg-yellow-600 text-white hover:bg-yellow-700" : ""} ${localConfig.variant === "danger" ? "bg-red-600 text-white hover:bg-red-700" : ""} ${localConfig.variant === "outline" ? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50" : ""} ${localConfig.fullWidth ? "w-full" : ""} ${localConfig.disabled ? "cursor-not-allowed opacity-50" : ""} `}
title={localConfig.tooltip}
> >
{localConfig.label || "버튼"} <SelectTrigger>
</button> <SelectValue placeholder="버튼 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="primary"> (Primary)</SelectItem>
<SelectItem value="secondary"> (Secondary)</SelectItem>
<SelectItem value="danger"> (Danger)</SelectItem>
<SelectItem value="success"> (Success)</SelectItem>
<SelectItem value="outline"> (Outline)</SelectItem>
<SelectItem value="ghost"> (Ghost)</SelectItem>
<SelectItem value="link"> (Link)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<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 || "custom"}
onValueChange={(value) => onUpdateProperty("componentConfig.action", 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="custom"> </SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ interface ComponentItem {
category: string; category: string;
componentType: string; componentType: string;
componentConfig: any; componentConfig: any;
webType: string; // webType 추가
icon: React.ReactNode; icon: React.ReactNode;
defaultSize: { width: number; height: number }; defaultSize: { width: number; height: number };
} }
@ -30,6 +31,7 @@ const COMPONENT_CATEGORIES = [
{ id: "navigation", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" }, { id: "navigation", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" },
{ id: "feedback", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" }, { id: "feedback", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" },
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" }, { id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
{ id: "표시", name: "표시", description: "정보를 표시하고 알리는 컴포넌트" },
{ id: "other", name: "기타", description: "기타 컴포넌트" }, { id: "other", name: "기타", description: "기타 컴포넌트" },
]; ];
@ -50,16 +52,41 @@ export const ComponentsPanel: React.FC<ComponentsPanelProps> = ({ onDragStart })
const componentItems = useMemo(() => { const componentItems = useMemo(() => {
if (!componentsData?.components) return []; if (!componentsData?.components) return [];
return componentsData.components.map((component) => ({ return componentsData.components.map((component) => {
id: component.component_code, console.log("🔍 ComponentsPanel 컴포넌트 매핑:", {
name: component.component_name, component_code: component.component_code,
description: component.description || `${component.component_name} 컴포넌트`, component_name: component.component_name,
category: component.category || "other", component_config: component.component_config,
componentType: component.component_config?.type || component.component_code, componentType: component.component_config?.type || component.component_code,
componentConfig: component.component_config, webType: component.component_config?.type || component.component_code,
icon: getComponentIcon(component.icon_name || component.component_config?.type), category: component.category,
defaultSize: component.default_size || getDefaultSize(component.component_config?.type), });
}));
// 카테고리 매핑 (영어 -> 한국어)
const categoryMapping: Record<string, string> = {
display: "표시",
action: "액션",
layout: "레이아웃",
data: "데이터",
navigation: "네비게이션",
feedback: "피드백",
input: "입력",
};
const mappedCategory = categoryMapping[component.category] || component.category || "other";
return {
id: component.component_code,
name: component.component_name,
description: component.description || `${component.component_name} 컴포넌트`,
category: mappedCategory,
componentType: component.component_config?.type || component.component_code,
componentConfig: component.component_config,
webType: component.component_config?.type || component.component_code, // webType 추가
icon: getComponentIcon(component.icon_name || component.component_config?.type),
defaultSize: component.default_size || getDefaultSize(component.component_config?.type),
};
});
}, [componentsData]); }, [componentsData]);
// 필터링된 컴포넌트 // 필터링된 컴포넌트

View File

@ -9,6 +9,16 @@ import { ComponentData, WidgetComponent, FileComponent, WebTypeConfig, TableInfo
import { ButtonConfigPanel } from "./ButtonConfigPanel"; import { ButtonConfigPanel } from "./ButtonConfigPanel";
import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
// 새로운 컴포넌트 설정 패널들 import
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
interface DetailSettingsPanelProps { interface DetailSettingsPanelProps {
selectedComponent?: ComponentData; selectedComponent?: ComponentData;
onUpdateProperty: (componentId: string, path: string, value: any) => void; onUpdateProperty: (componentId: string, path: string, value: any) => void;
@ -106,6 +116,121 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
); );
} }
// 컴포넌트 타입별 설정 패널 렌더링
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 !== "widget" && selectedComponent.type !== "file" && selectedComponent.type !== "button") { if (selectedComponent.type !== "widget" && selectedComponent.type !== "file" && selectedComponent.type !== "button") {
return ( return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center"> <div className="flex h-full flex-col items-center justify-center p-6 text-center">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,120 @@
import { apiClient } from "./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: {
type: string;
webType?: string;
config_panel?: string;
};
preview_image?: string;
sort_order: number;
is_active: string;
is_public?: string;
company_code?: string;
created_by?: string;
updated_by?: string;
created_date?: string;
updated_date?: string;
}
export interface ComponentQueryParams {
category?: string;
active?: string;
is_public?: string;
search?: string;
sort?: string;
order?: "asc" | "desc";
limit?: number;
offset?: number;
}
export interface ComponentsResponse {
components: ComponentStandard[];
total: number;
limit?: number;
offset?: number;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
error?: string;
}
// 컴포넌트 목록 조회
export const getComponents = async (params?: ComponentQueryParams): Promise<ComponentsResponse> => {
const response = await apiClient.get<ApiResponse<ComponentsResponse>>("/admin/component-standards", {
params,
});
return response.data.data;
};
// 컴포넌트 상세 조회
export const getComponent = async (componentCode: string): Promise<ComponentStandard> => {
const response = await apiClient.get<ApiResponse<ComponentStandard>>(`/admin/component-standards/${componentCode}`);
return response.data.data;
};
// 컴포넌트 생성
export const createComponent = async (
data: Omit<ComponentStandard, "created_date" | "updated_date">,
): Promise<ComponentStandard> => {
const response = await apiClient.post<ApiResponse<ComponentStandard>>("/admin/component-standards", data);
return response.data.data;
};
// 컴포넌트 수정
export const updateComponent = async (
componentCode: string,
data: Partial<ComponentStandard>,
): Promise<ComponentStandard> => {
const response = await apiClient.put<ApiResponse<ComponentStandard>>(
`/admin/component-standards/${componentCode}`,
data,
);
return response.data.data;
};
// 컴포넌트 삭제
export const deleteComponent = async (componentCode: string): Promise<void> => {
await apiClient.delete(`/admin/component-standards/${componentCode}`);
};
// 컴포넌트 코드 중복 체크
export const checkComponentDuplicate = async (
componentCode: string,
): Promise<{ isDuplicate: boolean; component_code: string }> => {
const response = await apiClient.get<ApiResponse<{ isDuplicate: boolean; component_code: string }>>(
`/admin/component-standards/check-duplicate/${componentCode}`,
);
return response.data.data;
};
// 카테고리 목록 조회
export const getCategories = async (): Promise<string[]> => {
const response = await apiClient.get<ApiResponse<string[]>>("/admin/component-standards/categories");
return response.data.data;
};
// 통계 조회
export interface ComponentStatistics {
total: number;
byCategory: Array<{ category: string; count: number }>;
byStatus: Array<{ status: string; count: number }>;
}
export const getStatistics = async (): Promise<ComponentStatistics> => {
const response = await apiClient.get<ApiResponse<ComponentStatistics>>("/admin/component-standards/statistics");
return response.data.data;
};

View File

@ -0,0 +1,135 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
// 컴포넌트 렌더러 인터페이스
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;
[key: string]: any;
}): React.ReactElement;
}
// 컴포넌트 레지스트리
class ComponentRegistry {
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(`🔍 ComponentRegistry.has("${componentType}"):`, {
result,
availableKeys: Array.from(this.renderers.keys()),
mapSize: this.renderers.size,
});
return result;
}
}
// 전역 컴포넌트 레지스트리 인스턴스
export const componentRegistry = new ComponentRegistry();
// 동적 컴포넌트 렌더러 컴포넌트
export interface DynamicComponentRendererProps {
component: ComponentData;
isSelected?: boolean;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
children?: React.ReactNode;
[key: string]: any;
}
export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> = ({
component,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
children,
...props
}) => {
// component_config에서 실제 컴포넌트 타입 추출
const componentType = component.componentConfig?.type || component.type;
console.log("🎯 DynamicComponentRenderer:", {
componentId: component.id,
componentType,
componentConfig: component.componentConfig,
registeredTypes: componentRegistry.getRegisteredTypes(),
hasRenderer: componentRegistry.has(componentType),
actualRenderer: componentRegistry.get(componentType),
mapSize: componentRegistry.getRegisteredTypes().length,
});
// 등록된 렌더러 조회
const renderer = componentRegistry.get(componentType);
if (!renderer) {
console.warn(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`);
// 폴백 렌더링 - 기본 플레이스홀더
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-gray-600">{component.label || component.id}</div>
<div className="text-xs text-gray-400"> : {componentType}</div>
</div>
</div>
);
}
// 동적 렌더링 실행
try {
return renderer({
component,
isSelected,
onClick,
onDragStart,
onDragEnd,
children,
...props,
});
} catch (error) {
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);
// 오류 발생 시 폴백 렌더링
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-red-600"> </div>
<div className="text-xs text-red-400">
{componentType}: {error instanceof Error ? error.message : "알 수 없는 오류"}
</div>
</div>
</div>
);
}
};
export default DynamicComponentRenderer;

View File

@ -0,0 +1,57 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info, AlertTriangle, CheckCircle, XCircle } from "lucide-react";
// 알림 컴포넌트 렌더러
const AlertRenderer: ComponentRenderer = ({ component, ...props }) => {
const config = component.componentConfig || {};
const {
title = "알림 제목",
message = "알림 메시지입니다.",
type = "info", // info, warning, success, error
showIcon = true,
style = {},
} = config;
const getAlertIcon = () => {
switch (type) {
case "warning":
return <AlertTriangle className="h-4 w-4" />;
case "success":
return <CheckCircle className="h-4 w-4" />;
case "error":
return <XCircle className="h-4 w-4" />;
default:
return <Info className="h-4 w-4" />;
}
};
const getAlertVariant = () => {
switch (type) {
case "error":
return "destructive";
default:
return "default";
}
};
return (
<div className="flex h-full w-full items-center p-4" style={style}>
<Alert variant={getAlertVariant() as any} className="w-full">
{showIcon && getAlertIcon()}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("alert", AlertRenderer);
componentRegistry.register("alert-info", AlertRenderer);
export { AlertRenderer };

View File

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

View File

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

View File

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

View File

@ -0,0 +1,48 @@
"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>
);
};
// 레지스트리에 등록 - 모든 버튼 타입들
componentRegistry.register("button", ButtonRenderer);
componentRegistry.register("button-primary", ButtonRenderer);
componentRegistry.register("button-secondary", ButtonRenderer);
export { ButtonRenderer };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,56 @@
// 컴포넌트 렌더러들을 자동으로 등록하는 인덱스 파일
// 기존 컴포넌트 렌더러들 import
import "./AreaRenderer";
import "./GroupRenderer";
import "./WidgetRenderer";
import "./FileRenderer";
import "./DataTableRenderer";
// ACTION 카테고리
import "./ButtonRenderer";
// DATA 카테고리
import "./StatsCardRenderer";
import "./ProgressBarRenderer";
import "./ChartRenderer";
// FEEDBACK 카테고리
import "./AlertRenderer";
import "./BadgeRenderer";
import "./LoadingRenderer";
// INPUT 카테고리
import "./SearchBoxRenderer";
import "./FilterDropdownRenderer";
// LAYOUT 카테고리
import "./CardRenderer";
import "./DashboardRenderer";
import "./PanelRenderer";
// NAVIGATION 카테고리
import "./BreadcrumbRenderer";
import "./TabsRenderer";
import "./PaginationRenderer";
export * from "./AreaRenderer";
export * from "./GroupRenderer";
export * from "./WidgetRenderer";
export * from "./FileRenderer";
export * from "./DataTableRenderer";
export * from "./ButtonRenderer";
export * from "./StatsCardRenderer";
export * from "./ProgressBarRenderer";
export * from "./ChartRenderer";
export * from "./AlertRenderer";
export * from "./BadgeRenderer";
export * from "./LoadingRenderer";
export * from "./SearchBoxRenderer";
export * from "./FilterDropdownRenderer";
export * from "./CardRenderer";
export * from "./DashboardRenderer";
export * from "./PanelRenderer";
export * from "./BreadcrumbRenderer";
export * from "./TabsRenderer";
export * from "./PaginationRenderer";

View File

@ -11,7 +11,9 @@ import { FileTypeConfigPanel } from "@/components/screen/panels/webtype-configs/
import { CodeTypeConfigPanel } from "@/components/screen/panels/webtype-configs/CodeTypeConfigPanel"; import { CodeTypeConfigPanel } from "@/components/screen/panels/webtype-configs/CodeTypeConfigPanel";
import { EntityTypeConfigPanel } from "@/components/screen/panels/webtype-configs/EntityTypeConfigPanel"; import { EntityTypeConfigPanel } from "@/components/screen/panels/webtype-configs/EntityTypeConfigPanel";
import { RatingTypeConfigPanel } from "@/components/screen/panels/webtype-configs/RatingTypeConfigPanel"; import { RatingTypeConfigPanel } from "@/components/screen/panels/webtype-configs/RatingTypeConfigPanel";
import { ButtonConfigPanel } from "@/components/screen/config-panels/ButtonConfigPanel"; import { ButtonConfigPanel as OriginalButtonConfigPanel } from "@/components/screen/config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "@/components/screen/config-panels/CardConfigPanel";
import { DashboardConfigPanel } from "@/components/screen/config-panels/DashboardConfigPanel";
// 설정 패널 컴포넌트 타입 // 설정 패널 컴포넌트 타입
export type ConfigPanelComponent = React.ComponentType<{ export type ConfigPanelComponent = React.ComponentType<{
@ -19,6 +21,66 @@ export type ConfigPanelComponent = React.ComponentType<{
onConfigChange: (config: any) => void; onConfigChange: (config: any) => void;
}>; }>;
// ButtonConfigPanel 래퍼 (config/onConfigChange → component/onUpdateProperty 변환)
const ButtonConfigPanelWrapper: ConfigPanelComponent = ({ config, onConfigChange }) => {
// config를 component 형태로 변환
const mockComponent = {
id: "temp",
type: "widget" as const,
componentConfig: { type: "button" },
webTypeConfig: config,
style: {},
};
// onConfigChange를 onUpdateProperty 형태로 변환
const handleUpdateProperty = (path: string, value: any) => {
if (path.startsWith("webTypeConfig.")) {
const configKey = path.replace("webTypeConfig.", "");
onConfigChange({ ...config, [configKey]: value });
}
};
return <OriginalButtonConfigPanel component={mockComponent as any} onUpdateProperty={handleUpdateProperty} />;
};
// CardConfigPanel 래퍼
const CardConfigPanelWrapper: ConfigPanelComponent = ({ config, onConfigChange }) => {
const mockComponent = {
id: "temp",
type: "card" as const,
componentConfig: config,
style: {},
};
const handleUpdateProperty = (path: string, value: any) => {
if (path.startsWith("componentConfig.")) {
const configKey = path.replace("componentConfig.", "");
onConfigChange({ ...config, [configKey]: value });
}
};
return <CardConfigPanel component={mockComponent as any} onUpdateProperty={handleUpdateProperty} />;
};
// DashboardConfigPanel 래퍼
const DashboardConfigPanelWrapper: ConfigPanelComponent = ({ config, onConfigChange }) => {
const mockComponent = {
id: "temp",
type: "dashboard" as const,
componentConfig: config,
style: {},
};
const handleUpdateProperty = (path: string, value: any) => {
if (path.startsWith("componentConfig.")) {
const configKey = path.replace("componentConfig.", "");
onConfigChange({ ...config, [configKey]: value });
}
};
return <DashboardConfigPanel component={mockComponent as any} onUpdateProperty={handleUpdateProperty} />;
};
// 설정 패널 이름으로 직접 매핑하는 함수 (DB의 config_panel 필드용) // 설정 패널 이름으로 직접 매핑하는 함수 (DB의 config_panel 필드용)
export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent | null => { export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent | null => {
console.log(`🔧 getConfigPanelComponent 호출: panelName="${panelName}"`); console.log(`🔧 getConfigPanelComponent 호출: panelName="${panelName}"`);
@ -56,8 +118,14 @@ export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent
console.log(`🔧 RatingTypeConfigPanel 내용:`, RatingTypeConfigPanel); console.log(`🔧 RatingTypeConfigPanel 내용:`, RatingTypeConfigPanel);
return RatingTypeConfigPanel; return RatingTypeConfigPanel;
case "ButtonConfigPanel": case "ButtonConfigPanel":
console.log(`🔧 ButtonConfigPanel 컴포넌트 반환`); console.log(`🔧 ButtonConfigPanel 래퍼 컴포넌트 반환`);
return ButtonConfigPanel; return ButtonConfigPanelWrapper;
case "CardConfigPanel":
console.log(`🔧 CardConfigPanel 래퍼 컴포넌트 반환`);
return CardConfigPanelWrapper;
case "DashboardConfigPanel":
console.log(`🔧 DashboardConfigPanel 래퍼 컴포넌트 반환`);
return DashboardConfigPanelWrapper;
default: default:
console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`); console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`);
return null; // 기본 설정 (패널 없음) return null; // 기본 설정 (패널 없음)

View File

@ -19,7 +19,9 @@
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
@ -1607,6 +1609,42 @@
} }
} }
}, },
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
"integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
@ -1747,6 +1785,30 @@
} }
} }
}, },
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group": { "node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",

View File

@ -24,7 +24,9 @@
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",