컴포넌트 화면편집기에 배치
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;
|
||||
} catch (error) {
|
||||
console.error("컴포넌트 수정 실패:", error);
|
||||
const { component_code } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
console.error("컴포넌트 수정 실패 [상세]:", {
|
||||
component_code,
|
||||
updateData,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "컴포넌트 수정에 실패했습니다.",
|
||||
|
|
@ -382,6 +390,52 @@ class ComponentStandardController {
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 코드 중복 체크
|
||||
*/
|
||||
async checkDuplicate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { component_code } = req.params;
|
||||
|
||||
if (!component_code) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "컴포넌트 코드가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicate = await componentStandardService.checkDuplicate(
|
||||
component_code,
|
||||
req.user?.companyCode
|
||||
);
|
||||
|
||||
console.log(
|
||||
`🔍 중복 체크 결과: component_code=${component_code}, company_code=${req.user?.companyCode}, isDuplicate=${isDuplicate}`
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { isDuplicate, component_code },
|
||||
message: isDuplicate
|
||||
? "이미 사용 중인 컴포넌트 코드입니다."
|
||||
: "사용 가능한 컴포넌트 코드입니다.",
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error("컴포넌트 코드 중복 체크 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "컴포넌트 코드 중복 체크에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ComponentStandardController();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { Request, Response } from "express";
|
||||
import { templateStandardService } from "../services/templateStandardService";
|
||||
import { handleError } from "../utils/errorHandler";
|
||||
import { checkMissingFields } from "../utils/validation";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
company_code?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 표준 관리 컨트롤러
|
||||
|
|
@ -11,26 +17,26 @@ export class TemplateStandardController {
|
|||
/**
|
||||
* 템플릿 목록 조회
|
||||
*/
|
||||
async getTemplates(req: AuthenticatedRequest, res: Response) {
|
||||
async getTemplates(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
active = "Y",
|
||||
category,
|
||||
search,
|
||||
companyCode,
|
||||
company_code,
|
||||
is_public = "Y",
|
||||
page = "1",
|
||||
limit = "50",
|
||||
} = req.query;
|
||||
|
||||
const user = req.user;
|
||||
const userCompanyCode = user?.companyCode || "DEFAULT";
|
||||
const userCompanyCode = user?.company_code || "DEFAULT";
|
||||
|
||||
const result = await templateStandardService.getTemplates({
|
||||
active: active as string,
|
||||
category: category as string,
|
||||
search: search as string,
|
||||
company_code: (companyCode as string) || userCompanyCode,
|
||||
company_code: (company_code as string) || userCompanyCode,
|
||||
is_public: is_public as string,
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
|
|
@ -47,23 +53,24 @@ export class TemplateStandardController {
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(
|
||||
res,
|
||||
error,
|
||||
"템플릿 목록 조회 중 오류가 발생했습니다."
|
||||
);
|
||||
console.error("템플릿 목록 조회 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 상세 조회
|
||||
*/
|
||||
async getTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async getTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
|
|
@ -72,7 +79,7 @@ export class TemplateStandardController {
|
|||
const template = await templateStandardService.getTemplate(templateCode);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
|
|
@ -83,40 +90,46 @@ export class TemplateStandardController {
|
|||
data: template,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 조회 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 조회 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 생성
|
||||
*/
|
||||
async createTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async createTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = req.user;
|
||||
const templateData = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
const requiredFields = [
|
||||
"template_code",
|
||||
"template_name",
|
||||
"category",
|
||||
"layout_config",
|
||||
];
|
||||
const missingFields = checkMissingFields(templateData, requiredFields);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return res.status(400).json({
|
||||
if (
|
||||
!templateData.template_code ||
|
||||
!templateData.template_name ||
|
||||
!templateData.category ||
|
||||
!templateData.layout_config
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `필수 필드가 누락되었습니다: ${missingFields.join(", ")}`,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (template_code, template_name, category, layout_config)",
|
||||
});
|
||||
}
|
||||
|
||||
// 회사 코드와 생성자 정보 추가
|
||||
const templateWithMeta = {
|
||||
...templateData,
|
||||
company_code: user?.companyCode || "DEFAULT",
|
||||
created_by: user?.userId || "system",
|
||||
updated_by: user?.userId || "system",
|
||||
company_code: user?.company_code || "DEFAULT",
|
||||
created_by: user?.user_id || "system",
|
||||
updated_by: user?.user_id || "system",
|
||||
};
|
||||
|
||||
const newTemplate =
|
||||
|
|
@ -128,21 +141,29 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 생성 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 생성 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 수정
|
||||
*/
|
||||
async updateTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async updateTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
const templateData = req.body;
|
||||
const user = req.user;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
|
|
@ -151,7 +172,7 @@ export class TemplateStandardController {
|
|||
// 수정자 정보 추가
|
||||
const templateWithMeta = {
|
||||
...templateData,
|
||||
updated_by: user?.userId || "system",
|
||||
updated_by: user?.user_id || "system",
|
||||
};
|
||||
|
||||
const updatedTemplate = await templateStandardService.updateTemplate(
|
||||
|
|
@ -160,7 +181,7 @@ export class TemplateStandardController {
|
|||
);
|
||||
|
||||
if (!updatedTemplate) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
|
|
@ -172,19 +193,27 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 수정 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 수정 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 삭제
|
||||
*/
|
||||
async deleteTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async deleteTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
|
|
@ -194,7 +223,7 @@ export class TemplateStandardController {
|
|||
await templateStandardService.deleteTemplate(templateCode);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
|
|
@ -205,19 +234,27 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 삭제 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 삭제 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 정렬 순서 일괄 업데이트
|
||||
*/
|
||||
async updateSortOrder(req: AuthenticatedRequest, res: Response) {
|
||||
async updateSortOrder(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { templates } = req.body;
|
||||
|
||||
if (!Array.isArray(templates)) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "templates는 배열이어야 합니다.",
|
||||
});
|
||||
|
|
@ -230,25 +267,29 @@ export class TemplateStandardController {
|
|||
message: "템플릿 정렬 순서가 성공적으로 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(
|
||||
res,
|
||||
error,
|
||||
"템플릿 정렬 순서 업데이트 중 오류가 발생했습니다."
|
||||
);
|
||||
console.error("템플릿 정렬 순서 업데이트 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 정렬 순서 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 복제
|
||||
*/
|
||||
async duplicateTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async duplicateTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
const { new_template_code, new_template_name } = req.body;
|
||||
const user = req.user;
|
||||
|
||||
if (!templateCode || !new_template_code || !new_template_name) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
|
|
@ -259,8 +300,8 @@ export class TemplateStandardController {
|
|||
originalCode: templateCode,
|
||||
newCode: new_template_code,
|
||||
newName: new_template_name,
|
||||
company_code: user?.companyCode || "DEFAULT",
|
||||
created_by: user?.userId || "system",
|
||||
company_code: user?.company_code || "DEFAULT",
|
||||
created_by: user?.user_id || "system",
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
|
@ -269,17 +310,22 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 복제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 복제 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 복제 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 복제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 카테고리 목록 조회
|
||||
*/
|
||||
async getCategories(req: AuthenticatedRequest, res: Response) {
|
||||
async getCategories(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const user = req.user;
|
||||
const companyCode = user?.companyCode || "DEFAULT";
|
||||
const companyCode = user?.company_code || "DEFAULT";
|
||||
|
||||
const categories =
|
||||
await templateStandardService.getCategories(companyCode);
|
||||
|
|
@ -289,24 +335,28 @@ export class TemplateStandardController {
|
|||
data: categories,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(
|
||||
res,
|
||||
error,
|
||||
"템플릿 카테고리 조회 중 오류가 발생했습니다."
|
||||
);
|
||||
console.error("템플릿 카테고리 조회 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 가져오기 (JSON 파일에서)
|
||||
*/
|
||||
async importTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async importTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = req.user;
|
||||
const templateData = req.body;
|
||||
|
||||
if (!templateData.layout_config) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "유효한 템플릿 데이터가 아닙니다.",
|
||||
});
|
||||
|
|
@ -315,9 +365,9 @@ export class TemplateStandardController {
|
|||
// 회사 코드와 생성자 정보 추가
|
||||
const templateWithMeta = {
|
||||
...templateData,
|
||||
company_code: user?.companyCode || "DEFAULT",
|
||||
created_by: user?.userId || "system",
|
||||
updated_by: user?.userId || "system",
|
||||
company_code: user?.company_code || "DEFAULT",
|
||||
created_by: user?.user_id || "system",
|
||||
updated_by: user?.user_id || "system",
|
||||
};
|
||||
|
||||
const importedTemplate =
|
||||
|
|
@ -329,31 +379,41 @@ export class TemplateStandardController {
|
|||
message: "템플릿이 성공적으로 가져왔습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 가져오기 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 가져오기 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 가져오기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 내보내기 (JSON 형태로)
|
||||
*/
|
||||
async exportTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
async exportTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { templateCode } = req.params;
|
||||
|
||||
if (!templateCode) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "템플릿 코드가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await templateStandardService.getTemplate(templateCode);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 내보내기용 데이터 (메타데이터 제외)
|
||||
|
|
@ -373,7 +433,12 @@ export class TemplateStandardController {
|
|||
data: exportData,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(res, error, "템플릿 내보내기 중 오류가 발생했습니다.");
|
||||
console.error("템플릿 내보내기 중 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "템플릿 내보내기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ router.get(
|
|||
componentStandardController.getStatistics.bind(componentStandardController)
|
||||
);
|
||||
|
||||
// 컴포넌트 코드 중복 체크
|
||||
router.get(
|
||||
"/check-duplicate/:component_code",
|
||||
componentStandardController.checkDuplicate.bind(componentStandardController)
|
||||
);
|
||||
|
||||
// 컴포넌트 상세 조회
|
||||
router.get(
|
||||
"/:component_code",
|
||||
|
|
|
|||
|
|
@ -131,9 +131,16 @@ class ComponentStandardService {
|
|||
);
|
||||
}
|
||||
|
||||
// 'active' 필드를 'is_active'로 변환
|
||||
const createData = { ...data };
|
||||
if ("active" in createData) {
|
||||
createData.is_active = (createData as any).active;
|
||||
delete (createData as any).active;
|
||||
}
|
||||
|
||||
const component = await prisma.component_standards.create({
|
||||
data: {
|
||||
...data,
|
||||
...createData,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
|
|
@ -151,10 +158,17 @@ class ComponentStandardService {
|
|||
) {
|
||||
const existing = await this.getComponent(component_code);
|
||||
|
||||
// 'active' 필드를 'is_active'로 변환
|
||||
const updateData = { ...data };
|
||||
if ("active" in updateData) {
|
||||
updateData.is_active = (updateData as any).active;
|
||||
delete (updateData as any).active;
|
||||
}
|
||||
|
||||
const component = await prisma.component_standards.update({
|
||||
where: { component_code },
|
||||
data: {
|
||||
...data,
|
||||
...updateData,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
|
@ -216,21 +230,19 @@ class ComponentStandardService {
|
|||
data: {
|
||||
component_code: new_code,
|
||||
component_name: new_name,
|
||||
component_name_eng: source.component_name_eng,
|
||||
description: source.description,
|
||||
category: source.category,
|
||||
icon_name: source.icon_name,
|
||||
default_size: source.default_size as any,
|
||||
component_config: source.component_config as any,
|
||||
preview_image: source.preview_image,
|
||||
sort_order: source.sort_order,
|
||||
is_active: source.is_active,
|
||||
is_public: source.is_public,
|
||||
company_code: source.company_code,
|
||||
component_name_eng: source?.component_name_eng,
|
||||
description: source?.description,
|
||||
category: source?.category,
|
||||
icon_name: source?.icon_name,
|
||||
default_size: source?.default_size as any,
|
||||
component_config: source?.component_config as any,
|
||||
preview_image: source?.preview_image,
|
||||
sort_order: source?.sort_order,
|
||||
is_active: source?.is_active,
|
||||
is_public: source?.is_public,
|
||||
company_code: source?.company_code || "DEFAULT",
|
||||
created_date: new Date(),
|
||||
created_by: source.created_by,
|
||||
updated_date: new Date(),
|
||||
updated_by: source.updated_by,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -297,6 +309,27 @@ class ComponentStandardService {
|
|||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 코드 중복 체크
|
||||
*/
|
||||
async checkDuplicate(
|
||||
component_code: string,
|
||||
company_code?: string
|
||||
): Promise<boolean> {
|
||||
const whereClause: any = { component_code };
|
||||
|
||||
// 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가
|
||||
if (company_code && company_code !== "*") {
|
||||
whereClause.company_code = company_code;
|
||||
}
|
||||
|
||||
const existingComponent = await prisma.component_standards.findFirst({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
return !!existingComponent;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ComponentStandardService();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter, Download, Upload } from "lucide-react";
|
||||
import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -14,7 +14,10 @@ import {
|
|||
useComponentCategories,
|
||||
useComponentStatistics,
|
||||
useDeleteComponent,
|
||||
useCreateComponent,
|
||||
useUpdateComponent,
|
||||
} from "@/hooks/admin/useComponents";
|
||||
import { ComponentFormModal } from "@/components/admin/ComponentFormModal";
|
||||
|
||||
// 컴포넌트 카테고리 정의
|
||||
const COMPONENT_CATEGORIES = [
|
||||
|
|
@ -32,6 +35,8 @@ export default function ComponentManagementPage() {
|
|||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedComponent, setSelectedComponent] = useState<any>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showNewComponentModal, setShowNewComponentModal] = useState(false);
|
||||
const [showEditComponentModal, setShowEditComponentModal] = useState(false);
|
||||
|
||||
// 컴포넌트 데이터 가져오기
|
||||
const {
|
||||
|
|
@ -51,8 +56,10 @@ export default function ComponentManagementPage() {
|
|||
const { data: categories } = useComponentCategories();
|
||||
const { data: statistics } = useComponentStatistics();
|
||||
|
||||
// 삭제 뮤테이션
|
||||
// 뮤테이션
|
||||
const deleteComponentMutation = useDeleteComponent();
|
||||
const createComponentMutation = useCreateComponent();
|
||||
const updateComponentMutation = useUpdateComponent();
|
||||
|
||||
// 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태)
|
||||
const components = componentsData?.components || [];
|
||||
|
|
@ -88,6 +95,23 @@ export default function ComponentManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 생성 처리
|
||||
const handleCreate = async (data: any) => {
|
||||
await createComponentMutation.mutateAsync(data);
|
||||
setShowNewComponentModal(false);
|
||||
};
|
||||
|
||||
// 컴포넌트 수정 처리
|
||||
const handleUpdate = async (data: any) => {
|
||||
if (!selectedComponent) return;
|
||||
await updateComponentMutation.mutateAsync({
|
||||
component_code: selectedComponent.component_code,
|
||||
data,
|
||||
});
|
||||
setShowEditComponentModal(false);
|
||||
setSelectedComponent(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -124,15 +148,7 @@ export default function ComponentManagementPage() {
|
|||
<p className="text-sm text-gray-500">화면 설계에 사용되는 컴포넌트들을 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
가져오기
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
내보내기
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => setShowNewComponentModal(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 컴포넌트
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -279,7 +295,14 @@ export default function ComponentManagementPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedComponent(component);
|
||||
setShowEditComponentModal(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -313,6 +336,26 @@ export default function ComponentManagementPage() {
|
|||
message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`}
|
||||
confirmText="삭제"
|
||||
/>
|
||||
|
||||
{/* 새 컴포넌트 추가 모달 */}
|
||||
<ComponentFormModal
|
||||
isOpen={showNewComponentModal}
|
||||
onClose={() => setShowNewComponentModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
mode="create"
|
||||
/>
|
||||
|
||||
{/* 컴포넌트 편집 모달 */}
|
||||
<ComponentFormModal
|
||||
isOpen={showEditComponentModal}
|
||||
onClose={() => {
|
||||
setShowEditComponentModal(false);
|
||||
setSelectedComponent(null);
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
initialData={selectedComponent}
|
||||
mode="edit"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { Loader2 } from "lucide-react";
|
|||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -93,12 +95,13 @@ export default function ScreenViewPage() {
|
|||
{layout && layout.components.length > 0 ? (
|
||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
|
||||
}}
|
||||
>
|
||||
{layout.components
|
||||
|
|
@ -202,14 +205,28 @@ export default function ScreenViewPage() {
|
|||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
width: `${component.size.width}px`,
|
||||
height: `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
console.log("🎯 할당된 화면 컴포넌트:", {
|
||||
id: component.id,
|
||||
type: component.type,
|
||||
position: component.position,
|
||||
size: component.size,
|
||||
styleWidth: component.style?.width,
|
||||
styleHeight: component.style?.height,
|
||||
finalWidth: `${component.size.width}px`,
|
||||
finalHeight: `${component.size.height}px`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||
{component.type !== "widget" ? (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
allComponents={layout.components}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
|
|
@ -217,12 +234,21 @@ export default function ScreenViewPage() {
|
|||
[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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { InteractiveDataTable } from "./InteractiveDataTable";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
import "@/lib/registry/components/CardRenderer";
|
||||
import "@/lib/registry/components/DashboardRenderer";
|
||||
import "@/lib/registry/components/WidgetRenderer";
|
||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||
import { useParams } from "next/navigation";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
|
@ -152,9 +159,22 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return renderFileComponent(comp as FileComponent);
|
||||
}
|
||||
|
||||
// 위젯 컴포넌트가 아닌 경우
|
||||
// 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용
|
||||
if (comp.type !== "widget") {
|
||||
return <div className="text-sm text-gray-500">지원되지 않는 컴포넌트 타입</div>;
|
||||
console.log("🎯 InteractiveScreenViewer - DynamicComponentRenderer 사용:", {
|
||||
componentId: comp.id,
|
||||
componentType: comp.type,
|
||||
componentConfig: comp.componentConfig,
|
||||
});
|
||||
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={comp}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const widget = comp as WidgetComponent;
|
||||
|
|
@ -492,5 +512,3 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
export { InteractiveScreenViewerDynamic as InteractiveScreenViewer };
|
||||
|
||||
InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FileUpload } from "./widgets/FileUpload";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
||||
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import {
|
||||
Database,
|
||||
Type,
|
||||
|
|
@ -24,26 +15,11 @@ import {
|
|||
Code,
|
||||
Building,
|
||||
File,
|
||||
Group,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Upload,
|
||||
Square,
|
||||
CreditCard,
|
||||
Layout,
|
||||
Grid3x3,
|
||||
Columns,
|
||||
Rows,
|
||||
SidebarOpen,
|
||||
Folder,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
|
||||
// 컴포넌트 렌더러들 자동 등록
|
||||
import "@/lib/registry/components";
|
||||
|
||||
interface RealtimePreviewProps {
|
||||
component: ComponentData;
|
||||
isSelected?: boolean;
|
||||
|
|
@ -54,147 +30,31 @@ interface RealtimePreviewProps {
|
|||
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
||||
}
|
||||
|
||||
// 영역 레이아웃에 따른 아이콘 반환
|
||||
const getAreaIcon = (layoutType: AreaLayoutType) => {
|
||||
switch (layoutType) {
|
||||
case "flex":
|
||||
return <Layout className="h-4 w-4 text-blue-600" />;
|
||||
case "grid":
|
||||
return <Grid3x3 className="h-4 w-4 text-green-600" />;
|
||||
case "columns":
|
||||
return <Columns className="h-4 w-4 text-purple-600" />;
|
||||
case "rows":
|
||||
return <Rows className="h-4 w-4 text-orange-600" />;
|
||||
case "sidebar":
|
||||
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
|
||||
case "tabs":
|
||||
return <Folder className="h-4 w-4 text-pink-600" />;
|
||||
default:
|
||||
return <Square className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
|
||||
if (!widgetType) return <Type className="h-3 w-3" />;
|
||||
|
||||
// 영역 렌더링
|
||||
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||
const area = component as AreaComponent;
|
||||
const { areaType, label } = area;
|
||||
|
||||
const renderPlaceholder = () => (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
{getAreaIcon(areaType)}
|
||||
<p className="mt-2 text-sm text-gray-600">{label || `${areaType} 영역`}</p>
|
||||
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 동적 웹 타입 위젯 렌더링
|
||||
const renderWidget = (component: ComponentData) => {
|
||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||
if (component.type !== "widget") {
|
||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||
}
|
||||
|
||||
const widget = component as WidgetComponent;
|
||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||
|
||||
// 디버깅: 실제 widgetType 값 확인
|
||||
console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
||||
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
const commonProps = {
|
||||
placeholder: placeholder || "입력하세요...",
|
||||
disabled: readonly,
|
||||
required: required,
|
||||
className: `w-full h-full ${borderClass}`,
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
text: <span className="text-xs">Aa</span>,
|
||||
number: <Hash className="h-3 w-3" />,
|
||||
decimal: <Hash className="h-3 w-3" />,
|
||||
date: <Calendar className="h-3 w-3" />,
|
||||
datetime: <Calendar className="h-3 w-3" />,
|
||||
select: <List className="h-3 w-3" />,
|
||||
dropdown: <List className="h-3 w-3" />,
|
||||
textarea: <AlignLeft className="h-3 w-3" />,
|
||||
boolean: <CheckSquare className="h-3 w-3" />,
|
||||
checkbox: <CheckSquare className="h-3 w-3" />,
|
||||
radio: <Radio className="h-3 w-3" />,
|
||||
code: <Code className="h-3 w-3" />,
|
||||
entity: <Building className="h-3 w-3" />,
|
||||
file: <File className="h-3 w-3" />,
|
||||
email: <span className="text-xs">@</span>,
|
||||
tel: <span className="text-xs">☎</span>,
|
||||
button: <span className="text-xs">BTN</span>,
|
||||
};
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
if (widgetType) {
|
||||
try {
|
||||
return (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={widgetType}
|
||||
props={{
|
||||
...commonProps,
|
||||
component: widget,
|
||||
value: undefined, // 미리보기이므로 값은 없음
|
||||
readonly: readonly,
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
|
||||
// 오류 발생 시 폴백으로 기본 input 렌더링
|
||||
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
|
||||
return <Input type="text" {...commonProps} />;
|
||||
};
|
||||
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
const getWidgetIcon = (widgetType: WebType | undefined) => {
|
||||
if (!widgetType) {
|
||||
return <Type className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
|
||||
// 레지스트리에서 웹타입 정의 조회
|
||||
const webTypeDefinition = WebTypeRegistry.getWebType(widgetType);
|
||||
if (webTypeDefinition && webTypeDefinition.icon) {
|
||||
const IconComponent = webTypeDefinition.icon;
|
||||
return <IconComponent className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
// 기본 아이콘 매핑 (하위 호환성)
|
||||
switch (widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return <Type className="h-4 w-4 text-blue-600" />;
|
||||
case "number":
|
||||
case "decimal":
|
||||
return <Hash className="h-4 w-4 text-green-600" />;
|
||||
case "date":
|
||||
case "datetime":
|
||||
return <Calendar className="h-4 w-4 text-purple-600" />;
|
||||
case "select":
|
||||
case "dropdown":
|
||||
return <List className="h-4 w-4 text-orange-600" />;
|
||||
case "textarea":
|
||||
case "text_area":
|
||||
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return <CheckSquare className="h-4 w-4 text-blue-600" />;
|
||||
case "radio":
|
||||
return <Radio className="h-4 w-4 text-blue-600" />;
|
||||
case "code":
|
||||
return <Code className="h-4 w-4 text-gray-600" />;
|
||||
case "entity":
|
||||
return <Building className="h-4 w-4 text-cyan-600" />;
|
||||
case "file":
|
||||
return <File className="h-4 w-4 text-yellow-600" />;
|
||||
default:
|
||||
return <Type className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
return iconMap[widgetType] || <Type className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
|
|
@ -206,28 +66,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onGroupToggle,
|
||||
children,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { type, id, position, size, style = {} } = component;
|
||||
const { id, type, position, size, style: componentStyle } = component;
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
width: size?.width || 200,
|
||||
height: size?.height || 40,
|
||||
zIndex: position?.z || 1,
|
||||
...style,
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
// 선택 상태에 따른 스타일
|
||||
const selectionStyle = isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outlineOffset: "2px",
|
||||
zIndex: 1000,
|
||||
}
|
||||
: {};
|
||||
|
||||
// 컴포넌트 기본 스타일
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
zIndex: position.z || 1,
|
||||
...componentStyle,
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
|
|
@ -246,166 +105,22 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
<div
|
||||
id={`component-${id}`}
|
||||
className="absolute cursor-pointer"
|
||||
style={{ ...componentStyle, ...selectionStyle }}
|
||||
style={{ ...baseStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
{/* 동적 컴포넌트 렌더링 */}
|
||||
<div className="h-full w-full">
|
||||
{/* 영역 타입 */}
|
||||
{type === "area" && renderArea(component, children)}
|
||||
|
||||
{/* 데이터 테이블 타입 */}
|
||||
{type === "datatable" &&
|
||||
(() => {
|
||||
const dataTableComponent = component as any; // DataTableComponent 타입
|
||||
|
||||
// 메모이제이션을 위한 계산 최적화
|
||||
const visibleColumns = React.useMemo(
|
||||
() => dataTableComponent.columns?.filter((col: any) => col.visible) || [],
|
||||
[dataTableComponent.columns],
|
||||
);
|
||||
const filters = React.useMemo(() => dataTableComponent.filters || [], [dataTableComponent.filters]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded border bg-white">
|
||||
{/* 테이블 제목 */}
|
||||
{dataTableComponent.title && (
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<h3 className="text-sm font-medium">{dataTableComponent.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 및 필터 영역 */}
|
||||
{(dataTableComponent.showSearchButton || filters.length > 0) && (
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{dataTableComponent.showSearchButton && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input placeholder="검색..." className="h-8 w-48" />
|
||||
<Button size="sm" variant="outline">
|
||||
{dataTableComponent.searchButtonText || "검색"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filters.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500">필터:</span>
|
||||
{filters.slice(0, 2).map((filter: any, index: number) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{filter.label || filter.columnName}
|
||||
</Badge>
|
||||
))}
|
||||
{filters.length > 2 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{filters.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 본체 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumns.length > 0 ? (
|
||||
visibleColumns.map((col: any, index: number) => (
|
||||
<TableHead key={col.id || index} className="text-xs">
|
||||
{col.label || col.columnName}
|
||||
{col.sortable && <span className="ml-1 text-gray-400">↕</span>}
|
||||
</TableHead>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<TableHead className="text-xs">컬럼 1</TableHead>
|
||||
<TableHead className="text-xs">컬럼 2</TableHead>
|
||||
<TableHead className="text-xs">컬럼 3</TableHead>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* 샘플 데이터 행들 */}
|
||||
{[1, 2, 3].map((rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{visibleColumns.length > 0 ? (
|
||||
visibleColumns.map((col: any, colIndex: number) => (
|
||||
<TableCell key={col.id || colIndex} className="text-xs">
|
||||
{col.widgetType === "checkbox" ? (
|
||||
<input type="checkbox" className="h-3 w-3" />
|
||||
) : col.widgetType === "select" ? (
|
||||
`옵션 ${rowIndex}`
|
||||
) : col.widgetType === "date" ? (
|
||||
"2024-01-01"
|
||||
) : col.widgetType === "number" ? (
|
||||
`${rowIndex * 100}`
|
||||
) : (
|
||||
`데이터 ${rowIndex}-${colIndex + 1}`
|
||||
)}
|
||||
</TableCell>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-1</TableCell>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-2</TableCell>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-3</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{dataTableComponent.pagination && (
|
||||
<div className="border-t bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>총 3개 항목</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
이전
|
||||
</Button>
|
||||
<span>1 / 1</span>
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 그룹 타입 */}
|
||||
{type === "group" && (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute inset-0">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 */}
|
||||
{type === "widget" && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 타입 */}
|
||||
{type === "file" && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1">
|
||||
<FileUpload disabled placeholder="파일 업로드 미리보기" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
children={children}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
|
|
@ -417,7 +132,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{(component as WidgetComponent).widgetType || "widget"}
|
||||
</div>
|
||||
)}
|
||||
{type !== "widget" && type}
|
||||
{type !== "widget" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{component.componentConfig?.type || type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -426,5 +145,4 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
// 기존 RealtimePreview와의 호환성을 위한 export
|
||||
export { RealtimePreviewDynamic as RealtimePreview };
|
||||
|
||||
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
|
||||
export default RealtimePreviewDynamic;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import { toast } from "sonner";
|
|||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
|
||||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreview";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
import FloatingPanel from "./FloatingPanel";
|
||||
import DesignerToolbar from "./DesignerToolbar";
|
||||
import TablesPanel from "./panels/TablesPanel";
|
||||
|
|
@ -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 = {
|
||||
id: generateComponentId(),
|
||||
type: component.webType === "button" ? "button" : "widget",
|
||||
type: component.componentType || "widget", // 데이터베이스의 componentType 사용
|
||||
label: component.name,
|
||||
widgetType: component.webType,
|
||||
position: snappedPosition,
|
||||
size: component.defaultSize,
|
||||
componentConfig: component.componentConfig || {}, // 데이터베이스의 componentConfig 사용
|
||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
|
|
@ -3140,9 +3149,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
description: component.description,
|
||||
category: component.category,
|
||||
webType: component.webType,
|
||||
componentType: component.componentType, // 추가!
|
||||
componentConfig: component.componentConfig, // 추가!
|
||||
defaultSize: component.defaultSize,
|
||||
},
|
||||
};
|
||||
console.log("🚀 드래그 데이터 설정:", 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";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ConfigPanelProps } from "@/lib/registry/types";
|
||||
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 { ComponentData } from "@/types/screen";
|
||||
|
||||
export const ButtonConfigPanel: React.FC<ConfigPanelProps> = ({ config: initialConfig, onConfigChange }) => {
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
label: "버튼",
|
||||
text: "",
|
||||
tooltip: "",
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
disabled: false,
|
||||
fullWidth: false,
|
||||
...initialConfig,
|
||||
});
|
||||
interface ButtonConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig({
|
||||
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);
|
||||
};
|
||||
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||
const config = component.componentConfig || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="button-label" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
버튼 텍스트
|
||||
</label>
|
||||
<input
|
||||
id="button-label"
|
||||
type="text"
|
||||
value={localConfig.label || ""}
|
||||
onChange={(e) => updateConfig("label", e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="버튼에 표시될 텍스트"
|
||||
<Label htmlFor="button-text">버튼 텍스트</Label>
|
||||
<Input
|
||||
id="button-text"
|
||||
value={config.text || "버튼"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.text", e.target.value)}
|
||||
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"
|
||||
<Label htmlFor="button-variant">버튼 스타일</Label>
|
||||
<Select
|
||||
value={config.variant || "default"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.variant", value)}
|
||||
>
|
||||
<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>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="버튼 스타일 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="primary">기본 (Primary)</SelectItem>
|
||||
<SelectItem value="secondary">보조 (Secondary)</SelectItem>
|
||||
<SelectItem value="danger">위험 (Danger)</SelectItem>
|
||||
<SelectItem value="success">성공 (Success)</SelectItem>
|
||||
<SelectItem value="outline">외곽선 (Outline)</SelectItem>
|
||||
<SelectItem value="ghost">고스트 (Ghost)</SelectItem>
|
||||
<SelectItem value="link">링크 (Link)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="button-size" 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"
|
||||
<Label htmlFor="button-size">버튼 크기</Label>
|
||||
<Select
|
||||
value={config.size || "default"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.size", value)}
|
||||
>
|
||||
<option value="small">작음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="large">큼</option>
|
||||
</select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="버튼 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">작음 (Small)</SelectItem>
|
||||
<SelectItem value="default">기본 (Default)</SelectItem>
|
||||
<SelectItem value="large">큼 (Large)</SelectItem>
|
||||
</SelectContent>
|
||||
</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 className="border-t border-gray-200 pt-3">
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">미리보기</h4>
|
||||
<button
|
||||
type="button"
|
||||
disabled={localConfig.disabled}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors duration-200 ${localConfig.size === "small" ? "px-3 py-1 text-xs" : ""} ${localConfig.size === "large" ? "px-6 py-3 text-base" : ""} ${localConfig.variant === "primary" ? "bg-blue-600 text-white hover:bg-blue-700" : ""} ${localConfig.variant === "secondary" ? "bg-gray-600 text-white hover:bg-gray-700" : ""} ${localConfig.variant === "success" ? "bg-green-600 text-white hover:bg-green-700" : ""} ${localConfig.variant === "warning" ? "bg-yellow-600 text-white hover:bg-yellow-700" : ""} ${localConfig.variant === "danger" ? "bg-red-600 text-white hover:bg-red-700" : ""} ${localConfig.variant === "outline" ? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50" : ""} ${localConfig.fullWidth ? "w-full" : ""} ${localConfig.disabled ? "cursor-not-allowed opacity-50" : ""} `}
|
||||
title={localConfig.tooltip}
|
||||
<div>
|
||||
<Label htmlFor="button-action">버튼 액션</Label>
|
||||
<Select
|
||||
value={config.action || "custom"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action", value)}
|
||||
>
|
||||
{localConfig.label || "버튼"}
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
componentType: string;
|
||||
componentConfig: any;
|
||||
webType: string; // webType 추가
|
||||
icon: React.ReactNode;
|
||||
defaultSize: { width: number; height: number };
|
||||
}
|
||||
|
|
@ -30,6 +31,7 @@ const COMPONENT_CATEGORIES = [
|
|||
{ id: "navigation", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" },
|
||||
{ id: "feedback", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" },
|
||||
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
|
||||
{ id: "표시", name: "표시", description: "정보를 표시하고 알리는 컴포넌트" },
|
||||
{ id: "other", name: "기타", description: "기타 컴포넌트" },
|
||||
];
|
||||
|
||||
|
|
@ -50,16 +52,41 @@ export const ComponentsPanel: React.FC<ComponentsPanelProps> = ({ onDragStart })
|
|||
const componentItems = useMemo(() => {
|
||||
if (!componentsData?.components) return [];
|
||||
|
||||
return componentsData.components.map((component) => ({
|
||||
return componentsData.components.map((component) => {
|
||||
console.log("🔍 ComponentsPanel 컴포넌트 매핑:", {
|
||||
component_code: component.component_code,
|
||||
component_name: component.component_name,
|
||||
component_config: component.component_config,
|
||||
componentType: component.component_config?.type || component.component_code,
|
||||
webType: component.component_config?.type || component.component_code,
|
||||
category: component.category,
|
||||
});
|
||||
|
||||
// 카테고리 매핑 (영어 -> 한국어)
|
||||
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: component.category || "other",
|
||||
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]);
|
||||
|
||||
// 필터링된 컴포넌트
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ import { ComponentData, WidgetComponent, FileComponent, WebTypeConfig, TableInfo
|
|||
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
|
||||
// 새로운 컴포넌트 설정 패널들 import
|
||||
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
|
||||
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
||||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||
|
||||
interface DetailSettingsPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
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") {
|
||||
return (
|
||||
<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 }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as TextTypeConfig | undefined;
|
||||
const { placeholder, required, style } = widget || {};
|
||||
const config = widget?.webTypeConfig as TextTypeConfig | undefined;
|
||||
|
||||
// 입력 타입에 따른 처리
|
||||
const isAutoInput = widget.inputType === "auto";
|
||||
const isAutoInput = widget?.inputType === "auto";
|
||||
|
||||
// 자동 값 생성 함수
|
||||
const getAutoValue = (autoValueType: string) => {
|
||||
|
|
@ -63,11 +63,11 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
|
||||
// 플레이스홀더 처리
|
||||
const finalPlaceholder = isAutoInput
|
||||
? getAutoPlaceholder(widget.autoValueType || "")
|
||||
? getAutoPlaceholder(widget?.autoValueType || "")
|
||||
: placeholder || config?.placeholder || "입력하세요...";
|
||||
|
||||
// 값 처리
|
||||
const finalValue = isAutoInput ? getAutoValue(widget.autoValueType || "") : value || "";
|
||||
const finalValue = isAutoInput ? getAutoValue(widget?.autoValueType || "") : value || "";
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
|
@ -77,7 +77,7 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
|
||||
// 웹타입에 따른 input type 결정
|
||||
const getInputType = () => {
|
||||
switch (widget.widgetType) {
|
||||
switch (widget?.widgetType) {
|
||||
case "email":
|
||||
return "email";
|
||||
case "tel":
|
||||
|
|
@ -106,5 +106,3 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
};
|
||||
|
||||
TextWidget.displayName = "TextWidget";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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 { EntityTypeConfigPanel } from "@/components/screen/panels/webtype-configs/EntityTypeConfigPanel";
|
||||
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<{
|
||||
|
|
@ -19,6 +21,66 @@ export type ConfigPanelComponent = React.ComponentType<{
|
|||
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 필드용)
|
||||
export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent | null => {
|
||||
console.log(`🔧 getConfigPanelComponent 호출: panelName="${panelName}"`);
|
||||
|
|
@ -56,8 +118,14 @@ export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent
|
|||
console.log(`🔧 RatingTypeConfigPanel 내용:`, RatingTypeConfigPanel);
|
||||
return RatingTypeConfigPanel;
|
||||
case "ButtonConfigPanel":
|
||||
console.log(`🔧 ButtonConfigPanel 컴포넌트 반환`);
|
||||
return ButtonConfigPanel;
|
||||
console.log(`🔧 ButtonConfigPanel 래퍼 컴포넌트 반환`);
|
||||
return ButtonConfigPanelWrapper;
|
||||
case "CardConfigPanel":
|
||||
console.log(`🔧 CardConfigPanel 래퍼 컴포넌트 반환`);
|
||||
return CardConfigPanelWrapper;
|
||||
case "DashboardConfigPanel":
|
||||
console.log(`🔧 DashboardConfigPanel 래퍼 컴포넌트 반환`);
|
||||
return DashboardConfigPanelWrapper;
|
||||
default:
|
||||
console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`);
|
||||
return null; // 기본 설정 (패널 없음)
|
||||
|
|
@ -19,7 +19,9 @@
|
|||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@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-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@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": {
|
||||
"version": "1.1.15",
|
||||
"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": {
|
||||
"version": "1.3.8",
|
||||
"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-dropdown-menu": "^2.1.1",
|
||||
"@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-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
|
|
|
|||
Loading…
Reference in New Issue