From 01860df8d768aaceb86275ea670d6d5e11138458 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 10 Sep 2025 14:09:32 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=ED=8E=B8=EC=A7=91=EA=B8=B0=EC=97=90=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/scripts/list-components.js | 46 + .../componentStandardController.ts | 56 +- .../controllers/templateStandardController.ts | 207 +++-- .../src/routes/componentStandardRoutes.ts | 6 + .../src/services/componentStandardService.ts | 63 +- backend-node/src/utils/errorHandler.ts | 69 -- backend-node/src/utils/validation.ts | 101 --- frontend/app/(main)/admin/components/page.tsx | 67 +- .../app/(main)/screens/[screenId]/page.tsx | 64 +- .../components/admin/ComponentFormModal.tsx | 565 +++++++++++++ .../screen/InteractiveScreenViewerDynamic.tsx | 26 +- .../screen/RealtimePreviewDynamic.tsx | 394 ++------- frontend/components/screen/ScreenDesigner.tsx | 16 +- .../screen/config-panels/AlertConfigPanel.tsx | 70 ++ .../screen/config-panels/BadgeConfigPanel.tsx | 65 ++ .../config-panels/ButtonConfigPanel.tsx | 204 ++--- .../screen/config-panels/CardConfigPanel.tsx | 117 +++ .../screen/config-panels/ChartConfigPanel.tsx | 79 ++ .../config-panels/DashboardConfigPanel.tsx | 148 ++++ .../config-panels/ProgressBarConfigPanel.tsx | 82 ++ .../config-panels/StatsCardConfigPanel.tsx | 77 ++ .../screen/panels/ComponentsPanel.tsx | 47 +- .../screen/panels/DetailSettingsPanel.tsx | 125 +++ .../screen/widgets/types/TextWidget.tsx | 14 +- frontend/components/ui/alert.tsx | 66 ++ frontend/components/ui/breadcrumb.tsx | 109 +++ frontend/components/ui/pagination.tsx | 127 +++ frontend/components/ui/progress.tsx | 31 + frontend/docs/component-development-guide.md | 795 ++++++++++++++++++ .../hooks/admin/useComponentDuplicateCheck.ts | 16 + frontend/lib/api/componentApi.ts | 120 +++ .../lib/registry/DynamicComponentRenderer.tsx | 135 +++ .../lib/registry/components/AlertRenderer.tsx | 57 ++ .../lib/registry/components/AreaRenderer.tsx | 54 ++ .../lib/registry/components/BadgeRenderer.tsx | 33 + .../components/BreadcrumbRenderer.tsx | 51 ++ .../registry/components/ButtonRenderer.tsx | 48 ++ .../lib/registry/components/CardRenderer.tsx | 61 ++ .../lib/registry/components/ChartRenderer.tsx | 62 ++ .../registry/components/DashboardRenderer.tsx | 67 ++ .../registry/components/DataTableRenderer.tsx | 43 + .../lib/registry/components/FileRenderer.tsx | 26 + .../components/FilterDropdownRenderer.tsx | 49 ++ .../lib/registry/components/GroupRenderer.tsx | 19 + .../registry/components/LoadingRenderer.tsx | 41 + .../components/PaginationRenderer.tsx | 89 ++ .../lib/registry/components/PanelRenderer.tsx | 57 ++ .../components/ProgressBarRenderer.tsx | 55 ++ .../registry/components/SearchBoxRenderer.tsx | 34 + .../registry/components/StatsCardRenderer.tsx | 68 ++ .../lib/registry/components/TabsRenderer.tsx | 55 ++ .../registry/components/WidgetRenderer.tsx | 80 ++ frontend/lib/registry/components/index.ts | 56 ++ ...mponent.ts => getConfigPanelComponent.tsx} | 74 +- frontend/package-lock.json | 62 ++ frontend/package.json | 2 + 56 files changed, 4572 insertions(+), 778 deletions(-) create mode 100644 backend-node/scripts/list-components.js delete mode 100644 backend-node/src/utils/errorHandler.ts delete mode 100644 backend-node/src/utils/validation.ts create mode 100644 frontend/components/admin/ComponentFormModal.tsx create mode 100644 frontend/components/screen/config-panels/AlertConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/BadgeConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/CardConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/ChartConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/DashboardConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/ProgressBarConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/StatsCardConfigPanel.tsx create mode 100644 frontend/components/ui/alert.tsx create mode 100644 frontend/components/ui/breadcrumb.tsx create mode 100644 frontend/components/ui/pagination.tsx create mode 100644 frontend/components/ui/progress.tsx create mode 100644 frontend/docs/component-development-guide.md create mode 100644 frontend/hooks/admin/useComponentDuplicateCheck.ts create mode 100644 frontend/lib/api/componentApi.ts create mode 100644 frontend/lib/registry/DynamicComponentRenderer.tsx create mode 100644 frontend/lib/registry/components/AlertRenderer.tsx create mode 100644 frontend/lib/registry/components/AreaRenderer.tsx create mode 100644 frontend/lib/registry/components/BadgeRenderer.tsx create mode 100644 frontend/lib/registry/components/BreadcrumbRenderer.tsx create mode 100644 frontend/lib/registry/components/ButtonRenderer.tsx create mode 100644 frontend/lib/registry/components/CardRenderer.tsx create mode 100644 frontend/lib/registry/components/ChartRenderer.tsx create mode 100644 frontend/lib/registry/components/DashboardRenderer.tsx create mode 100644 frontend/lib/registry/components/DataTableRenderer.tsx create mode 100644 frontend/lib/registry/components/FileRenderer.tsx create mode 100644 frontend/lib/registry/components/FilterDropdownRenderer.tsx create mode 100644 frontend/lib/registry/components/GroupRenderer.tsx create mode 100644 frontend/lib/registry/components/LoadingRenderer.tsx create mode 100644 frontend/lib/registry/components/PaginationRenderer.tsx create mode 100644 frontend/lib/registry/components/PanelRenderer.tsx create mode 100644 frontend/lib/registry/components/ProgressBarRenderer.tsx create mode 100644 frontend/lib/registry/components/SearchBoxRenderer.tsx create mode 100644 frontend/lib/registry/components/StatsCardRenderer.tsx create mode 100644 frontend/lib/registry/components/TabsRenderer.tsx create mode 100644 frontend/lib/registry/components/WidgetRenderer.tsx create mode 100644 frontend/lib/registry/components/index.ts rename frontend/lib/utils/{getConfigPanelComponent.ts => getConfigPanelComponent.tsx} (53%) diff --git a/backend-node/scripts/list-components.js b/backend-node/scripts/list-components.js new file mode 100644 index 00000000..a0ba6da4 --- /dev/null +++ b/backend-node/scripts/list-components.js @@ -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(); diff --git a/backend-node/src/controllers/componentStandardController.ts b/backend-node/src/controllers/componentStandardController.ts index 33f2f95b..dc264870 100644 --- a/backend-node/src/controllers/componentStandardController.ts +++ b/backend-node/src/controllers/componentStandardController.ts @@ -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 { + 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(); diff --git a/backend-node/src/controllers/templateStandardController.ts b/backend-node/src/controllers/templateStandardController.ts index a08245ad..73fbc328 100644 --- a/backend-node/src/controllers/templateStandardController.ts +++ b/backend-node/src/controllers/templateStandardController.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 : "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜", + }); } } } diff --git a/backend-node/src/routes/componentStandardRoutes.ts b/backend-node/src/routes/componentStandardRoutes.ts index 565fa7d0..1de7be83 100644 --- a/backend-node/src/routes/componentStandardRoutes.ts +++ b/backend-node/src/routes/componentStandardRoutes.ts @@ -25,6 +25,12 @@ router.get( componentStandardController.getStatistics.bind(componentStandardController) ); +// ์ปดํฌ๋„ŒํŠธ ์ฝ”๋“œ ์ค‘๋ณต ์ฒดํฌ +router.get( + "/check-duplicate/:component_code", + componentStandardController.checkDuplicate.bind(componentStandardController) +); + // ์ปดํฌ๋„ŒํŠธ ์ƒ์„ธ ์กฐํšŒ router.get( "/:component_code", diff --git a/backend-node/src/services/componentStandardService.ts b/backend-node/src/services/componentStandardService.ts index 6a05c122..3ac00742 100644 --- a/backend-node/src/services/componentStandardService.ts +++ b/backend-node/src/services/componentStandardService.ts @@ -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 { + 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(); diff --git a/backend-node/src/utils/errorHandler.ts b/backend-node/src/utils/errorHandler.ts deleted file mode 100644 index e8239273..00000000 --- a/backend-node/src/utils/errorHandler.ts +++ /dev/null @@ -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, - }, - }); -}; diff --git a/backend-node/src/utils/validation.ts b/backend-node/src/utils/validation.ts deleted file mode 100644 index ce3f909c..00000000 --- a/backend-node/src/utils/validation.ts +++ /dev/null @@ -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, - 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, - 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; -}; diff --git a/frontend/app/(main)/admin/components/page.tsx b/frontend/app/(main)/admin/components/page.tsx index 8273f6d1..dbd63be8 100644 --- a/frontend/app/(main)/admin/components/page.tsx +++ b/frontend/app/(main)/admin/components/page.tsx @@ -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(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 (
@@ -124,15 +148,7 @@ export default function ComponentManagementPage() {

ํ™”๋ฉด ์„ค๊ณ„์— ์‚ฌ์šฉ๋˜๋Š” ์ปดํฌ๋„ŒํŠธ๋“ค์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค

- - -
@@ -279,7 +295,14 @@ export default function ComponentManagementPage() {
-
); } diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9cbfbd82..4bebdcda 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -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 ? ( // ์บ”๋ฒ„์Šค ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์ •ํ™•ํ•œ ํ•ด์ƒ๋„๋กœ ํ‘œ์‹œ
{layout.components @@ -202,27 +205,50 @@ 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`, + }); + }} > - { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }} - hideLabel={true} // ๋ผ๋ฒจ ์ˆจ๊น€ ํ”Œ๋ž˜๊ทธ ์ „๋‹ฌ - screenInfo={{ - id: screenId, - tableName: screen?.tableName, - }} - /> + {/* ์œ„์ ฏ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ DynamicComponentRenderer ์‚ฌ์šฉ */} + {component.type !== "widget" ? ( + { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + /> + ) : ( + { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + /> + )}
); diff --git a/frontend/components/admin/ComponentFormModal.tsx b/frontend/components/admin/ComponentFormModal.tsx new file mode 100644 index 00000000..c64b62c9 --- /dev/null +++ b/frontend/components/admin/ComponentFormModal.tsx @@ -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; + initialData?: any; + mode?: "create" | "edit"; +} + +export const ComponentFormModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + initialData, + mode = "create", +}) => { + const [formData, setFormData] = useState({ + 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 ( + + + + {mode === "create" ? "์ƒˆ ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€" : "์ปดํฌ๋„ŒํŠธ ํŽธ์ง‘"} + + {mode === "create" + ? "ํ™”๋ฉด ์„ค๊ณ„์— ์‚ฌ์šฉํ•  ์ƒˆ๋กœ์šด ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค." + : "์„ ํƒํ•œ ์ปดํฌ๋„ŒํŠธ์˜ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค."} + + + +
+ {/* ๊ธฐ๋ณธ ์ •๋ณด */} + + + ๊ธฐ๋ณธ ์ •๋ณด + + +
+
+ + handleChange("component_name", e.target.value)} + placeholder="์˜ˆ: ์ •๋ณด ์•Œ๋ฆผ" + /> +
+
+ +
+ 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 && ( +
+ {duplicateCheck.data.isDuplicate ? ( + + ) : ( + + )} +
+ )} +
+ {mode === "create" && formData.component_code && duplicateCheck.data && ( + + + {duplicateCheck.data.isDuplicate + ? "โš ๏ธ ์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์ปดํฌ๋„ŒํŠธ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค." + : "โœ… ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปดํฌ๋„ŒํŠธ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค."} + + + )} +
+
+ +
+ +