컴포넌트 화면편집기에 배치
This commit is contained in:
parent
3bf694ce24
commit
01860df8d7
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 : "알 수 없는 오류",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { Response } from "express";
|
|
||||||
import { logger } from "./logger";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 처리 유틸리티
|
|
||||||
*/
|
|
||||||
export const handleError = (
|
|
||||||
res: Response,
|
|
||||||
error: any,
|
|
||||||
message: string = "서버 오류가 발생했습니다."
|
|
||||||
) => {
|
|
||||||
logger.error(`Error: ${message}`, error);
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
code: "SERVER_ERROR",
|
|
||||||
details: message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 잘못된 요청 에러 처리
|
|
||||||
*/
|
|
||||||
export const handleBadRequest = (
|
|
||||||
res: Response,
|
|
||||||
message: string = "잘못된 요청입니다."
|
|
||||||
) => {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
details: message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 찾을 수 없음 에러 처리
|
|
||||||
*/
|
|
||||||
export const handleNotFound = (
|
|
||||||
res: Response,
|
|
||||||
message: string = "요청한 리소스를 찾을 수 없습니다."
|
|
||||||
) => {
|
|
||||||
res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
details: message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 없음 에러 처리
|
|
||||||
*/
|
|
||||||
export const handleUnauthorized = (
|
|
||||||
res: Response,
|
|
||||||
message: string = "권한이 없습니다."
|
|
||||||
) => {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
details: message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
/**
|
|
||||||
* 유효성 검증 유틸리티
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필수 값 검증
|
|
||||||
*/
|
|
||||||
export const validateRequired = (value: any, fieldName: string): void => {
|
|
||||||
if (value === null || value === undefined || value === "") {
|
|
||||||
throw new Error(`${fieldName}은(는) 필수 입력값입니다.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 여러 필수 값 검증
|
|
||||||
*/
|
|
||||||
export const validateRequiredFields = (
|
|
||||||
data: Record<string, any>,
|
|
||||||
requiredFields: string[]
|
|
||||||
): void => {
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
validateRequired(data[field], field);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 문자열 길이 검증
|
|
||||||
*/
|
|
||||||
export const validateStringLength = (
|
|
||||||
value: string,
|
|
||||||
fieldName: string,
|
|
||||||
minLength?: number,
|
|
||||||
maxLength?: number
|
|
||||||
): void => {
|
|
||||||
if (minLength !== undefined && value.length < minLength) {
|
|
||||||
throw new Error(
|
|
||||||
`${fieldName}은(는) 최소 ${minLength}자 이상이어야 합니다.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxLength !== undefined && value.length > maxLength) {
|
|
||||||
throw new Error(`${fieldName}은(는) 최대 ${maxLength}자 이하여야 합니다.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이메일 형식 검증
|
|
||||||
*/
|
|
||||||
export const validateEmail = (email: string): boolean => {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
return emailRegex.test(email);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숫자 범위 검증
|
|
||||||
*/
|
|
||||||
export const validateNumberRange = (
|
|
||||||
value: number,
|
|
||||||
fieldName: string,
|
|
||||||
min?: number,
|
|
||||||
max?: number
|
|
||||||
): void => {
|
|
||||||
if (min !== undefined && value < min) {
|
|
||||||
throw new Error(`${fieldName}은(는) ${min} 이상이어야 합니다.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (max !== undefined && value > max) {
|
|
||||||
throw new Error(`${fieldName}은(는) ${max} 이하여야 합니다.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배열이 비어있지 않은지 검증
|
|
||||||
*/
|
|
||||||
export const validateNonEmptyArray = (
|
|
||||||
array: any[],
|
|
||||||
fieldName: string
|
|
||||||
): void => {
|
|
||||||
if (!Array.isArray(array) || array.length === 0) {
|
|
||||||
throw new Error(`${fieldName}은(는) 비어있을 수 없습니다.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필수 필드 검증 후 누락된 필드 목록 반환
|
|
||||||
*/
|
|
||||||
export const checkMissingFields = (
|
|
||||||
data: Record<string, any>,
|
|
||||||
requiredFields: string[]
|
|
||||||
): string[] => {
|
|
||||||
const missingFields: string[] = [];
|
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
const value = data[field];
|
|
||||||
if (value === null || value === undefined || value === "") {
|
|
||||||
missingFields.push(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return missingFields;
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface AlertConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertConfigPanel: React.FC<AlertConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alert-title">제목</Label>
|
||||||
|
<Input
|
||||||
|
id="alert-title"
|
||||||
|
value={config.title || "알림 제목"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
|
||||||
|
placeholder="알림 제목을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alert-message">메시지</Label>
|
||||||
|
<Textarea
|
||||||
|
id="alert-message"
|
||||||
|
value={config.message || "알림 메시지입니다."}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.message", e.target.value)}
|
||||||
|
placeholder="알림 메시지를 입력하세요"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alert-type">알림 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.type || "info"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.type", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="알림 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="info">정보 (Info)</SelectItem>
|
||||||
|
<SelectItem value="warning">경고 (Warning)</SelectItem>
|
||||||
|
<SelectItem value="success">성공 (Success)</SelectItem>
|
||||||
|
<SelectItem value="error">오류 (Error)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="show-icon"
|
||||||
|
checked={config.showIcon ?? true}
|
||||||
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showIcon", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-icon">아이콘 표시</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface BadgeConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeConfigPanel: React.FC<BadgeConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="badge-text">뱃지 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
id="badge-text"
|
||||||
|
value={config.text || "상태"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.text", e.target.value)}
|
||||||
|
placeholder="뱃지 텍스트를 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="badge-variant">뱃지 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.variant || "default"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.variant", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="뱃지 스타일 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">기본 (Default)</SelectItem>
|
||||||
|
<SelectItem value="secondary">보조 (Secondary)</SelectItem>
|
||||||
|
<SelectItem value="destructive">위험 (Destructive)</SelectItem>
|
||||||
|
<SelectItem value="outline">외곽선 (Outline)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="badge-size">뱃지 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={config.size || "default"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.size", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="뱃지 크기 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="small">작음 (Small)</SelectItem>
|
||||||
|
<SelectItem value="default">기본 (Default)</SelectItem>
|
||||||
|
<SelectItem value="large">큼 (Large)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface CardConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CardConfigPanel: React.FC<CardConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
const handleConfigChange = (key: string, value: any) => {
|
||||||
|
onUpdateProperty(`componentConfig.${key}`, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">카드 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 카드 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="card-title">카드 제목</Label>
|
||||||
|
<Input
|
||||||
|
id="card-title"
|
||||||
|
placeholder="카드 제목을 입력하세요"
|
||||||
|
value={config.title || "카드 제목"}
|
||||||
|
onChange={(e) => handleConfigChange("title", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 내용 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="card-content">카드 내용</Label>
|
||||||
|
<Textarea
|
||||||
|
id="card-content"
|
||||||
|
placeholder="카드 내용을 입력하세요"
|
||||||
|
value={config.content || "카드 내용 영역"}
|
||||||
|
onChange={(e) => handleConfigChange("content", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 스타일 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="card-variant">카드 스타일</Label>
|
||||||
|
<Select value={config.variant || "default"} onValueChange={(value) => handleConfigChange("variant", value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="카드 스타일 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">기본 (Default)</SelectItem>
|
||||||
|
<SelectItem value="outlined">테두리 (Outlined)</SelectItem>
|
||||||
|
<SelectItem value="elevated">그림자 (Elevated)</SelectItem>
|
||||||
|
<SelectItem value="filled">채움 (Filled)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 표시 여부 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="show-header"
|
||||||
|
checked={config.showHeader !== false}
|
||||||
|
onCheckedChange={(checked) => handleConfigChange("showHeader", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-header">헤더 표시</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 패딩 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="card-padding">패딩</Label>
|
||||||
|
<Select value={config.padding || "default"} onValueChange={(value) => handleConfigChange("padding", value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="패딩 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">없음 (None)</SelectItem>
|
||||||
|
<SelectItem value="small">작게 (Small)</SelectItem>
|
||||||
|
<SelectItem value="default">기본 (Default)</SelectItem>
|
||||||
|
<SelectItem value="large">크게 (Large)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배경색 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="background-color">배경색</Label>
|
||||||
|
<Input
|
||||||
|
id="background-color"
|
||||||
|
type="color"
|
||||||
|
value={config.backgroundColor || "#ffffff"}
|
||||||
|
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테두리 반경 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="border-radius">테두리 반경</Label>
|
||||||
|
<Input
|
||||||
|
id="border-radius"
|
||||||
|
placeholder="8px"
|
||||||
|
value={config.borderRadius || "8px"}
|
||||||
|
onChange={(e) => handleConfigChange("borderRadius", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface ChartConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="chart-title">차트 제목</Label>
|
||||||
|
<Input
|
||||||
|
id="chart-title"
|
||||||
|
value={config.title || "차트 제목"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
|
||||||
|
placeholder="차트 제목을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="chart-type">차트 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.chartType || "bar"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.chartType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="차트 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="bar">바 차트 (Bar)</SelectItem>
|
||||||
|
<SelectItem value="line">라인 차트 (Line)</SelectItem>
|
||||||
|
<SelectItem value="pie">파이 차트 (Pie)</SelectItem>
|
||||||
|
<SelectItem value="area">영역 차트 (Area)</SelectItem>
|
||||||
|
<SelectItem value="scatter">산점도 (Scatter)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="chart-data-source">데이터 소스</Label>
|
||||||
|
<Input
|
||||||
|
id="chart-data-source"
|
||||||
|
value={config.dataSource || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.dataSource", e.target.value)}
|
||||||
|
placeholder="데이터 소스 URL 또는 API 엔드포인트"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="chart-x-axis">X축 라벨</Label>
|
||||||
|
<Input
|
||||||
|
id="chart-x-axis"
|
||||||
|
value={config.xAxisLabel || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.xAxisLabel", e.target.value)}
|
||||||
|
placeholder="X축 라벨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="chart-y-axis">Y축 라벨</Label>
|
||||||
|
<Input
|
||||||
|
id="chart-y-axis"
|
||||||
|
value={config.yAxisLabel || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.yAxisLabel", e.target.value)}
|
||||||
|
placeholder="Y축 라벨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface DashboardConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardConfigPanel: React.FC<DashboardConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
const handleConfigChange = (key: string, value: any) => {
|
||||||
|
onUpdateProperty(`componentConfig.${key}`, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">대시보드 그리드 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 그리드 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="grid-title">그리드 제목</Label>
|
||||||
|
<Input
|
||||||
|
id="grid-title"
|
||||||
|
placeholder="그리드 제목을 입력하세요"
|
||||||
|
value={config.title || "대시보드 그리드"}
|
||||||
|
onChange={(e) => handleConfigChange("title", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 개수 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="grid-rows">행 개수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(config.rows || 2)}
|
||||||
|
onValueChange={(value) => handleConfigChange("rows", parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="행 개수 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1행</SelectItem>
|
||||||
|
<SelectItem value="2">2행</SelectItem>
|
||||||
|
<SelectItem value="3">3행</SelectItem>
|
||||||
|
<SelectItem value="4">4행</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 열 개수 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="grid-columns">열 개수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(config.columns || 3)}
|
||||||
|
onValueChange={(value) => handleConfigChange("columns", parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="열 개수 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1열</SelectItem>
|
||||||
|
<SelectItem value="2">2열</SelectItem>
|
||||||
|
<SelectItem value="3">3열</SelectItem>
|
||||||
|
<SelectItem value="4">4열</SelectItem>
|
||||||
|
<SelectItem value="6">6열</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 간격 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="grid-gap">그리드 간격</Label>
|
||||||
|
<Select value={config.gap || "medium"} onValueChange={(value) => handleConfigChange("gap", value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="간격 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">없음 (0px)</SelectItem>
|
||||||
|
<SelectItem value="small">작게 (8px)</SelectItem>
|
||||||
|
<SelectItem value="medium">보통 (16px)</SelectItem>
|
||||||
|
<SelectItem value="large">크게 (24px)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그리드 아이템 높이 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="item-height">아이템 높이</Label>
|
||||||
|
<Input
|
||||||
|
id="item-height"
|
||||||
|
placeholder="120px"
|
||||||
|
value={config.itemHeight || "120px"}
|
||||||
|
onChange={(e) => handleConfigChange("itemHeight", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 반응형 설정 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="responsive"
|
||||||
|
checked={config.responsive !== false}
|
||||||
|
onCheckedChange={(checked) => handleConfigChange("responsive", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="responsive">반응형 레이아웃</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테두리 표시 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="show-borders"
|
||||||
|
checked={config.showBorders !== false}
|
||||||
|
onCheckedChange={(checked) => handleConfigChange("showBorders", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-borders">그리드 테두리 표시</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배경색 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="background-color">배경색</Label>
|
||||||
|
<Input
|
||||||
|
id="background-color"
|
||||||
|
type="color"
|
||||||
|
value={config.backgroundColor || "#f8f9fa"}
|
||||||
|
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테두리 반경 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="border-radius">테두리 반경</Label>
|
||||||
|
<Input
|
||||||
|
id="border-radius"
|
||||||
|
placeholder="8px"
|
||||||
|
value={config.borderRadius || "8px"}
|
||||||
|
onChange={(e) => handleConfigChange("borderRadius", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface ProgressBarConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressBarConfigPanel: React.FC<ProgressBarConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="progress-label">라벨</Label>
|
||||||
|
<Input
|
||||||
|
id="progress-label"
|
||||||
|
value={config.label || "진행률"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.label", e.target.value)}
|
||||||
|
placeholder="진행률 라벨을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="progress-value">현재 값</Label>
|
||||||
|
<Input
|
||||||
|
id="progress-value"
|
||||||
|
type="number"
|
||||||
|
value={config.value || 65}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.value", parseInt(e.target.value) || 0)}
|
||||||
|
placeholder="현재 값"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="progress-max">최대 값</Label>
|
||||||
|
<Input
|
||||||
|
id="progress-max"
|
||||||
|
type="number"
|
||||||
|
value={config.max || 100}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.max", parseInt(e.target.value) || 100)}
|
||||||
|
placeholder="최대 값"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="progress-color">진행률 색상</Label>
|
||||||
|
<Input
|
||||||
|
id="progress-color"
|
||||||
|
type="color"
|
||||||
|
value={config.color || "#3b82f6"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.color", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="show-percentage"
|
||||||
|
checked={config.showPercentage ?? true}
|
||||||
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showPercentage", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-percentage">퍼센트 표시</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="show-value"
|
||||||
|
checked={config.showValue ?? true}
|
||||||
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showValue", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-value">값 표시</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface StatsCardConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatsCardConfigPanel: React.FC<StatsCardConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="stats-title">제목</Label>
|
||||||
|
<Input
|
||||||
|
id="stats-title"
|
||||||
|
value={config.title || "통계 제목"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
|
||||||
|
placeholder="통계 제목을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="stats-value">값</Label>
|
||||||
|
<Input
|
||||||
|
id="stats-value"
|
||||||
|
value={config.value || "1,234"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.value", e.target.value)}
|
||||||
|
placeholder="통계 값을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="stats-change">변화량</Label>
|
||||||
|
<Input
|
||||||
|
id="stats-change"
|
||||||
|
value={config.change || "+12.5%"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.change", e.target.value)}
|
||||||
|
placeholder="변화량을 입력하세요 (예: +12.5%)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="stats-trend">트렌드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.trend || "up"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.trend", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="트렌드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="up">상승 (Up)</SelectItem>
|
||||||
|
<SelectItem value="down">하락 (Down)</SelectItem>
|
||||||
|
<SelectItem value="neutral">중립 (Neutral)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="stats-description">설명</Label>
|
||||||
|
<Input
|
||||||
|
id="stats-description"
|
||||||
|
value={config.description || "전월 대비"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.description", e.target.value)}
|
||||||
|
placeholder="설명을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
// 필터링된 컴포넌트
|
// 필터링된 컴포넌트
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
|
|
@ -0,0 +1,795 @@
|
||||||
|
# 화면관리 시스템 컴포넌트 개발 가이드
|
||||||
|
|
||||||
|
화면관리 시스템에서 새로운 컴포넌트, 템플릿, 웹타입을 추가하는 완전한 가이드입니다.
|
||||||
|
|
||||||
|
## 🎯 목차
|
||||||
|
|
||||||
|
1. [컴포넌트 추가하기](#1-컴포넌트-추가하기)
|
||||||
|
2. [웹타입 추가하기](#2-웹타입-추가하기)
|
||||||
|
3. [템플릿 추가하기](#3-템플릿-추가하기)
|
||||||
|
4. [설정 패널 개발](#4-설정-패널-개발)
|
||||||
|
5. [데이터베이스 설정](#5-데이터베이스-설정)
|
||||||
|
6. [테스트 및 검증](#6-테스트-및-검증)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 컴포넌트 추가하기
|
||||||
|
|
||||||
|
### 1.1 컴포넌트 렌더러 생성
|
||||||
|
|
||||||
|
새로운 컴포넌트 렌더러를 생성합니다.
|
||||||
|
|
||||||
|
**파일 위치**: `frontend/lib/registry/components/{ComponentName}Renderer.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예시: AlertRenderer.tsx
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { AlertTriangle, Info, CheckCircle, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
const AlertRenderer: ComponentRenderer = ({
|
||||||
|
component,
|
||||||
|
children,
|
||||||
|
isInteractive,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
title = "알림",
|
||||||
|
message = "알림 메시지입니다.",
|
||||||
|
type = "info", // info, warning, success, error
|
||||||
|
showIcon = true,
|
||||||
|
style = {}
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// 타입별 아이콘 매핑
|
||||||
|
const iconMap = {
|
||||||
|
info: Info,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = iconMap[type as keyof typeof iconMap] || Info;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
className={`h-full w-full ${type === 'error' ? 'border-red-500' : ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{showIcon && <Icon className="h-4 w-4" />}
|
||||||
|
<AlertTitle>{title}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{isInteractive ? (
|
||||||
|
// 실제 할당된 화면에서는 설정된 메시지 표시
|
||||||
|
message
|
||||||
|
) : (
|
||||||
|
// 디자이너에서는 플레이스홀더 + children 표시
|
||||||
|
children && React.Children.count(children) > 0 ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{message}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">
|
||||||
|
알림 컴포넌트 - {type} 타입
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlertRenderer;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 컴포넌트 등록
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/registry/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 컴포넌트 렌더러 import 추가
|
||||||
|
import AlertRenderer from "./components/AlertRenderer";
|
||||||
|
|
||||||
|
// 컴포넌트 레지스트리에 등록
|
||||||
|
export const registerComponents = () => {
|
||||||
|
// 기존 컴포넌트들...
|
||||||
|
ComponentRegistry.register("alert", AlertRenderer);
|
||||||
|
ComponentRegistry.register("alert-info", AlertRenderer);
|
||||||
|
ComponentRegistry.register("alert-warning", AlertRenderer);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 InteractiveScreenViewer에 등록
|
||||||
|
|
||||||
|
**파일**: `frontend/components/screen/InteractiveScreenViewerDynamic.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||||
|
import "@/lib/registry/components/ButtonRenderer";
|
||||||
|
import "@/lib/registry/components/CardRenderer";
|
||||||
|
import "@/lib/registry/components/DashboardRenderer";
|
||||||
|
import "@/lib/registry/components/AlertRenderer"; // 추가
|
||||||
|
import "@/lib/registry/components/WidgetRenderer";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 웹타입 추가하기
|
||||||
|
|
||||||
|
### 2.1 웹타입 컴포넌트 생성
|
||||||
|
|
||||||
|
**파일 위치**: `frontend/components/screen/widgets/types/{WebTypeName}Widget.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예시: ColorPickerWidget.tsx
|
||||||
|
import React from "react";
|
||||||
|
import { WebTypeComponentProps } from "@/types/screen";
|
||||||
|
import { WidgetComponent } from "@/types/screen";
|
||||||
|
|
||||||
|
interface ColorPickerConfig {
|
||||||
|
defaultColor?: string;
|
||||||
|
showAlpha?: boolean;
|
||||||
|
presetColors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorPickerWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
|
component,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readonly = false
|
||||||
|
}) => {
|
||||||
|
const widget = component as WidgetComponent;
|
||||||
|
const { placeholder, required, style } = widget || {};
|
||||||
|
const config = widget?.webTypeConfig as ColorPickerConfig | undefined;
|
||||||
|
|
||||||
|
const handleColorChange = (color: string) => {
|
||||||
|
if (!readonly && onChange) {
|
||||||
|
onChange(color);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full" style={style}>
|
||||||
|
{/* 라벨 표시 */}
|
||||||
|
{widget?.label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium">
|
||||||
|
{widget.label}
|
||||||
|
{required && <span className="text-orange-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* 색상 입력 */}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value || config?.defaultColor || "#000000"}
|
||||||
|
onChange={(e) => handleColorChange(e.target.value)}
|
||||||
|
disabled={readonly}
|
||||||
|
className="h-10 w-16 rounded border border-gray-300 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 색상 값 표시 */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || config?.defaultColor || "#000000"}
|
||||||
|
onChange={(e) => handleColorChange(e.target.value)}
|
||||||
|
placeholder={placeholder || "색상을 선택하세요"}
|
||||||
|
disabled={readonly}
|
||||||
|
className="flex-1 h-10 px-3 rounded border border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리 설정된 색상들 */}
|
||||||
|
{config?.presetColors && (
|
||||||
|
<div className="mt-2 flex gap-1 flex-wrap">
|
||||||
|
{config.presetColors.map((color, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleColorChange(color)}
|
||||||
|
disabled={readonly}
|
||||||
|
className="w-6 h-6 rounded border border-gray-300 cursor-pointer hover:scale-110 transition-transform disabled:cursor-not-allowed"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
title={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 웹타입 등록
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/registry/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 웹타입 컴포넌트 import 추가
|
||||||
|
import { ColorPickerWidget } from "@/components/screen/widgets/types/ColorPickerWidget";
|
||||||
|
|
||||||
|
// 웹타입 레지스트리에 등록
|
||||||
|
export const registerWebTypes = () => {
|
||||||
|
// 기존 웹타입들...
|
||||||
|
WebTypeRegistry.register("color", ColorPickerWidget);
|
||||||
|
WebTypeRegistry.register("colorpicker", ColorPickerWidget);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 웹타입 설정 인터페이스 추가
|
||||||
|
|
||||||
|
**파일**: `frontend/types/screen.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 웹타입별 설정 인터페이스에 추가
|
||||||
|
export interface ColorPickerTypeConfig {
|
||||||
|
defaultColor?: string;
|
||||||
|
showAlpha?: boolean;
|
||||||
|
presetColors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 웹타입 설정 유니온에 추가
|
||||||
|
export type WebTypeConfig =
|
||||||
|
| TextTypeConfig
|
||||||
|
| NumberTypeConfig
|
||||||
|
| DateTypeConfig
|
||||||
|
| SelectTypeConfig
|
||||||
|
| FileTypeConfig
|
||||||
|
| ColorPickerTypeConfig; // 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 템플릿 추가하기
|
||||||
|
|
||||||
|
### 3.1 템플릿 컴포넌트 생성
|
||||||
|
|
||||||
|
**파일 위치**: `frontend/components/screen/templates/{TemplateName}Template.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예시: ContactFormTemplate.tsx
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { generateComponentId } from "@/lib/utils/componentUtils";
|
||||||
|
|
||||||
|
interface ContactFormTemplateProps {
|
||||||
|
onAddComponents: (components: ComponentData[]) => void;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContactFormTemplate: React.FC<ContactFormTemplateProps> = ({
|
||||||
|
onAddComponents,
|
||||||
|
position,
|
||||||
|
}) => {
|
||||||
|
const createContactForm = () => {
|
||||||
|
const components: ComponentData[] = [
|
||||||
|
// 컨테이너
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "container",
|
||||||
|
position: position,
|
||||||
|
size: { width: 500, height: 600 },
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 제목
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "widget",
|
||||||
|
webType: "text",
|
||||||
|
position: { x: position.x + 24, y: position.y + 24 },
|
||||||
|
size: { width: 452, height: 40 },
|
||||||
|
label: "연락처 양식",
|
||||||
|
placeholder: "연락처를 입력해주세요",
|
||||||
|
style: {
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#1f2937",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이름 입력
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "widget",
|
||||||
|
webType: "text",
|
||||||
|
position: { x: position.x + 24, y: position.y + 84 },
|
||||||
|
size: { width: 452, height: 40 },
|
||||||
|
label: "이름",
|
||||||
|
placeholder: "이름을 입력하세요",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이메일 입력
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "widget",
|
||||||
|
webType: "email",
|
||||||
|
position: { x: position.x + 24, y: position.y + 144 },
|
||||||
|
size: { width: 452, height: 40 },
|
||||||
|
label: "이메일",
|
||||||
|
placeholder: "이메일을 입력하세요",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 전화번호 입력
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "widget",
|
||||||
|
webType: "tel",
|
||||||
|
position: { x: position.x + 24, y: position.y + 204 },
|
||||||
|
size: { width: 452, height: 40 },
|
||||||
|
label: "전화번호",
|
||||||
|
placeholder: "전화번호를 입력하세요",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 메시지 입력
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "widget",
|
||||||
|
webType: "textarea",
|
||||||
|
position: { x: position.x + 24, y: position.y + 264 },
|
||||||
|
size: { width: 452, height: 120 },
|
||||||
|
label: "메시지",
|
||||||
|
placeholder: "메시지를 입력하세요",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 제출 버튼
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "button",
|
||||||
|
componentType: "button-primary",
|
||||||
|
position: { x: position.x + 24, y: position.y + 404 },
|
||||||
|
size: { width: 120, height: 40 },
|
||||||
|
componentConfig: {
|
||||||
|
text: "제출",
|
||||||
|
actionType: "submit",
|
||||||
|
style: "primary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 취소 버튼
|
||||||
|
{
|
||||||
|
id: generateComponentId(),
|
||||||
|
type: "button",
|
||||||
|
componentType: "button-secondary",
|
||||||
|
position: { x: position.x + 164, y: position.y + 404 },
|
||||||
|
size: { width: 120, height: 40 },
|
||||||
|
componentConfig: {
|
||||||
|
text: "취소",
|
||||||
|
actionType: "cancel",
|
||||||
|
style: "secondary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
onAddComponents(components);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4 hover:border-blue-400 hover:bg-blue-50"
|
||||||
|
onClick={createContactForm}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-medium">연락처 양식</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-600">
|
||||||
|
이름, 이메일, 전화번호, 메시지 입력이 포함된 연락처 양식
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 템플릿 패널에 등록
|
||||||
|
|
||||||
|
**파일**: `frontend/components/screen/panels/TemplatesPanel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 템플릿 import 추가
|
||||||
|
import { ContactFormTemplate } from "@/components/screen/templates/ContactFormTemplate";
|
||||||
|
|
||||||
|
// 템플릿 목록에 추가
|
||||||
|
const templates = [
|
||||||
|
// 기존 템플릿들...
|
||||||
|
{
|
||||||
|
id: "contact-form",
|
||||||
|
name: "연락처 양식",
|
||||||
|
description: "이름, 이메일, 전화번호, 메시지가 포함된 연락처 양식",
|
||||||
|
component: ContactFormTemplate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 설정 패널 개발
|
||||||
|
|
||||||
|
### 4.1 설정 패널 컴포넌트 생성
|
||||||
|
|
||||||
|
**파일 위치**: `frontend/components/screen/config-panels/{ComponentName}ConfigPanel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예시: AlertConfigPanel.tsx
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface AlertConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertConfigPanel: React.FC<AlertConfigPanelProps> = ({
|
||||||
|
component,
|
||||||
|
onUpdateProperty,
|
||||||
|
}) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">알림 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 제목 설정 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alert-title" className="text-xs">제목</Label>
|
||||||
|
<Input
|
||||||
|
id="alert-title"
|
||||||
|
value={config.title || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
|
||||||
|
placeholder="알림 제목"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메시지 설정 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alert-message" className="text-xs">메시지</Label>
|
||||||
|
<Textarea
|
||||||
|
id="alert-message"
|
||||||
|
value={config.message || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.message", e.target.value)}
|
||||||
|
placeholder="알림 메시지를 입력하세요"
|
||||||
|
className="min-h-[60px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타입 설정 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">알림 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.type || "info"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.type", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="info">정보</SelectItem>
|
||||||
|
<SelectItem value="warning">경고</SelectItem>
|
||||||
|
<SelectItem value="success">성공</SelectItem>
|
||||||
|
<SelectItem value="error">오류</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아이콘 표시 설정 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="show-icon" className="text-xs">아이콘 표시</Label>
|
||||||
|
<Switch
|
||||||
|
id="show-icon"
|
||||||
|
checked={config.showIcon ?? true}
|
||||||
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showIcon", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스타일 설정 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alert-bg-color" className="text-xs">배경 색상</Label>
|
||||||
|
<Input
|
||||||
|
id="alert-bg-color"
|
||||||
|
type="color"
|
||||||
|
value={config.style?.backgroundColor || "#ffffff"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.style.backgroundColor", e.target.value)}
|
||||||
|
className="h-8 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테두리 반경 설정 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="border-radius" className="text-xs">테두리 반경 (px)</Label>
|
||||||
|
<Input
|
||||||
|
id="border-radius"
|
||||||
|
type="number"
|
||||||
|
value={parseInt(config.style?.borderRadius || "6") || 6}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.style.borderRadius", `${e.target.value}px`)}
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 설정 패널 등록
|
||||||
|
|
||||||
|
**파일**: `frontend/components/screen/panels/DetailSettingsPanel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 설정 패널 import 추가
|
||||||
|
import { AlertConfigPanel } from "@/components/screen/config-panels/AlertConfigPanel";
|
||||||
|
|
||||||
|
// hasNewConfigPanel 배열에 추가
|
||||||
|
const hasNewConfigPanel =
|
||||||
|
componentType &&
|
||||||
|
[
|
||||||
|
"button",
|
||||||
|
"button-primary",
|
||||||
|
"button-secondary",
|
||||||
|
"card",
|
||||||
|
"dashboard",
|
||||||
|
"alert", // 추가
|
||||||
|
"alert-info", // 추가
|
||||||
|
// 기타 컴포넌트들...
|
||||||
|
].includes(componentType);
|
||||||
|
|
||||||
|
// switch 문에 케이스 추가
|
||||||
|
switch (componentType) {
|
||||||
|
case "button":
|
||||||
|
case "button-primary":
|
||||||
|
case "button-secondary":
|
||||||
|
return <NewButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
||||||
|
case "card":
|
||||||
|
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
||||||
|
case "dashboard":
|
||||||
|
return <DashboardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
||||||
|
case "alert": // 추가
|
||||||
|
case "alert-info":
|
||||||
|
return <AlertConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
||||||
|
// 기타 케이스들...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 데이터베이스 설정
|
||||||
|
|
||||||
|
### 5.1 component_standards 테이블에 데이터 추가
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// backend-node/scripts/add-new-component.js
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function addNewComponents() {
|
||||||
|
// 알림 컴포넌트들 추가
|
||||||
|
const alertComponents = [
|
||||||
|
{
|
||||||
|
component_code: "alert-info",
|
||||||
|
component_name: "정보 알림",
|
||||||
|
category: "display",
|
||||||
|
description: "정보성 내용을 표시하는 알림 컴포넌트",
|
||||||
|
component_config: {
|
||||||
|
type: "alert",
|
||||||
|
config_panel: "AlertConfigPanel",
|
||||||
|
},
|
||||||
|
default_size: {
|
||||||
|
width: 400,
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
icon_name: "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component_code: "alert-warning",
|
||||||
|
component_name: "경고 알림",
|
||||||
|
category: "display",
|
||||||
|
description: "경고 내용을 표시하는 알림 컴포넌트",
|
||||||
|
component_config: {
|
||||||
|
type: "alert",
|
||||||
|
config_panel: "AlertConfigPanel",
|
||||||
|
},
|
||||||
|
default_size: {
|
||||||
|
width: 400,
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
icon_name: "alert-triangle",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 웹타입 추가
|
||||||
|
const webTypes = [
|
||||||
|
{
|
||||||
|
component_code: "color-picker",
|
||||||
|
component_name: "색상 선택기",
|
||||||
|
category: "input",
|
||||||
|
description: "색상을 선택할 수 있는 입력 컴포넌트",
|
||||||
|
component_config: {
|
||||||
|
type: "widget",
|
||||||
|
webType: "color",
|
||||||
|
},
|
||||||
|
default_size: {
|
||||||
|
width: 200,
|
||||||
|
height: 40,
|
||||||
|
},
|
||||||
|
icon_name: "palette",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 데이터베이스에 삽입
|
||||||
|
for (const component of [...alertComponents, ...webTypes]) {
|
||||||
|
await prisma.component_standards.create({
|
||||||
|
data: component,
|
||||||
|
});
|
||||||
|
console.log(`✅ ${component.component_name} 추가 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🎉 모든 컴포넌트 추가 완료!");
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
addNewComponents().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 스크립트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend-node
|
||||||
|
node scripts/add-new-component.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 및 검증
|
||||||
|
|
||||||
|
### 6.1 개발 서버 재시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프론트엔드 재시작
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 백엔드 재시작 (필요시)
|
||||||
|
cd ../backend-node
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 테스트 체크리스트
|
||||||
|
|
||||||
|
#### ✅ 컴포넌트 패널 확인
|
||||||
|
|
||||||
|
- [ ] 새 컴포넌트가 컴포넌트 패널에 표시됨
|
||||||
|
- [ ] 카테고리별로 올바르게 분류됨
|
||||||
|
- [ ] 아이콘이 올바르게 표시됨
|
||||||
|
|
||||||
|
#### ✅ 드래그앤드롭 확인
|
||||||
|
|
||||||
|
- [ ] 컴포넌트를 캔버스에 드래그 가능
|
||||||
|
- [ ] 기본 크기로 올바르게 배치됨
|
||||||
|
- [ ] 컴포넌트가 텍스트박스가 아닌 실제 형태로 렌더링됨
|
||||||
|
|
||||||
|
#### ✅ 속성 편집 확인
|
||||||
|
|
||||||
|
- [ ] 컴포넌트 선택 시 속성 패널에 기본 속성 표시
|
||||||
|
- [ ] 상세 설정 패널이 올바르게 표시됨
|
||||||
|
- [ ] 설정값 변경이 실시간으로 반영됨
|
||||||
|
|
||||||
|
#### ✅ 할당된 화면 확인
|
||||||
|
|
||||||
|
- [ ] 화면 저장 후 메뉴에 할당
|
||||||
|
- [ ] 할당된 화면에서 컴포넌트가 올바르게 표시됨
|
||||||
|
- [ ] 위치와 크기가 편집기와 동일함
|
||||||
|
- [ ] 인터랙티브 모드로 올바르게 동작함
|
||||||
|
|
||||||
|
### 6.3 문제 해결
|
||||||
|
|
||||||
|
#### 컴포넌트가 텍스트박스로 표시되는 경우
|
||||||
|
|
||||||
|
1. `DynamicComponentRenderer.tsx`에서 컴포넌트가 등록되었는지 확인
|
||||||
|
2. `InteractiveScreenViewerDynamic.tsx`에서 import 되었는지 확인
|
||||||
|
3. 브라우저 콘솔에서 레지스트리 등록 로그 확인
|
||||||
|
|
||||||
|
#### 설정 패널이 표시되지 않는 경우
|
||||||
|
|
||||||
|
1. `DetailSettingsPanel.tsx`의 `hasNewConfigPanel` 배열 확인
|
||||||
|
2. switch 문에 케이스가 추가되었는지 확인
|
||||||
|
3. 데이터베이스의 `config_panel` 값 확인
|
||||||
|
|
||||||
|
#### 할당된 화면에서 렌더링 안 되는 경우
|
||||||
|
|
||||||
|
1. `InteractiveScreenViewerDynamic.tsx`에서 import 확인
|
||||||
|
2. 컴포넌트 렌더러에서 `isInteractive` prop 처리 확인
|
||||||
|
3. 브라우저 콘솔에서 오류 메시지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 모범 사례
|
||||||
|
|
||||||
|
### 1. 컴포넌트 네이밍
|
||||||
|
|
||||||
|
- 컴포넌트 코드: `kebab-case` (예: `alert-info`, `contact-form`)
|
||||||
|
- 파일명: `PascalCase` (예: `AlertRenderer.tsx`, `ContactFormTemplate.tsx`)
|
||||||
|
- 클래스명: `camelCase` (예: `alertContainer`, `formInput`)
|
||||||
|
|
||||||
|
### 2. 설정 구조
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 일관된 설정 구조 사용
|
||||||
|
interface ComponentConfig {
|
||||||
|
// 기본 설정
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// 표시 설정
|
||||||
|
showIcon?: boolean;
|
||||||
|
showBorder?: boolean;
|
||||||
|
|
||||||
|
// 스타일 설정
|
||||||
|
style?: {
|
||||||
|
backgroundColor?: string;
|
||||||
|
borderRadius?: string;
|
||||||
|
padding?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트별 전용 설정
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 반응형 지원
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 컨테이너 크기에 따른 반응형 처리
|
||||||
|
const isSmall = component.size.width < 300;
|
||||||
|
const columns = isSmall ? 1 : 3;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 접근성 고려
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 접근성 속성 추가
|
||||||
|
<button
|
||||||
|
aria-label={config.ariaLabel || config.text}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{config.text}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고 자료
|
||||||
|
|
||||||
|
- [Shadcn/ui 컴포넌트 문서](https://ui.shadcn.com)
|
||||||
|
- [Lucide 아이콘 문서](https://lucide.dev)
|
||||||
|
- [React Hook Form 문서](https://react-hook-form.com)
|
||||||
|
- [TypeScript 타입 정의 가이드](https://www.typescriptlang.org/docs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
이 가이드를 따라 새로운 컴포넌트, 웹타입, 템플릿을 성공적으로 추가할 수 있습니다. 추가 질문이나 문제가 발생하면 언제든지 문의해주세요! 🚀
|
||||||
|
|
@ -0,0 +1,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, // 실패 시 재시도 안함
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Info, AlertTriangle, CheckCircle, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
// 알림 컴포넌트 렌더러
|
||||||
|
const AlertRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
title = "알림 제목",
|
||||||
|
message = "알림 메시지입니다.",
|
||||||
|
type = "info", // info, warning, success, error
|
||||||
|
showIcon = true,
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const getAlertIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "warning":
|
||||||
|
return <AlertTriangle className="h-4 w-4" />;
|
||||||
|
case "success":
|
||||||
|
return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case "error":
|
||||||
|
return <XCircle className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <Info className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlertVariant = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "error":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center p-4" style={style}>
|
||||||
|
<Alert variant={getAlertVariant() as any} className="w-full">
|
||||||
|
{showIcon && getAlertIcon()}
|
||||||
|
<AlertTitle>{title}</AlertTitle>
|
||||||
|
<AlertDescription>{message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("alert", AlertRenderer);
|
||||||
|
componentRegistry.register("alert-info", AlertRenderer);
|
||||||
|
|
||||||
|
export { AlertRenderer };
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData, AreaComponent, AreaLayoutType } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Square, CreditCard, Layout, Grid3x3, Columns, Rows, SidebarOpen, Folder } from "lucide-react";
|
||||||
|
|
||||||
|
// 영역 레이아웃에 따른 아이콘 반환
|
||||||
|
const getAreaIcon = (layoutType: AreaLayoutType): React.ReactNode => {
|
||||||
|
const iconMap: Record<AreaLayoutType, React.ReactNode> = {
|
||||||
|
container: <Square className="h-4 w-4" />,
|
||||||
|
card: <CreditCard className="h-4 w-4" />,
|
||||||
|
panel: <Layout className="h-4 w-4" />,
|
||||||
|
grid: <Grid3x3 className="h-4 w-4" />,
|
||||||
|
flex_row: <Columns className="h-4 w-4" />,
|
||||||
|
flex_column: <Rows className="h-4 w-4" />,
|
||||||
|
sidebar: <SidebarOpen className="h-4 w-4" />,
|
||||||
|
section: <Folder className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
return iconMap[layoutType] || <Square className="h-4 w-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 영역 렌더링 함수
|
||||||
|
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||||
|
const area = component as AreaComponent;
|
||||||
|
const { title, description, layoutType = "container" } = area;
|
||||||
|
|
||||||
|
const renderPlaceholder = () => (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
{getAreaIcon(layoutType)}
|
||||||
|
<div className="mt-2 text-sm font-medium text-gray-600">{title || "영역"}</div>
|
||||||
|
{description && <div className="mt-1 text-xs text-gray-400">{description}</div>}
|
||||||
|
<div className="mt-1 text-xs text-gray-400">레이아웃: {layoutType}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full rounded border border-dashed border-gray-300 bg-gray-50 p-2">
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 영역 컴포넌트 렌더러
|
||||||
|
const AreaRenderer: ComponentRenderer = ({ component, children, ...props }) => {
|
||||||
|
return renderArea(component, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("area", AreaRenderer);
|
||||||
|
|
||||||
|
export { AreaRenderer };
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
// 뱃지 컴포넌트 렌더러
|
||||||
|
const BadgeRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
text = "상태",
|
||||||
|
variant = "default", // default, secondary, destructive, outline
|
||||||
|
size = "default",
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const badgeVariant = variant as "default" | "secondary" | "destructive" | "outline";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center" style={style}>
|
||||||
|
<Badge variant={badgeVariant} className="pointer-events-none">
|
||||||
|
{text}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("badge", BadgeRenderer);
|
||||||
|
componentRegistry.register("badge-status", BadgeRenderer);
|
||||||
|
|
||||||
|
export { BadgeRenderer };
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
|
||||||
|
// 브레드크럼 컴포넌트 렌더러
|
||||||
|
const BreadcrumbRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
items = [{ label: "홈", href: "/" }, { label: "관리자", href: "/admin" }, { label: "현재 페이지" }],
|
||||||
|
separator = "/",
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center p-2" style={style}>
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{items.map((item: any, index: number) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{index === items.length - 1 ? (
|
||||||
|
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink href={item.href || "#"} className="pointer-events-none">
|
||||||
|
{item.label}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{index < items.length - 1 && <BreadcrumbSeparator />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("breadcrumb", BreadcrumbRenderer);
|
||||||
|
|
||||||
|
export { BreadcrumbRenderer };
|
||||||
|
|
@ -0,0 +1,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 };
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// 카드 컴포넌트 렌더러
|
||||||
|
const CardRenderer: ComponentRenderer = ({ component, children, isInteractive = false, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const { title = "카드 제목", content = "카드 내용 영역", showHeader = true, showFooter = false, style = {} } = config;
|
||||||
|
|
||||||
|
console.log("🃏 CardRenderer 렌더링:", {
|
||||||
|
componentId: component.id,
|
||||||
|
isInteractive,
|
||||||
|
config,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full w-full" style={style}>
|
||||||
|
{showHeader && (
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className="flex-1 p-4">
|
||||||
|
{children && React.Children.count(children) > 0 ? (
|
||||||
|
children
|
||||||
|
) : isInteractive ? (
|
||||||
|
// 실제 할당된 화면에서는 설정된 내용 표시
|
||||||
|
<div className="flex h-full items-start text-sm text-gray-700">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-2 font-medium">{content}</div>
|
||||||
|
<div className="text-xs text-gray-500">실제 할당된 화면에서 표시되는 카드입니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 디자이너에서는 플레이스홀더 표시
|
||||||
|
<div className="flex h-full items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">카드 내용 영역</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">컴포넌트를 여기에 배치하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{showFooter && (
|
||||||
|
<CardFooter>
|
||||||
|
<div className="text-sm text-gray-500">카드 푸터</div>
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("card", CardRenderer);
|
||||||
|
|
||||||
|
export { CardRenderer };
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { BarChart3, LineChart, PieChart } from "lucide-react";
|
||||||
|
|
||||||
|
// 차트 컴포넌트 렌더러
|
||||||
|
const ChartRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
title = "차트 제목",
|
||||||
|
chartType = "bar", // bar, line, pie
|
||||||
|
data = [],
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const getChartIcon = () => {
|
||||||
|
switch (chartType) {
|
||||||
|
case "line":
|
||||||
|
return <LineChart className="h-8 w-8 text-blue-500" />;
|
||||||
|
case "pie":
|
||||||
|
return <PieChart className="h-8 w-8 text-green-500" />;
|
||||||
|
default:
|
||||||
|
return <BarChart3 className="h-8 w-8 text-purple-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChartTypeName = () => {
|
||||||
|
switch (chartType) {
|
||||||
|
case "line":
|
||||||
|
return "라인 차트";
|
||||||
|
case "pie":
|
||||||
|
return "파이 차트";
|
||||||
|
default:
|
||||||
|
return "바 차트";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full w-full" style={style}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
{getChartIcon()}
|
||||||
|
<div className="mt-2 text-sm text-gray-600">{getChartTypeName()}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">미리보기 모드</div>
|
||||||
|
{data.length > 0 && <div className="mt-2 text-xs text-gray-500">데이터 {data.length}개 항목</div>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("chart", ChartRenderer);
|
||||||
|
componentRegistry.register("chart-basic", ChartRenderer);
|
||||||
|
|
||||||
|
export { ChartRenderer };
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { LayoutGrid } from "lucide-react";
|
||||||
|
|
||||||
|
// 대시보드 컴포넌트 렌더러
|
||||||
|
const DashboardRenderer: ComponentRenderer = ({ component, children, isInteractive = false, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const { columns = 3, gap = 16, items = [], style = {} } = config;
|
||||||
|
|
||||||
|
console.log("📊 DashboardRenderer 렌더링:", {
|
||||||
|
componentId: component.id,
|
||||||
|
isInteractive,
|
||||||
|
config,
|
||||||
|
columns,
|
||||||
|
gap,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full w-full overflow-hidden p-4"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
|
gap: `${gap}px`,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children && React.Children.count(children) > 0
|
||||||
|
? children
|
||||||
|
: // 플레이스홀더 그리드 아이템들
|
||||||
|
Array.from({ length: columns * 2 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex min-h-0 items-center justify-center rounded p-2 ${
|
||||||
|
isInteractive
|
||||||
|
? "border border-gray-200 bg-white shadow-sm"
|
||||||
|
: "border-2 border-dashed border-gray-300 bg-gray-50"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: "60px",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<LayoutGrid className={`mx-auto mb-2 h-6 w-6 ${isInteractive ? "text-blue-500" : "text-gray-400"}`} />
|
||||||
|
<div className={`text-xs ${isInteractive ? "font-medium text-gray-700" : "text-gray-400"}`}>
|
||||||
|
그리드 {index + 1}
|
||||||
|
</div>
|
||||||
|
{isInteractive && <div className="mt-1 text-xs text-gray-500">실제 화면</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("dashboard", DashboardRenderer);
|
||||||
|
|
||||||
|
export { DashboardRenderer };
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
|
||||||
|
|
||||||
|
// 데이터 테이블 컴포넌트 렌더러
|
||||||
|
const DataTableRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const dataTableComponent = component as any; // DataTableComponent 타입
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTableTemplate
|
||||||
|
title={dataTableComponent.title || dataTableComponent.label}
|
||||||
|
description={`${dataTableComponent.label}을 표시하는 데이터 테이블`}
|
||||||
|
columns={dataTableComponent.columns}
|
||||||
|
filters={dataTableComponent.filters}
|
||||||
|
pagination={dataTableComponent.pagination}
|
||||||
|
actions={
|
||||||
|
dataTableComponent.actions || {
|
||||||
|
showSearchButton: dataTableComponent.showSearchButton ?? true,
|
||||||
|
searchButtonText: dataTableComponent.searchButtonText || "검색",
|
||||||
|
enableExport: dataTableComponent.enableExport ?? true,
|
||||||
|
enableRefresh: dataTableComponent.enableRefresh ?? true,
|
||||||
|
enableAdd: dataTableComponent.enableAdd ?? true,
|
||||||
|
enableEdit: dataTableComponent.enableEdit ?? true,
|
||||||
|
enableDelete: dataTableComponent.enableDelete ?? true,
|
||||||
|
addButtonText: dataTableComponent.addButtonText || "추가",
|
||||||
|
editButtonText: dataTableComponent.editButtonText || "수정",
|
||||||
|
deleteButtonText: dataTableComponent.deleteButtonText || "삭제",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
style={component.style}
|
||||||
|
className="h-full w-full"
|
||||||
|
isPreview={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("datatable", DataTableRenderer);
|
||||||
|
|
||||||
|
export { DataTableRenderer };
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { File } from "lucide-react";
|
||||||
|
|
||||||
|
// 파일 컴포넌트 렌더러
|
||||||
|
const FileRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4">
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
<File className="mb-2 h-8 w-8 text-gray-400" />
|
||||||
|
<p className="text-sm text-gray-600">파일 업로드 영역</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">미리보기 모드</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("file", FileRenderer);
|
||||||
|
|
||||||
|
export { FileRenderer };
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Filter } from "lucide-react";
|
||||||
|
|
||||||
|
// 필터 드롭다운 컴포넌트 렌더러
|
||||||
|
const FilterDropdownRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
label = "필터",
|
||||||
|
placeholder = "필터를 선택하세요",
|
||||||
|
options = [
|
||||||
|
{ label: "전체", value: "all" },
|
||||||
|
{ label: "활성", value: "active" },
|
||||||
|
{ label: "비활성", value: "inactive" },
|
||||||
|
],
|
||||||
|
showIcon = true,
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center gap-2 p-2" style={style}>
|
||||||
|
{showIcon && <Filter className="h-4 w-4 text-gray-500" />}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Select disabled>
|
||||||
|
<SelectTrigger className="pointer-events-none">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option: any, index: number) => (
|
||||||
|
<SelectItem key={option.value || index} value={option.value || index.toString()}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("filter", FilterDropdownRenderer);
|
||||||
|
componentRegistry.register("filter-dropdown", FilterDropdownRenderer);
|
||||||
|
|
||||||
|
export { FilterDropdownRenderer };
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
|
||||||
|
// 그룹 컴포넌트 렌더러
|
||||||
|
const GroupRenderer: ComponentRenderer = ({ component, children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<div className="absolute inset-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("group", GroupRenderer);
|
||||||
|
|
||||||
|
export { GroupRenderer };
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
// 로딩 스피너 컴포넌트 렌더러
|
||||||
|
const LoadingRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
text = "로딩 중...",
|
||||||
|
size = "default", // small, default, large
|
||||||
|
showText = true,
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const getSizeClass = () => {
|
||||||
|
switch (size) {
|
||||||
|
case "small":
|
||||||
|
return "h-4 w-4";
|
||||||
|
case "large":
|
||||||
|
return "h-8 w-8";
|
||||||
|
default:
|
||||||
|
return "h-6 w-6";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2" style={style}>
|
||||||
|
<Loader2 className={`animate-spin text-blue-600 ${getSizeClass()}`} />
|
||||||
|
{showText && <div className="text-sm text-gray-600">{text}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("loading", LoadingRenderer);
|
||||||
|
componentRegistry.register("loading-spinner", LoadingRenderer);
|
||||||
|
|
||||||
|
export { LoadingRenderer };
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
|
// 페이지네이션 컴포넌트 렌더러
|
||||||
|
const PaginationRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const { currentPage = 1, totalPages = 10, showPrevNext = true, showEllipsis = true, style = {} } = config;
|
||||||
|
|
||||||
|
const generatePageNumbers = () => {
|
||||||
|
const pages = [];
|
||||||
|
const maxVisible = 5;
|
||||||
|
|
||||||
|
if (totalPages <= maxVisible) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (currentPage > 3) {
|
||||||
|
pages.push("ellipsis1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(2, currentPage - 1);
|
||||||
|
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage < totalPages - 2) {
|
||||||
|
pages.push("ellipsis2");
|
||||||
|
}
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageNumbers = generatePageNumbers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center" style={style}>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
{showPrevNext && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious href="#" className="pointer-events-none" />
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pageNumbers.map((page, index) => (
|
||||||
|
<PaginationItem key={index}>
|
||||||
|
{typeof page === "string" && page.startsWith("ellipsis") ? (
|
||||||
|
showEllipsis && <PaginationEllipsis />
|
||||||
|
) : (
|
||||||
|
<PaginationLink href="#" isActive={page === currentPage} className="pointer-events-none">
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
)}
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showPrevNext && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext href="#" className="pointer-events-none" />
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("pagination", PaginationRenderer);
|
||||||
|
|
||||||
|
export { PaginationRenderer };
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
// 접을 수 있는 패널 컴포넌트 렌더러
|
||||||
|
const PanelRenderer: ComponentRenderer = ({ component, children, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const { title = "패널 제목", collapsible = true, defaultExpanded = true, style = {} } = config;
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full w-full" style={style}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
|
{collapsible && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="pointer-events-none h-6 w-6 p-0"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent className="flex-1">
|
||||||
|
{children && React.Children.count(children) > 0 ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">패널 내용 영역</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">컴포넌트를 여기에 배치하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("panel", PanelRenderer);
|
||||||
|
componentRegistry.register("panel-collapsible", PanelRenderer);
|
||||||
|
|
||||||
|
export { PanelRenderer };
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
// 진행률 바 컴포넌트 렌더러
|
||||||
|
const ProgressBarRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
label = "진행률",
|
||||||
|
value = 65,
|
||||||
|
max = 100,
|
||||||
|
showPercentage = true,
|
||||||
|
showValue = true,
|
||||||
|
color = "#3b82f6",
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const percentage = Math.round((value / max) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col justify-center p-4" style={style}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
{showValue && (
|
||||||
|
<span>
|
||||||
|
{value}/{max}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showPercentage && <span>({percentage}%)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={percentage}
|
||||||
|
className="h-2"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--progress-background": color,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("progress", ProgressBarRenderer);
|
||||||
|
componentRegistry.register("progress-bar", ProgressBarRenderer);
|
||||||
|
|
||||||
|
export { ProgressBarRenderer };
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
// 검색 박스 컴포넌트 렌더러
|
||||||
|
const SearchBoxRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const { placeholder = "검색어를 입력하세요...", showButton = true, buttonText = "검색", style = {} } = config;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center gap-2 p-2" style={style}>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input placeholder={placeholder} className="pointer-events-none pl-10" disabled />
|
||||||
|
</div>
|
||||||
|
{showButton && (
|
||||||
|
<Button className="pointer-events-none" disabled>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("search", SearchBoxRenderer);
|
||||||
|
componentRegistry.register("search-box", SearchBoxRenderer);
|
||||||
|
|
||||||
|
export { SearchBoxRenderer };
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||||
|
|
||||||
|
// 통계 카드 컴포넌트 렌더러
|
||||||
|
const StatsCardRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
title = "통계 제목",
|
||||||
|
value = "1,234",
|
||||||
|
change = "+12.5%",
|
||||||
|
trend = "up", // up, down, neutral
|
||||||
|
description = "전월 대비",
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const getTrendIcon = () => {
|
||||||
|
switch (trend) {
|
||||||
|
case "up":
|
||||||
|
return <TrendingUp className="h-4 w-4 text-green-600" />;
|
||||||
|
case "down":
|
||||||
|
return <TrendingDown className="h-4 w-4 text-red-600" />;
|
||||||
|
default:
|
||||||
|
return <Minus className="h-4 w-4 text-gray-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrendColor = () => {
|
||||||
|
switch (trend) {
|
||||||
|
case "up":
|
||||||
|
return "text-green-600";
|
||||||
|
case "down":
|
||||||
|
return "text-red-600";
|
||||||
|
default:
|
||||||
|
return "text-gray-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full w-full" style={style}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
<div className={`flex items-center gap-1 text-sm ${getTrendColor()}`}>
|
||||||
|
{getTrendIcon()}
|
||||||
|
<span>{change}</span>
|
||||||
|
<span className="text-gray-500">{description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("stats", StatsCardRenderer);
|
||||||
|
componentRegistry.register("stats-card", StatsCardRenderer);
|
||||||
|
|
||||||
|
export { StatsCardRenderer };
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
// 탭 컴포넌트 렌더러
|
||||||
|
const TabsRenderer: ComponentRenderer = ({ component, children, ...props }) => {
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const {
|
||||||
|
tabs = [
|
||||||
|
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
|
||||||
|
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
|
||||||
|
{ id: "tab3", label: "탭 3", content: "세 번째 탭 내용" },
|
||||||
|
],
|
||||||
|
defaultTab = "tab1",
|
||||||
|
orientation = "horizontal", // horizontal, vertical
|
||||||
|
style = {},
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full p-2" style={style}>
|
||||||
|
<Tabs defaultValue={defaultTab} orientation={orientation} className="h-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
{tabs.map((tab: any) => (
|
||||||
|
<TabsTrigger key={tab.id} value={tab.id} className="pointer-events-none" disabled>
|
||||||
|
{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{tabs.map((tab: any) => (
|
||||||
|
<TabsContent key={tab.id} value={tab.id} className="mt-4 flex-1">
|
||||||
|
{children && React.Children.count(children) > 0 ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded border border-dashed border-gray-300 bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-600">{tab.content}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">탭 내용 영역</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("tabs", TabsRenderer);
|
||||||
|
componentRegistry.register("tabs-horizontal", TabsRenderer);
|
||||||
|
|
||||||
|
export { TabsRenderer };
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData, WidgetComponent } from "@/types/screen";
|
||||||
|
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { DynamicWebTypeRenderer } from "../DynamicWebTypeRenderer";
|
||||||
|
|
||||||
|
// 위젯 컴포넌트 렌더러
|
||||||
|
const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
|
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||||
|
if (component.type !== "widget") {
|
||||||
|
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = component as WidgetComponent;
|
||||||
|
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||||
|
|
||||||
|
// 디버깅: 실제 widgetType 값 확인
|
||||||
|
console.log("WidgetRenderer - widgetType:", widgetType, "columnName:", columnName);
|
||||||
|
|
||||||
|
// 사용자가 테두리를 설정했는지 확인
|
||||||
|
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||||
|
|
||||||
|
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
|
||||||
|
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
placeholder: placeholder || "입력하세요...",
|
||||||
|
disabled: readonly,
|
||||||
|
required: required,
|
||||||
|
className: `w-full h-full ${borderClass}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 동적 웹타입 렌더링 사용
|
||||||
|
if (widgetType) {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="pointer-events-none flex-1">
|
||||||
|
<DynamicWebTypeRenderer
|
||||||
|
webType={widgetType}
|
||||||
|
props={{
|
||||||
|
...commonProps,
|
||||||
|
component: widget,
|
||||||
|
value: undefined, // 미리보기이므로 값은 없음
|
||||||
|
readonly: readonly,
|
||||||
|
}}
|
||||||
|
config={widget.webTypeConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
|
||||||
|
// 오류 발생 시 폴백으로 기본 input 렌더링
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="pointer-events-none flex-1">
|
||||||
|
<Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="pointer-events-none flex-1">
|
||||||
|
<Input type="text" {...commonProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레지스트리에 등록
|
||||||
|
componentRegistry.register("widget", WidgetRenderer);
|
||||||
|
|
||||||
|
export { WidgetRenderer };
|
||||||
|
|
@ -0,0 +1,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";
|
||||||
|
|
@ -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; // 기본 설정 (패널 없음)
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue