diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 421ecbcb..c7e15d81 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5254,3 +5254,51 @@ model table_relationships { @@index([to_table_name], map: "idx_table_relationships_to_table") } + +// 템플릿 표준 관리 테이블 +model template_standards { + template_code String @id @db.VarChar(50) + template_name String @db.VarChar(100) + template_name_eng String? @db.VarChar(100) + description String? @db.Text + category String @db.VarChar(50) + icon_name String? @db.VarChar(50) + default_size Json? // { width: number, height: number } + layout_config Json // 템플릿의 컴포넌트 구조 정의 + preview_image String? @db.VarChar(255) + sort_order Int? @default(0) + is_active String? @default("Y") @db.Char(1) + is_public String? @default("Y") @db.Char(1) + company_code String @db.VarChar(50) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([category], map: "idx_template_standards_category") + @@index([company_code], map: "idx_template_standards_company") +} + +// 컴포넌트 표준 관리 테이블 +model component_standards { + component_code String @id @db.VarChar(50) + component_name String @db.VarChar(100) + component_name_eng String? @db.VarChar(100) + description String? @db.Text + category String @db.VarChar(50) + icon_name String? @db.VarChar(50) + default_size Json? // { width: number, height: number } + component_config Json // 컴포넌트의 기본 설정 및 props + preview_image String? @db.VarChar(255) + sort_order Int? @default(0) + is_active String? @default("Y") @db.Char(1) + is_public String? @default("Y") @db.Char(1) + company_code String @db.VarChar(50) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([category], map: "idx_component_standards_category") + @@index([company_code], map: "idx_component_standards_company") +} diff --git a/backend-node/scripts/add-button-webtype.js b/backend-node/scripts/add-button-webtype.js new file mode 100644 index 00000000..2fd68221 --- /dev/null +++ b/backend-node/scripts/add-button-webtype.js @@ -0,0 +1,52 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function addButtonWebType() { + try { + console.log("🔍 버튼 웹타입 확인 중..."); + + // 기존 button 웹타입 확인 + const existingButton = await prisma.web_type_standards.findUnique({ + where: { web_type: "button" }, + }); + + if (existingButton) { + console.log("✅ 버튼 웹타입이 이미 존재합니다."); + console.log("📄 기존 설정:", JSON.stringify(existingButton, null, 2)); + return; + } + + console.log("➕ 버튼 웹타입 추가 중..."); + + // 버튼 웹타입 추가 + const buttonWebType = await prisma.web_type_standards.create({ + data: { + web_type: "button", + type_name: "버튼", + type_name_eng: "Button", + description: "클릭 가능한 버튼 컴포넌트", + category: "action", + component_name: "ButtonWidget", + config_panel: "ButtonConfigPanel", + default_config: { + actionType: "custom", + variant: "default", + }, + sort_order: 100, + is_active: "Y", + created_by: "system", + updated_by: "system", + }, + }); + + console.log("✅ 버튼 웹타입이 성공적으로 추가되었습니다!"); + console.log("📄 추가된 설정:", JSON.stringify(buttonWebType, null, 2)); + } catch (error) { + console.error("❌ 버튼 웹타입 추가 실패:", error); + } finally { + await prisma.$disconnect(); + } +} + +addButtonWebType(); diff --git a/backend-node/scripts/create-component-table.js b/backend-node/scripts/create-component-table.js new file mode 100644 index 00000000..e40dfbfa --- /dev/null +++ b/backend-node/scripts/create-component-table.js @@ -0,0 +1,74 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function createComponentTable() { + try { + console.log("🔧 component_standards 테이블 생성 중..."); + + // 테이블 생성 SQL + await prisma.$executeRaw` + CREATE TABLE IF NOT EXISTS component_standards ( + component_code VARCHAR(50) PRIMARY KEY, + component_name VARCHAR(100) NOT NULL, + component_name_eng VARCHAR(100), + description TEXT, + category VARCHAR(50) NOT NULL, + icon_name VARCHAR(50), + default_size JSON, + component_config JSON NOT NULL, + preview_image VARCHAR(255), + sort_order INTEGER DEFAULT 0, + is_active CHAR(1) DEFAULT 'Y', + is_public CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50) NOT NULL, + created_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + updated_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(50) + ) + `; + + console.log("✅ component_standards 테이블 생성 완료"); + + // 인덱스 생성 + await prisma.$executeRaw` + CREATE INDEX IF NOT EXISTS idx_component_standards_category + ON component_standards (category) + `; + + await prisma.$executeRaw` + CREATE INDEX IF NOT EXISTS idx_component_standards_company + ON component_standards (company_code) + `; + + console.log("✅ 인덱스 생성 완료"); + + // 테이블 코멘트 추가 + await prisma.$executeRaw` + COMMENT ON TABLE component_standards IS 'UI 컴포넌트 표준 정보를 저장하는 테이블' + `; + + console.log("✅ 테이블 코멘트 추가 완료"); + } catch (error) { + console.error("❌ 테이블 생성 실패:", error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 실행 +if (require.main === module) { + createComponentTable() + .then(() => { + console.log("🎉 테이블 생성 완료!"); + process.exit(0); + }) + .catch((error) => { + console.error("💥 테이블 생성 실패:", error); + process.exit(1); + }); +} + +module.exports = { createComponentTable }; diff --git a/backend-node/scripts/seed-templates.js b/backend-node/scripts/seed-templates.js new file mode 100644 index 00000000..f72b53ea --- /dev/null +++ b/backend-node/scripts/seed-templates.js @@ -0,0 +1,294 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +// 기본 템플릿 데이터 정의 +const defaultTemplates = [ + { + template_code: "advanced-data-table-v2", + template_name: "고급 데이터 테이블 v2", + template_name_eng: "Advanced Data Table v2", + description: + "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트", + category: "table", + icon_name: "table", + default_size: { + width: 1000, + height: 680, + }, + layout_config: { + components: [ + { + type: "datatable", + label: "고급 데이터 테이블", + position: { x: 0, y: 0 }, + size: { width: 1000, height: 680 }, + style: { + border: "1px solid #e5e7eb", + borderRadius: "8px", + backgroundColor: "#ffffff", + padding: "0", + }, + columns: [ + { + id: "id", + label: "ID", + type: "number", + visible: true, + sortable: true, + filterable: false, + width: 80, + }, + { + id: "name", + label: "이름", + type: "text", + visible: true, + sortable: true, + filterable: true, + width: 150, + }, + { + id: "email", + label: "이메일", + type: "email", + visible: true, + sortable: true, + filterable: true, + width: 200, + }, + { + id: "status", + label: "상태", + type: "select", + visible: true, + sortable: true, + filterable: true, + width: 100, + }, + { + id: "created_date", + label: "생성일", + type: "date", + visible: true, + sortable: true, + filterable: true, + width: 120, + }, + ], + filters: [ + { + id: "status", + label: "상태", + type: "select", + options: [ + { label: "전체", value: "" }, + { label: "활성", value: "active" }, + { label: "비활성", value: "inactive" }, + ], + }, + { id: "name", label: "이름", type: "text" }, + { id: "email", label: "이메일", type: "text" }, + ], + pagination: { + enabled: true, + pageSize: 10, + pageSizeOptions: [5, 10, 20, 50, 100], + showPageSizeSelector: true, + showPageInfo: true, + showFirstLast: true, + }, + actions: { + showSearchButton: true, + searchButtonText: "검색", + enableExport: true, + enableRefresh: true, + enableAdd: true, + enableEdit: true, + enableDelete: true, + addButtonText: "추가", + editButtonText: "수정", + deleteButtonText: "삭제", + }, + addModalConfig: { + title: "새 데이터 추가", + description: "테이블에 새로운 데이터를 추가합니다.", + width: "lg", + layout: "two-column", + gridColumns: 2, + fieldOrder: ["name", "email", "status"], + requiredFields: ["name", "email"], + hiddenFields: ["id", "created_date"], + advancedFieldConfigs: { + status: { + type: "select", + options: [ + { label: "활성", value: "active" }, + { label: "비활성", value: "inactive" }, + ], + }, + }, + submitButtonText: "추가", + cancelButtonText: "취소", + }, + }, + ], + }, + sort_order: 1, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "system", + updated_by: "system", + }, + { + template_code: "universal-button", + template_name: "범용 버튼", + template_name_eng: "Universal Button", + description: + "다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.", + category: "button", + icon_name: "mouse-pointer", + default_size: { + width: 80, + height: 36, + }, + layout_config: { + components: [ + { + type: "widget", + widgetType: "button", + label: "버튼", + position: { x: 0, y: 0 }, + size: { width: 80, height: 36 }, + style: { + backgroundColor: "#3b82f6", + color: "#ffffff", + border: "none", + borderRadius: "6px", + fontSize: "14px", + fontWeight: "500", + }, + }, + ], + }, + sort_order: 2, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "system", + updated_by: "system", + }, + { + template_code: "file-upload", + template_name: "파일 첨부", + template_name_eng: "File Upload", + description: "드래그앤드롭 파일 업로드 영역", + category: "file", + icon_name: "upload", + default_size: { + width: 300, + height: 120, + }, + layout_config: { + components: [ + { + type: "widget", + widgetType: "file", + label: "파일 첨부", + position: { x: 0, y: 0 }, + size: { width: 300, height: 120 }, + style: { + border: "2px dashed #d1d5db", + borderRadius: "8px", + backgroundColor: "#f9fafb", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "14px", + color: "#6b7280", + }, + }, + ], + }, + sort_order: 3, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "system", + updated_by: "system", + }, + { + template_code: "form-container", + template_name: "폼 컨테이너", + template_name_eng: "Form Container", + description: "입력 폼을 위한 기본 컨테이너 레이아웃", + category: "form", + icon_name: "form", + default_size: { + width: 400, + height: 300, + }, + layout_config: { + components: [ + { + type: "container", + label: "폼 컨테이너", + position: { x: 0, y: 0 }, + size: { width: 400, height: 300 }, + style: { + border: "1px solid #e5e7eb", + borderRadius: "8px", + backgroundColor: "#ffffff", + padding: "16px", + }, + }, + ], + }, + sort_order: 4, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "system", + updated_by: "system", + }, +]; + +async function seedTemplates() { + console.log("🌱 템플릿 시드 데이터 삽입 시작..."); + + try { + // 기존 템플릿이 있는지 확인하고 없는 경우에만 삽입 + for (const template of defaultTemplates) { + const existing = await prisma.template_standards.findUnique({ + where: { template_code: template.template_code }, + }); + + if (!existing) { + await prisma.template_standards.create({ + data: template, + }); + console.log(`✅ 템플릿 '${template.template_name}' 생성됨`); + } else { + console.log(`⏭️ 템플릿 '${template.template_name}' 이미 존재함`); + } + } + + console.log("🎉 템플릿 시드 데이터 삽입 완료!"); + } catch (error) { + console.error("❌ 템플릿 시드 데이터 삽입 실패:", error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 스크립트가 직접 실행될 때만 시드 함수 실행 +if (require.main === module) { + seedTemplates().catch((error) => { + console.error(error); + process.exit(1); + }); +} + +module.exports = { seedTemplates }; diff --git a/backend-node/scripts/seed-ui-components.js b/backend-node/scripts/seed-ui-components.js new file mode 100644 index 00000000..78a71ead --- /dev/null +++ b/backend-node/scripts/seed-ui-components.js @@ -0,0 +1,411 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +// 실제 UI 구성에 필요한 컴포넌트들 +const uiComponents = [ + // === 액션 컴포넌트 === + { + component_code: "button-primary", + component_name: "기본 버튼", + component_name_eng: "Primary Button", + description: "일반적인 액션을 위한 기본 버튼 컴포넌트", + category: "action", + icon_name: "MousePointer", + default_size: { width: 100, height: 36 }, + component_config: { + type: "button", + variant: "primary", + text: "버튼", + action: "custom", + style: { + backgroundColor: "#3b82f6", + color: "#ffffff", + borderRadius: "6px", + fontSize: "14px", + fontWeight: "500", + }, + }, + sort_order: 10, + }, + { + component_code: "button-secondary", + component_name: "보조 버튼", + component_name_eng: "Secondary Button", + description: "보조 액션을 위한 버튼 컴포넌트", + category: "action", + icon_name: "MousePointer", + default_size: { width: 100, height: 36 }, + component_config: { + type: "button", + variant: "secondary", + text: "취소", + action: "cancel", + style: { + backgroundColor: "#f1f5f9", + color: "#475569", + borderRadius: "6px", + fontSize: "14px", + }, + }, + sort_order: 11, + }, + + // === 레이아웃 컴포넌트 === + { + component_code: "card-basic", + component_name: "기본 카드", + component_name_eng: "Basic Card", + description: "정보를 그룹화하는 기본 카드 컴포넌트", + category: "layout", + icon_name: "Square", + default_size: { width: 400, height: 300 }, + component_config: { + type: "card", + title: "카드 제목", + showHeader: true, + showFooter: false, + style: { + backgroundColor: "#ffffff", + border: "1px solid #e5e7eb", + borderRadius: "8px", + padding: "16px", + boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)", + }, + }, + sort_order: 20, + }, + { + component_code: "dashboard-grid", + component_name: "대시보드 그리드", + component_name_eng: "Dashboard Grid", + description: "대시보드를 위한 그리드 레이아웃 컴포넌트", + category: "layout", + icon_name: "LayoutGrid", + default_size: { width: 800, height: 600 }, + component_config: { + type: "dashboard", + columns: 3, + gap: 16, + items: [], + style: { + backgroundColor: "#f8fafc", + padding: "20px", + borderRadius: "8px", + }, + }, + sort_order: 21, + }, + { + component_code: "panel-collapsible", + component_name: "접을 수 있는 패널", + component_name_eng: "Collapsible Panel", + description: "접고 펼칠 수 있는 패널 컴포넌트", + category: "layout", + icon_name: "ChevronDown", + default_size: { width: 500, height: 200 }, + component_config: { + type: "panel", + title: "패널 제목", + collapsible: true, + defaultExpanded: true, + style: { + backgroundColor: "#ffffff", + border: "1px solid #e5e7eb", + borderRadius: "8px", + }, + }, + sort_order: 22, + }, + + // === 데이터 표시 컴포넌트 === + { + component_code: "stats-card", + component_name: "통계 카드", + component_name_eng: "Statistics Card", + description: "수치와 통계를 표시하는 카드 컴포넌트", + category: "data", + icon_name: "BarChart3", + default_size: { width: 250, height: 120 }, + component_config: { + type: "stats", + title: "총 판매량", + value: "1,234", + unit: "개", + trend: "up", + percentage: "+12.5%", + style: { + backgroundColor: "#ffffff", + border: "1px solid #e5e7eb", + borderRadius: "8px", + padding: "20px", + }, + }, + sort_order: 30, + }, + { + component_code: "progress-bar", + component_name: "진행률 표시", + component_name_eng: "Progress Bar", + description: "작업 진행률을 표시하는 컴포넌트", + category: "data", + icon_name: "BarChart2", + default_size: { width: 300, height: 60 }, + component_config: { + type: "progress", + label: "진행률", + value: 65, + max: 100, + showPercentage: true, + style: { + backgroundColor: "#f1f5f9", + borderRadius: "4px", + height: "8px", + }, + }, + sort_order: 31, + }, + { + component_code: "chart-basic", + component_name: "기본 차트", + component_name_eng: "Basic Chart", + description: "데이터를 시각화하는 기본 차트 컴포넌트", + category: "data", + icon_name: "TrendingUp", + default_size: { width: 500, height: 300 }, + component_config: { + type: "chart", + chartType: "line", + title: "차트 제목", + data: [], + options: { + responsive: true, + plugins: { + legend: { position: "top" }, + }, + }, + }, + sort_order: 32, + }, + + // === 네비게이션 컴포넌트 === + { + component_code: "breadcrumb", + component_name: "브레드크럼", + component_name_eng: "Breadcrumb", + description: "현재 위치를 표시하는 네비게이션 컴포넌트", + category: "navigation", + icon_name: "ChevronRight", + default_size: { width: 400, height: 32 }, + component_config: { + type: "breadcrumb", + items: [ + { label: "홈", href: "/" }, + { label: "관리자", href: "/admin" }, + { label: "현재 페이지" }, + ], + separator: ">", + }, + sort_order: 40, + }, + { + component_code: "tabs-horizontal", + component_name: "가로 탭", + component_name_eng: "Horizontal Tabs", + description: "컨텐츠를 탭으로 구분하는 네비게이션 컴포넌트", + category: "navigation", + icon_name: "Tabs", + default_size: { width: 500, height: 300 }, + component_config: { + type: "tabs", + orientation: "horizontal", + tabs: [ + { id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" }, + { id: "tab2", label: "탭 2", content: "두 번째 탭 내용" }, + ], + defaultTab: "tab1", + }, + sort_order: 41, + }, + { + component_code: "pagination", + component_name: "페이지네이션", + component_name_eng: "Pagination", + description: "페이지를 나눠서 표시하는 네비게이션 컴포넌트", + category: "navigation", + icon_name: "ChevronLeft", + default_size: { width: 300, height: 40 }, + component_config: { + type: "pagination", + currentPage: 1, + totalPages: 10, + showFirst: true, + showLast: true, + showPrevNext: true, + }, + sort_order: 42, + }, + + // === 피드백 컴포넌트 === + { + component_code: "alert-info", + component_name: "정보 알림", + component_name_eng: "Info Alert", + description: "정보를 사용자에게 알리는 컴포넌트", + category: "feedback", + icon_name: "Info", + default_size: { width: 400, height: 60 }, + component_config: { + type: "alert", + variant: "info", + title: "알림", + message: "중요한 정보를 확인해주세요.", + dismissible: true, + icon: true, + }, + sort_order: 50, + }, + { + component_code: "badge-status", + component_name: "상태 뱃지", + component_name_eng: "Status Badge", + description: "상태나 카테고리를 표시하는 뱃지 컴포넌트", + category: "feedback", + icon_name: "Tag", + default_size: { width: 80, height: 24 }, + component_config: { + type: "badge", + text: "활성", + variant: "success", + size: "sm", + style: { + backgroundColor: "#10b981", + color: "#ffffff", + borderRadius: "12px", + fontSize: "12px", + }, + }, + sort_order: 51, + }, + { + component_code: "loading-spinner", + component_name: "로딩 스피너", + component_name_eng: "Loading Spinner", + description: "로딩 상태를 표시하는 스피너 컴포넌트", + category: "feedback", + icon_name: "RefreshCw", + default_size: { width: 100, height: 100 }, + component_config: { + type: "loading", + variant: "spinner", + size: "md", + message: "로딩 중...", + overlay: false, + }, + sort_order: 52, + }, + + // === 입력 컴포넌트 === + { + component_code: "search-box", + component_name: "검색 박스", + component_name_eng: "Search Box", + description: "검색 기능이 있는 입력 컴포넌트", + category: "input", + icon_name: "Search", + default_size: { width: 300, height: 40 }, + component_config: { + type: "search", + placeholder: "검색어를 입력하세요...", + showButton: true, + debounce: 500, + style: { + borderRadius: "20px", + border: "1px solid #d1d5db", + }, + }, + sort_order: 60, + }, + { + component_code: "filter-dropdown", + component_name: "필터 드롭다운", + component_name_eng: "Filter Dropdown", + description: "데이터 필터링을 위한 드롭다운 컴포넌트", + category: "input", + icon_name: "Filter", + default_size: { width: 200, height: 40 }, + component_config: { + type: "filter", + label: "필터", + options: [ + { value: "all", label: "전체" }, + { value: "active", label: "활성" }, + { value: "inactive", label: "비활성" }, + ], + defaultValue: "all", + multiple: false, + }, + sort_order: 61, + }, +]; + +async function seedUIComponents() { + try { + console.log("🚀 UI 컴포넌트 시딩 시작..."); + + // 기존 데이터 삭제 + console.log("📝 기존 컴포넌트 데이터 삭제 중..."); + await prisma.$executeRaw`DELETE FROM component_standards`; + + // 새 컴포넌트 데이터 삽입 + console.log("📦 새로운 UI 컴포넌트 삽입 중..."); + + for (const component of uiComponents) { + await prisma.component_standards.create({ + data: { + ...component, + company_code: "DEFAULT", + created_by: "system", + updated_by: "system", + }, + }); + console.log(`✅ ${component.component_name} 컴포넌트 생성됨`); + } + + console.log( + `\n🎉 총 ${uiComponents.length}개의 UI 컴포넌트가 성공적으로 생성되었습니다!` + ); + + // 카테고리별 통계 + const categoryCounts = {}; + uiComponents.forEach((component) => { + categoryCounts[component.category] = + (categoryCounts[component.category] || 0) + 1; + }); + + console.log("\n📊 카테고리별 컴포넌트 수:"); + Object.entries(categoryCounts).forEach(([category, count]) => { + console.log(` ${category}: ${count}개`); + }); + } catch (error) { + console.error("❌ UI 컴포넌트 시딩 실패:", error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 실행 +if (require.main === module) { + seedUIComponents() + .then(() => { + console.log("✨ UI 컴포넌트 시딩 완료!"); + process.exit(0); + }) + .catch((error) => { + console.error("💥 시딩 실패:", error); + process.exit(1); + }); +} + +module.exports = { seedUIComponents, uiComponents }; diff --git a/backend-node/scripts/test-template-creation.js b/backend-node/scripts/test-template-creation.js new file mode 100644 index 00000000..a4879cbc --- /dev/null +++ b/backend-node/scripts/test-template-creation.js @@ -0,0 +1,121 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function testTemplateCreation() { + console.log("🧪 템플릿 생성 테스트 시작..."); + + try { + // 1. 테이블 존재 여부 확인 + console.log("1. 템플릿 테이블 존재 여부 확인 중..."); + + try { + const count = await prisma.template_standards.count(); + console.log(`✅ template_standards 테이블 발견 (현재 ${count}개 레코드)`); + } catch (error) { + if (error.code === "P2021") { + console.log("❌ template_standards 테이블이 존재하지 않습니다."); + console.log("👉 데이터베이스 마이그레이션이 필요합니다."); + return; + } + throw error; + } + + // 2. 샘플 템플릿 생성 테스트 + console.log("2. 샘플 템플릿 생성 중..."); + + const sampleTemplate = { + template_code: "test-button-" + Date.now(), + template_name: "테스트 버튼", + template_name_eng: "Test Button", + description: "테스트용 버튼 템플릿", + category: "button", + icon_name: "mouse-pointer", + default_size: { + width: 80, + height: 36, + }, + layout_config: { + components: [ + { + type: "widget", + widgetType: "button", + label: "테스트 버튼", + position: { x: 0, y: 0 }, + size: { width: 80, height: 36 }, + style: { + backgroundColor: "#3b82f6", + color: "#ffffff", + border: "none", + borderRadius: "6px", + }, + }, + ], + }, + sort_order: 999, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "test", + updated_by: "test", + }; + + const created = await prisma.template_standards.create({ + data: sampleTemplate, + }); + + console.log("✅ 샘플 템플릿 생성 성공:", created.template_code); + + // 3. 생성된 템플릿 조회 테스트 + console.log("3. 템플릿 조회 테스트 중..."); + + const retrieved = await prisma.template_standards.findUnique({ + where: { template_code: created.template_code }, + }); + + if (retrieved) { + console.log("✅ 템플릿 조회 성공:", retrieved.template_name); + console.log( + "📄 Layout Config:", + JSON.stringify(retrieved.layout_config, null, 2) + ); + } + + // 4. 카테고리 목록 조회 테스트 + console.log("4. 카테고리 목록 조회 테스트 중..."); + + const categories = await prisma.template_standards.findMany({ + where: { is_active: "Y" }, + select: { category: true }, + distinct: ["category"], + }); + + console.log( + "✅ 발견된 카테고리:", + categories.map((c) => c.category) + ); + + // 5. 테스트 데이터 정리 + console.log("5. 테스트 데이터 정리 중..."); + + await prisma.template_standards.delete({ + where: { template_code: created.template_code }, + }); + + console.log("✅ 테스트 데이터 정리 완료"); + + console.log("🎉 모든 테스트 통과!"); + } catch (error) { + console.error("❌ 테스트 실패:", error); + console.error("📋 상세 정보:", { + message: error.message, + code: error.code, + stack: error.stack?.split("\n").slice(0, 5), + }); + } finally { + await prisma.$disconnect(); + } +} + +// 스크립트 실행 +testTemplateCreation(); diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 061c5749..b82b6fb0 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -22,6 +22,8 @@ import companyManagementRoutes from "./routes/companyManagementRoutes"; import webTypeStandardRoutes from "./routes/webTypeStandardRoutes"; import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes"; import screenStandardRoutes from "./routes/screenStandardRoutes"; +import templateStandardRoutes from "./routes/templateStandardRoutes"; +import componentStandardRoutes from "./routes/componentStandardRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -106,6 +108,8 @@ app.use("/api/files", fileRoutes); app.use("/api/company-management", companyManagementRoutes); app.use("/api/admin/web-types", webTypeStandardRoutes); app.use("/api/admin/button-actions", buttonActionStandardRoutes); +app.use("/api/admin/template-standards", templateStandardRoutes); +app.use("/api/admin/component-standards", componentStandardRoutes); app.use("/api/screen", screenStandardRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/componentStandardController.ts b/backend-node/src/controllers/componentStandardController.ts new file mode 100644 index 00000000..33f2f95b --- /dev/null +++ b/backend-node/src/controllers/componentStandardController.ts @@ -0,0 +1,387 @@ +import { Request, Response } from "express"; +import componentStandardService, { + ComponentQueryParams, +} from "../services/componentStandardService"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + companyCode: string; + [key: string]: any; + }; +} + +class ComponentStandardController { + /** + * 컴포넌트 목록 조회 + */ + async getComponents(req: AuthenticatedRequest, res: Response): Promise { + try { + const { + category, + active, + is_public, + search, + sort, + order, + limit, + offset, + } = req.query; + + const params: ComponentQueryParams = { + category: category as string, + active: (active as string) || "Y", + is_public: is_public as string, + company_code: req.user?.companyCode, + search: search as string, + sort: (sort as string) || "sort_order", + order: (order as "asc" | "desc") || "asc", + limit: limit ? parseInt(limit as string) : undefined, + offset: offset ? parseInt(offset as string) : 0, + }; + + const result = await componentStandardService.getComponents(params); + + res.status(200).json({ + success: true, + data: result, + message: "컴포넌트 목록을 성공적으로 조회했습니다.", + }); + return; + } catch (error) { + console.error("컴포넌트 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "컴포넌트 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 컴포넌트 상세 조회 + */ + async getComponent(req: AuthenticatedRequest, res: Response): Promise { + try { + const { component_code } = req.params; + + if (!component_code) { + res.status(400).json({ + success: false, + message: "컴포넌트 코드가 필요합니다.", + }); + return; + } + + const component = + await componentStandardService.getComponent(component_code); + + res.status(200).json({ + success: true, + data: component, + message: "컴포넌트를 성공적으로 조회했습니다.", + }); + return; + } catch (error) { + console.error("컴포넌트 조회 실패:", error); + res.status(404).json({ + success: false, + message: "컴포넌트를 찾을 수 없습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 컴포넌트 생성 + */ + async createComponent( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { + component_code, + component_name, + component_name_eng, + description, + category, + icon_name, + default_size, + component_config, + preview_image, + sort_order, + is_active, + is_public, + } = req.body; + + // 필수 필드 검증 + if ( + !component_code || + !component_name || + !category || + !component_config + ) { + res.status(400).json({ + success: false, + message: + "필수 필드가 누락되었습니다. (component_code, component_name, category, component_config)", + }); + return; + } + + const componentData = { + component_code, + component_name, + component_name_eng, + description, + category, + icon_name, + default_size, + component_config, + preview_image, + sort_order, + is_active: is_active || "Y", + is_public: is_public || "Y", + company_code: req.user?.companyCode || "DEFAULT", + created_by: req.user?.userId, + updated_by: req.user?.userId, + }; + + const component = + await componentStandardService.createComponent(componentData); + + res.status(201).json({ + success: true, + data: component, + message: "컴포넌트가 성공적으로 생성되었습니다.", + }); + return; + } catch (error) { + console.error("컴포넌트 생성 실패:", error); + res.status(400).json({ + success: false, + message: "컴포넌트 생성에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 컴포넌트 수정 + */ + async updateComponent( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { component_code } = req.params; + const updateData = { + ...req.body, + updated_by: req.user?.userId, + }; + + if (!component_code) { + res.status(400).json({ + success: false, + message: "컴포넌트 코드가 필요합니다.", + }); + return; + } + + const component = await componentStandardService.updateComponent( + component_code, + updateData + ); + + res.status(200).json({ + success: true, + data: component, + message: "컴포넌트가 성공적으로 수정되었습니다.", + }); + return; + } catch (error) { + console.error("컴포넌트 수정 실패:", error); + res.status(400).json({ + success: false, + message: "컴포넌트 수정에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 컴포넌트 삭제 + */ + async deleteComponent( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { component_code } = req.params; + + if (!component_code) { + res.status(400).json({ + success: false, + message: "컴포넌트 코드가 필요합니다.", + }); + return; + } + + const result = + await componentStandardService.deleteComponent(component_code); + + res.status(200).json({ + success: true, + data: result, + message: "컴포넌트가 성공적으로 삭제되었습니다.", + }); + return; + } catch (error) { + console.error("컴포넌트 삭제 실패:", error); + res.status(400).json({ + success: false, + message: "컴포넌트 삭제에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 컴포넌트 정렬 순서 업데이트 + */ + async updateSortOrder( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { updates } = req.body; + + if (!updates || !Array.isArray(updates)) { + res.status(400).json({ + success: false, + message: "업데이트 데이터가 필요합니다.", + }); + return; + } + + const result = await componentStandardService.updateSortOrder(updates); + + res.status(200).json({ + success: true, + data: result, + message: "정렬 순서가 성공적으로 업데이트되었습니다.", + }); + return; + } catch (error) { + console.error("정렬 순서 업데이트 실패:", error); + res.status(400).json({ + success: false, + message: "정렬 순서 업데이트에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 컴포넌트 복제 + */ + async duplicateComponent( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { source_code, new_code, new_name } = req.body; + + if (!source_code || !new_code || !new_name) { + res.status(400).json({ + success: false, + message: + "필수 필드가 누락되었습니다. (source_code, new_code, new_name)", + }); + return; + } + + const component = await componentStandardService.duplicateComponent( + source_code, + new_code, + new_name + ); + + res.status(201).json({ + success: true, + data: component, + message: "컴포넌트가 성공적으로 복제되었습니다.", + }); + return; + } catch (error) { + console.error("컴포넌트 복제 실패:", error); + res.status(400).json({ + success: false, + message: "컴포넌트 복제에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 카테고리 목록 조회 + */ + async getCategories(req: AuthenticatedRequest, res: Response): Promise { + try { + const categories = await componentStandardService.getCategories( + req.user?.companyCode + ); + + res.status(200).json({ + success: true, + data: categories, + message: "카테고리 목록을 성공적으로 조회했습니다.", + }); + return; + } catch (error) { + console.error("카테고리 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } + + /** + * 컴포넌트 통계 조회 + */ + async getStatistics(req: AuthenticatedRequest, res: Response): Promise { + try { + const statistics = await componentStandardService.getStatistics( + req.user?.companyCode + ); + + res.status(200).json({ + success: true, + data: statistics, + message: "통계를 성공적으로 조회했습니다.", + }); + 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 new file mode 100644 index 00000000..a08245ad --- /dev/null +++ b/backend-node/src/controllers/templateStandardController.ts @@ -0,0 +1,381 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { templateStandardService } from "../services/templateStandardService"; +import { handleError } from "../utils/errorHandler"; +import { checkMissingFields } from "../utils/validation"; + +/** + * 템플릿 표준 관리 컨트롤러 + */ +export class TemplateStandardController { + /** + * 템플릿 목록 조회 + */ + async getTemplates(req: AuthenticatedRequest, res: Response) { + try { + const { + active = "Y", + category, + search, + companyCode, + is_public = "Y", + page = "1", + limit = "50", + } = req.query; + + const user = req.user; + const userCompanyCode = user?.companyCode || "DEFAULT"; + + const result = await templateStandardService.getTemplates({ + active: active as string, + category: category as string, + search: search as string, + company_code: (companyCode as string) || userCompanyCode, + is_public: is_public as string, + page: parseInt(page as string), + limit: parseInt(limit as string), + }); + + res.json({ + success: true, + data: result.templates, + pagination: { + total: result.total, + page: parseInt(page as string), + limit: parseInt(limit as string), + totalPages: Math.ceil(result.total / parseInt(limit as string)), + }, + }); + } catch (error) { + return handleError( + res, + error, + "템플릿 목록 조회 중 오류가 발생했습니다." + ); + } + } + + /** + * 템플릿 상세 조회 + */ + async getTemplate(req: AuthenticatedRequest, res: Response) { + try { + const { templateCode } = req.params; + + if (!templateCode) { + return res.status(400).json({ + success: false, + error: "템플릿 코드가 필요합니다.", + }); + } + + const template = await templateStandardService.getTemplate(templateCode); + + if (!template) { + return res.status(404).json({ + success: false, + error: "템플릿을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: template, + }); + } catch (error) { + return handleError(res, error, "템플릿 조회 중 오류가 발생했습니다."); + } + } + + /** + * 템플릿 생성 + */ + async createTemplate(req: AuthenticatedRequest, res: Response) { + 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({ + success: false, + error: `필수 필드가 누락되었습니다: ${missingFields.join(", ")}`, + }); + } + + // 회사 코드와 생성자 정보 추가 + const templateWithMeta = { + ...templateData, + company_code: user?.companyCode || "DEFAULT", + created_by: user?.userId || "system", + updated_by: user?.userId || "system", + }; + + const newTemplate = + await templateStandardService.createTemplate(templateWithMeta); + + res.status(201).json({ + success: true, + data: newTemplate, + message: "템플릿이 성공적으로 생성되었습니다.", + }); + } catch (error) { + return handleError(res, error, "템플릿 생성 중 오류가 발생했습니다."); + } + } + + /** + * 템플릿 수정 + */ + async updateTemplate(req: AuthenticatedRequest, res: Response) { + try { + const { templateCode } = req.params; + const templateData = req.body; + const user = req.user; + + if (!templateCode) { + return res.status(400).json({ + success: false, + error: "템플릿 코드가 필요합니다.", + }); + } + + // 수정자 정보 추가 + const templateWithMeta = { + ...templateData, + updated_by: user?.userId || "system", + }; + + const updatedTemplate = await templateStandardService.updateTemplate( + templateCode, + templateWithMeta + ); + + if (!updatedTemplate) { + return res.status(404).json({ + success: false, + error: "템플릿을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: updatedTemplate, + message: "템플릿이 성공적으로 수정되었습니다.", + }); + } catch (error) { + return handleError(res, error, "템플릿 수정 중 오류가 발생했습니다."); + } + } + + /** + * 템플릿 삭제 + */ + async deleteTemplate(req: AuthenticatedRequest, res: Response) { + try { + const { templateCode } = req.params; + + if (!templateCode) { + return res.status(400).json({ + success: false, + error: "템플릿 코드가 필요합니다.", + }); + } + + const deleted = + await templateStandardService.deleteTemplate(templateCode); + + if (!deleted) { + return res.status(404).json({ + success: false, + error: "템플릿을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "템플릿이 성공적으로 삭제되었습니다.", + }); + } catch (error) { + return handleError(res, error, "템플릿 삭제 중 오류가 발생했습니다."); + } + } + + /** + * 템플릿 정렬 순서 일괄 업데이트 + */ + async updateSortOrder(req: AuthenticatedRequest, res: Response) { + try { + const { templates } = req.body; + + if (!Array.isArray(templates)) { + return res.status(400).json({ + success: false, + error: "templates는 배열이어야 합니다.", + }); + } + + await templateStandardService.updateSortOrder(templates); + + res.json({ + success: true, + message: "템플릿 정렬 순서가 성공적으로 업데이트되었습니다.", + }); + } catch (error) { + return handleError( + res, + error, + "템플릿 정렬 순서 업데이트 중 오류가 발생했습니다." + ); + } + } + + /** + * 템플릿 복제 + */ + async duplicateTemplate(req: AuthenticatedRequest, res: Response) { + 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({ + success: false, + error: "필수 필드가 누락되었습니다.", + }); + } + + const duplicatedTemplate = + await templateStandardService.duplicateTemplate({ + originalCode: templateCode, + newCode: new_template_code, + newName: new_template_name, + company_code: user?.companyCode || "DEFAULT", + created_by: user?.userId || "system", + }); + + res.status(201).json({ + success: true, + data: duplicatedTemplate, + message: "템플릿이 성공적으로 복제되었습니다.", + }); + } catch (error) { + return handleError(res, error, "템플릿 복제 중 오류가 발생했습니다."); + } + } + + /** + * 템플릿 카테고리 목록 조회 + */ + async getCategories(req: AuthenticatedRequest, res: Response) { + try { + const user = req.user; + const companyCode = user?.companyCode || "DEFAULT"; + + const categories = + await templateStandardService.getCategories(companyCode); + + res.json({ + success: true, + data: categories, + }); + } catch (error) { + return handleError( + res, + error, + "템플릿 카테고리 조회 중 오류가 발생했습니다." + ); + } + } + + /** + * 템플릿 가져오기 (JSON 파일에서) + */ + async importTemplate(req: AuthenticatedRequest, res: Response) { + try { + const user = req.user; + const templateData = req.body; + + if (!templateData.layout_config) { + return res.status(400).json({ + success: false, + error: "유효한 템플릿 데이터가 아닙니다.", + }); + } + + // 회사 코드와 생성자 정보 추가 + const templateWithMeta = { + ...templateData, + company_code: user?.companyCode || "DEFAULT", + created_by: user?.userId || "system", + updated_by: user?.userId || "system", + }; + + const importedTemplate = + await templateStandardService.createTemplate(templateWithMeta); + + res.status(201).json({ + success: true, + data: importedTemplate, + message: "템플릿이 성공적으로 가져왔습니다.", + }); + } catch (error) { + return handleError(res, error, "템플릿 가져오기 중 오류가 발생했습니다."); + } + } + + /** + * 템플릿 내보내기 (JSON 형태로) + */ + async exportTemplate(req: AuthenticatedRequest, res: Response) { + try { + const { templateCode } = req.params; + + if (!templateCode) { + return res.status(400).json({ + success: false, + error: "템플릿 코드가 필요합니다.", + }); + } + + const template = await templateStandardService.getTemplate(templateCode); + + if (!template) { + return res.status(404).json({ + success: false, + error: "템플릿을 찾을 수 없습니다.", + }); + } + + // 내보내기용 데이터 (메타데이터 제외) + const exportData = { + template_code: template.template_code, + template_name: template.template_name, + template_name_eng: template.template_name_eng, + description: template.description, + category: template.category, + icon_name: template.icon_name, + default_size: template.default_size, + layout_config: template.layout_config, + }; + + res.json({ + success: true, + data: exportData, + }); + } catch (error) { + return handleError(res, error, "템플릿 내보내기 중 오류가 발생했습니다."); + } + } +} + +export const templateStandardController = new TemplateStandardController(); diff --git a/backend-node/src/controllers/webTypeStandardController.ts b/backend-node/src/controllers/webTypeStandardController.ts index a62e892e..c35b6dc4 100644 --- a/backend-node/src/controllers/webTypeStandardController.ts +++ b/backend-node/src/controllers/webTypeStandardController.ts @@ -2,42 +2,6 @@ import { Request, Response } from "express"; import { PrismaClient } from "@prisma/client"; import { AuthenticatedRequest } from "../types/auth"; -// 임시 타입 확장 (Prisma Client 재생성 전까지) -interface WebTypeStandardCreateData { - web_type: string; - type_name: string; - type_name_eng?: string; - description?: string; - category?: string; - component_name?: string; - config_panel?: string; - default_config?: any; - validation_rules?: any; - default_style?: any; - input_properties?: any; - sort_order?: number; - is_active?: string; - created_by?: string; - updated_by?: string; -} - -interface WebTypeStandardUpdateData { - type_name?: string; - type_name_eng?: string; - description?: string; - category?: string; - component_name?: string; - config_panel?: string; - default_config?: any; - validation_rules?: any; - default_style?: any; - input_properties?: any; - sort_order?: number; - is_active?: string; - updated_by?: string; - updated_date?: Date; -} - const prisma = new PrismaClient(); export class WebTypeStandardController { @@ -173,7 +137,7 @@ export class WebTypeStandardController { is_active, created_by: req.user?.userId || "system", updated_by: req.user?.userId || "system", - } as WebTypeStandardCreateData, + }, }); return res.status(201).json({ @@ -239,7 +203,7 @@ export class WebTypeStandardController { is_active, updated_by: req.user?.userId || "system", updated_date: new Date(), - } as WebTypeStandardUpdateData, + }, }); return res.json({ diff --git a/backend-node/src/routes/componentStandardRoutes.ts b/backend-node/src/routes/componentStandardRoutes.ts new file mode 100644 index 00000000..565fa7d0 --- /dev/null +++ b/backend-node/src/routes/componentStandardRoutes.ts @@ -0,0 +1,66 @@ +import { Router } from "express"; +import componentStandardController from "../controllers/componentStandardController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 컴포넌트 목록 조회 +router.get( + "/", + componentStandardController.getComponents.bind(componentStandardController) +); + +// 카테고리 목록 조회 +router.get( + "/categories", + componentStandardController.getCategories.bind(componentStandardController) +); + +// 통계 조회 +router.get( + "/statistics", + componentStandardController.getStatistics.bind(componentStandardController) +); + +// 컴포넌트 상세 조회 +router.get( + "/:component_code", + componentStandardController.getComponent.bind(componentStandardController) +); + +// 컴포넌트 생성 +router.post( + "/", + componentStandardController.createComponent.bind(componentStandardController) +); + +// 컴포넌트 수정 +router.put( + "/:component_code", + componentStandardController.updateComponent.bind(componentStandardController) +); + +// 컴포넌트 삭제 +router.delete( + "/:component_code", + componentStandardController.deleteComponent.bind(componentStandardController) +); + +// 정렬 순서 업데이트 +router.put( + "/sort/order", + componentStandardController.updateSortOrder.bind(componentStandardController) +); + +// 컴포넌트 복제 +router.post( + "/duplicate", + componentStandardController.duplicateComponent.bind( + componentStandardController + ) +); + +export default router; diff --git a/backend-node/src/routes/templateStandardRoutes.ts b/backend-node/src/routes/templateStandardRoutes.ts new file mode 100644 index 00000000..ea3b8627 --- /dev/null +++ b/backend-node/src/routes/templateStandardRoutes.ts @@ -0,0 +1,70 @@ +import { Router } from "express"; +import { templateStandardController } from "../controllers/templateStandardController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 템플릿 목록 조회 +router.get( + "/", + templateStandardController.getTemplates.bind(templateStandardController) +); + +// 템플릿 카테고리 목록 조회 +router.get( + "/categories", + templateStandardController.getCategories.bind(templateStandardController) +); + +// 템플릿 정렬 순서 일괄 업데이트 +router.put( + "/sort-order/bulk", + templateStandardController.updateSortOrder.bind(templateStandardController) +); + +// 템플릿 가져오기 +router.post( + "/import", + templateStandardController.importTemplate.bind(templateStandardController) +); + +// 템플릿 상세 조회 +router.get( + "/:templateCode", + templateStandardController.getTemplate.bind(templateStandardController) +); + +// 템플릿 내보내기 +router.get( + "/:templateCode/export", + templateStandardController.exportTemplate.bind(templateStandardController) +); + +// 템플릿 생성 +router.post( + "/", + templateStandardController.createTemplate.bind(templateStandardController) +); + +// 템플릿 수정 +router.put( + "/:templateCode", + templateStandardController.updateTemplate.bind(templateStandardController) +); + +// 템플릿 삭제 +router.delete( + "/:templateCode", + templateStandardController.deleteTemplate.bind(templateStandardController) +); + +// 템플릿 복제 +router.post( + "/:templateCode/duplicate", + templateStandardController.duplicateTemplate.bind(templateStandardController) +); + +export default router; diff --git a/backend-node/src/services/componentStandardService.ts b/backend-node/src/services/componentStandardService.ts new file mode 100644 index 00000000..6a05c122 --- /dev/null +++ b/backend-node/src/services/componentStandardService.ts @@ -0,0 +1,302 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export interface ComponentStandardData { + component_code: string; + component_name: string; + component_name_eng?: string; + description?: string; + category: string; + icon_name?: string; + default_size?: any; + component_config: any; + preview_image?: string; + sort_order?: number; + is_active?: string; + is_public?: string; + company_code: string; + created_by?: string; + updated_by?: string; +} + +export interface ComponentQueryParams { + category?: string; + active?: string; + is_public?: string; + company_code?: string; + search?: string; + sort?: string; + order?: "asc" | "desc"; + limit?: number; + offset?: number; +} + +class ComponentStandardService { + /** + * 컴포넌트 목록 조회 + */ + async getComponents(params: ComponentQueryParams = {}) { + const { + category, + active = "Y", + is_public, + company_code, + search, + sort = "sort_order", + order = "asc", + limit, + offset = 0, + } = params; + + const where: any = {}; + + // 활성화 상태 필터 + if (active) { + where.is_active = active; + } + + // 카테고리 필터 + if (category && category !== "all") { + where.category = category; + } + + // 공개 여부 필터 + if (is_public) { + where.is_public = is_public; + } + + // 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트) + if (company_code) { + where.OR = [{ is_public: "Y" }, { company_code }]; + } + + // 검색 조건 + if (search) { + where.OR = [ + ...(where.OR || []), + { component_name: { contains: search, mode: "insensitive" } }, + { component_name_eng: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ]; + } + + const orderBy: any = {}; + orderBy[sort] = order; + + const components = await prisma.component_standards.findMany({ + where, + orderBy, + take: limit, + skip: offset, + }); + + const total = await prisma.component_standards.count({ where }); + + return { + components, + total, + limit, + offset, + }; + } + + /** + * 컴포넌트 상세 조회 + */ + async getComponent(component_code: string) { + const component = await prisma.component_standards.findUnique({ + where: { component_code }, + }); + + if (!component) { + throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`); + } + + return component; + } + + /** + * 컴포넌트 생성 + */ + async createComponent(data: ComponentStandardData) { + // 중복 코드 확인 + const existing = await prisma.component_standards.findUnique({ + where: { component_code: data.component_code }, + }); + + if (existing) { + throw new Error( + `이미 존재하는 컴포넌트 코드입니다: ${data.component_code}` + ); + } + + const component = await prisma.component_standards.create({ + data: { + ...data, + created_date: new Date(), + updated_date: new Date(), + }, + }); + + return component; + } + + /** + * 컴포넌트 수정 + */ + async updateComponent( + component_code: string, + data: Partial + ) { + const existing = await this.getComponent(component_code); + + const component = await prisma.component_standards.update({ + where: { component_code }, + data: { + ...data, + updated_date: new Date(), + }, + }); + + return component; + } + + /** + * 컴포넌트 삭제 + */ + async deleteComponent(component_code: string) { + const existing = await this.getComponent(component_code); + + await prisma.component_standards.delete({ + where: { component_code }, + }); + + return { message: `컴포넌트가 삭제되었습니다: ${component_code}` }; + } + + /** + * 컴포넌트 정렬 순서 업데이트 + */ + async updateSortOrder( + updates: Array<{ component_code: string; sort_order: number }> + ) { + const transactions = updates.map(({ component_code, sort_order }) => + prisma.component_standards.update({ + where: { component_code }, + data: { sort_order, updated_date: new Date() }, + }) + ); + + await prisma.$transaction(transactions); + + return { message: "정렬 순서가 업데이트되었습니다." }; + } + + /** + * 컴포넌트 복제 + */ + async duplicateComponent( + source_code: string, + new_code: string, + new_name: string + ) { + const source = await this.getComponent(source_code); + + // 새 코드 중복 확인 + const existing = await prisma.component_standards.findUnique({ + where: { component_code: new_code }, + }); + + if (existing) { + throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`); + } + + const component = await prisma.component_standards.create({ + 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, + created_date: new Date(), + created_by: source.created_by, + updated_date: new Date(), + updated_by: source.updated_by, + }, + }); + + return component; + } + + /** + * 카테고리 목록 조회 + */ + async getCategories(company_code?: string) { + const where: any = { + is_active: "Y", + }; + + if (company_code) { + where.OR = [{ is_public: "Y" }, { company_code }]; + } + + const categories = await prisma.component_standards.findMany({ + where, + select: { category: true }, + distinct: ["category"], + }); + + return categories + .map((item) => item.category) + .filter((category) => category !== null); + } + + /** + * 컴포넌트 통계 + */ + async getStatistics(company_code?: string) { + const where: any = { + is_active: "Y", + }; + + if (company_code) { + where.OR = [{ is_public: "Y" }, { company_code }]; + } + + const total = await prisma.component_standards.count({ where }); + + const byCategory = await prisma.component_standards.groupBy({ + by: ["category"], + where, + _count: { category: true }, + }); + + const byStatus = await prisma.component_standards.groupBy({ + by: ["is_active"], + _count: { is_active: true }, + }); + + return { + total, + byCategory: byCategory.map((item) => ({ + category: item.category, + count: item._count.category, + })), + byStatus: byStatus.map((item) => ({ + status: item.is_active, + count: item._count.is_active, + })), + }; + } +} + +export default new ComponentStandardService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 3edcabaf..ee9c9e92 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -857,8 +857,9 @@ export class TableManagementService { logger.info(`테이블 데이터 조회: ${tableName}`, options); - // 🎯 파일 타입 컬럼 감지 - const fileColumns = await this.getFileTypeColumns(tableName); + // 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지) + // const fileColumns = await this.getFileTypeColumns(tableName); + const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화 // WHERE 조건 구성 let whereConditions: string[] = []; diff --git a/backend-node/src/services/templateStandardService.ts b/backend-node/src/services/templateStandardService.ts new file mode 100644 index 00000000..f9d436c2 --- /dev/null +++ b/backend-node/src/services/templateStandardService.ts @@ -0,0 +1,395 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +/** + * 템플릿 표준 관리 서비스 + */ +export class TemplateStandardService { + /** + * 템플릿 목록 조회 + */ + async getTemplates(params: { + active?: string; + category?: string; + search?: string; + company_code?: string; + is_public?: string; + page?: number; + limit?: number; + }) { + const { + active = "Y", + category, + search, + company_code, + is_public = "Y", + page = 1, + limit = 50, + } = params; + + const skip = (page - 1) * limit; + + // 기본 필터 조건 + const where: any = {}; + + if (active && active !== "all") { + where.is_active = active; + } + + if (category && category !== "all") { + where.category = category; + } + + if (search) { + where.OR = [ + { template_name: { contains: search, mode: "insensitive" } }, + { template_name_eng: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ]; + } + + // 회사별 필터링 (공개 템플릿 + 해당 회사 템플릿) + if (company_code) { + where.OR = [{ is_public: "Y" }, { company_code: company_code }]; + } else if (is_public === "Y") { + where.is_public = "Y"; + } + + const [templates, total] = await Promise.all([ + prisma.template_standards.findMany({ + where, + orderBy: [{ sort_order: "asc" }, { template_name: "asc" }], + skip, + take: limit, + }), + prisma.template_standards.count({ where }), + ]); + + return { templates, total }; + } + + /** + * 템플릿 상세 조회 + */ + async getTemplate(templateCode: string) { + return await prisma.template_standards.findUnique({ + where: { template_code: templateCode }, + }); + } + + /** + * 템플릿 생성 + */ + async createTemplate(templateData: any) { + // 템플릿 코드 중복 확인 + const existing = await prisma.template_standards.findUnique({ + where: { template_code: templateData.template_code }, + }); + + if (existing) { + throw new Error( + `템플릿 코드 '${templateData.template_code}'는 이미 존재합니다.` + ); + } + + return await prisma.template_standards.create({ + data: { + template_code: templateData.template_code, + template_name: templateData.template_name, + template_name_eng: templateData.template_name_eng, + description: templateData.description, + category: templateData.category, + icon_name: templateData.icon_name, + default_size: templateData.default_size, + layout_config: templateData.layout_config, + preview_image: templateData.preview_image, + sort_order: templateData.sort_order || 0, + is_active: templateData.is_active || "Y", + is_public: templateData.is_public || "N", + company_code: templateData.company_code, + created_by: templateData.created_by, + updated_by: templateData.updated_by, + }, + }); + } + + /** + * 템플릿 수정 + */ + async updateTemplate(templateCode: string, templateData: any) { + const updateData: any = {}; + + // 수정 가능한 필드들만 업데이트 + if (templateData.template_name !== undefined) { + updateData.template_name = templateData.template_name; + } + if (templateData.template_name_eng !== undefined) { + updateData.template_name_eng = templateData.template_name_eng; + } + if (templateData.description !== undefined) { + updateData.description = templateData.description; + } + if (templateData.category !== undefined) { + updateData.category = templateData.category; + } + if (templateData.icon_name !== undefined) { + updateData.icon_name = templateData.icon_name; + } + if (templateData.default_size !== undefined) { + updateData.default_size = templateData.default_size; + } + if (templateData.layout_config !== undefined) { + updateData.layout_config = templateData.layout_config; + } + if (templateData.preview_image !== undefined) { + updateData.preview_image = templateData.preview_image; + } + if (templateData.sort_order !== undefined) { + updateData.sort_order = templateData.sort_order; + } + if (templateData.is_active !== undefined) { + updateData.is_active = templateData.is_active; + } + if (templateData.is_public !== undefined) { + updateData.is_public = templateData.is_public; + } + if (templateData.updated_by !== undefined) { + updateData.updated_by = templateData.updated_by; + } + + updateData.updated_date = new Date(); + + try { + return await prisma.template_standards.update({ + where: { template_code: templateCode }, + data: updateData, + }); + } catch (error: any) { + if (error.code === "P2025") { + return null; // 템플릿을 찾을 수 없음 + } + throw error; + } + } + + /** + * 템플릿 삭제 + */ + async deleteTemplate(templateCode: string) { + try { + await prisma.template_standards.delete({ + where: { template_code: templateCode }, + }); + return true; + } catch (error: any) { + if (error.code === "P2025") { + return false; // 템플릿을 찾을 수 없음 + } + throw error; + } + } + + /** + * 템플릿 정렬 순서 일괄 업데이트 + */ + async updateSortOrder( + templates: { template_code: string; sort_order: number }[] + ) { + const updatePromises = templates.map((template) => + prisma.template_standards.update({ + where: { template_code: template.template_code }, + data: { + sort_order: template.sort_order, + updated_date: new Date(), + }, + }) + ); + + await Promise.all(updatePromises); + } + + /** + * 템플릿 복제 + */ + async duplicateTemplate(params: { + originalCode: string; + newCode: string; + newName: string; + company_code: string; + created_by: string; + }) { + const { originalCode, newCode, newName, company_code, created_by } = params; + + // 원본 템플릿 조회 + const originalTemplate = await this.getTemplate(originalCode); + if (!originalTemplate) { + throw new Error("원본 템플릿을 찾을 수 없습니다."); + } + + // 새 템플릿 코드 중복 확인 + const existing = await this.getTemplate(newCode); + if (existing) { + throw new Error(`템플릿 코드 '${newCode}'는 이미 존재합니다.`); + } + + // 템플릿 복제 + return await this.createTemplate({ + template_code: newCode, + template_name: newName, + template_name_eng: originalTemplate.template_name_eng + ? `${originalTemplate.template_name_eng} (Copy)` + : undefined, + description: originalTemplate.description, + category: originalTemplate.category, + icon_name: originalTemplate.icon_name, + default_size: originalTemplate.default_size, + layout_config: originalTemplate.layout_config, + preview_image: originalTemplate.preview_image, + sort_order: 0, + is_active: "Y", + is_public: "N", // 복제된 템플릿은 기본적으로 비공개 + company_code, + created_by, + updated_by: created_by, + }); + } + + /** + * 템플릿 카테고리 목록 조회 + */ + async getCategories(companyCode: string) { + const categories = await prisma.template_standards.findMany({ + where: { + OR: [{ is_public: "Y" }, { company_code: companyCode }], + is_active: "Y", + }, + select: { category: true }, + distinct: ["category"], + orderBy: { category: "asc" }, + }); + + return categories.map((item) => item.category).filter(Boolean); + } + + /** + * 기본 템플릿 데이터 삽입 (초기 설정용) + */ + async seedDefaultTemplates() { + const defaultTemplates = [ + { + template_code: "advanced-data-table", + template_name: "고급 데이터 테이블", + template_name_eng: "Advanced Data Table", + description: + "컬럼 설정, 필터링, 페이지네이션이 포함된 완전한 데이터 테이블", + category: "table", + icon_name: "table", + default_size: { width: 1000, height: 680 }, + layout_config: { + components: [ + { + type: "datatable", + label: "데이터 테이블", + position: { x: 0, y: 0 }, + size: { width: 1000, height: 680 }, + style: { + border: "1px solid #e5e7eb", + borderRadius: "8px", + backgroundColor: "#ffffff", + padding: "16px", + }, + }, + ], + }, + sort_order: 1, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "system", + updated_by: "system", + }, + { + template_code: "universal-button", + template_name: "버튼", + template_name_eng: "Universal Button", + description: + "다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.", + category: "button", + icon_name: "mouse-pointer", + default_size: { width: 80, height: 36 }, + layout_config: { + components: [ + { + type: "widget", + widgetType: "button", + label: "버튼", + position: { x: 0, y: 0 }, + size: { width: 80, height: 36 }, + style: { + backgroundColor: "#3b82f6", + color: "#ffffff", + border: "none", + borderRadius: "6px", + fontSize: "14px", + fontWeight: "500", + }, + }, + ], + }, + sort_order: 2, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "system", + updated_by: "system", + }, + { + template_code: "file-upload", + template_name: "파일 첨부", + template_name_eng: "File Upload", + description: "드래그앤드롭 파일 업로드 영역", + category: "file", + icon_name: "upload", + default_size: { width: 300, height: 120 }, + layout_config: { + components: [ + { + type: "widget", + widgetType: "file", + label: "파일 첨부", + position: { x: 0, y: 0 }, + size: { width: 300, height: 120 }, + style: { + border: "2px dashed #d1d5db", + borderRadius: "8px", + backgroundColor: "#f9fafb", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "14px", + color: "#6b7280", + }, + }, + ], + }, + sort_order: 3, + is_active: "Y", + is_public: "Y", + company_code: "*", + created_by: "system", + updated_by: "system", + }, + ]; + + // 기존 데이터가 있는지 확인 후 삽입 + for (const template of defaultTemplates) { + const existing = await this.getTemplate(template.template_code); + if (!existing) { + await this.createTemplate(template); + } + } + } +} + +export const templateStandardService = new TemplateStandardService(); diff --git a/backend-node/src/utils/errorHandler.ts b/backend-node/src/utils/errorHandler.ts new file mode 100644 index 00000000..e8239273 --- /dev/null +++ b/backend-node/src/utils/errorHandler.ts @@ -0,0 +1,69 @@ +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 new file mode 100644 index 00000000..ce3f909c --- /dev/null +++ b/backend-node/src/utils/validation.ts @@ -0,0 +1,101 @@ +/** + * 유효성 검증 유틸리티 + */ + +/** + * 필수 값 검증 + */ +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 new file mode 100644 index 00000000..8273f6d1 --- /dev/null +++ b/frontend/app/(main)/admin/components/page.tsx @@ -0,0 +1,318 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter, Download, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { AlertModal } from "@/components/common/AlertModal"; +import { + useComponents, + useComponentCategories, + useComponentStatistics, + useDeleteComponent, +} from "@/hooks/admin/useComponents"; + +// 컴포넌트 카테고리 정의 +const COMPONENT_CATEGORIES = [ + { id: "input", name: "입력", color: "blue" }, + { id: "action", name: "액션", color: "green" }, + { id: "display", name: "표시", color: "purple" }, + { id: "layout", name: "레이아웃", color: "orange" }, + { id: "other", name: "기타", color: "gray" }, +]; + +export default function ComponentManagementPage() { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [sortBy, setSortBy] = useState("sort_order"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [selectedComponent, setSelectedComponent] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // 컴포넌트 데이터 가져오기 + const { + data: componentsData, + isLoading: loading, + error, + refetch, + } = useComponents({ + category: selectedCategory === "all" ? undefined : selectedCategory, + active: "Y", + search: searchTerm, + sort: sortBy, + order: sortOrder, + }); + + // 카테고리와 통계 데이터 + const { data: categories } = useComponentCategories(); + const { data: statistics } = useComponentStatistics(); + + // 삭제 뮤테이션 + const deleteComponentMutation = useDeleteComponent(); + + // 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태) + const components = componentsData?.components || []; + + // 카테고리별 통계 (백엔드에서 가져온 데이터 사용) + const categoryStats = useMemo(() => { + if (!statistics?.byCategory) return {}; + + const stats: Record = {}; + statistics.byCategory.forEach(({ category, count }) => { + stats[category] = count; + }); + + return stats; + }, [statistics]); + + // 카테고리 이름 및 색상 가져오기 + const getCategoryInfo = (categoryId: string) => { + const category = COMPONENT_CATEGORIES.find((cat) => cat.id === categoryId); + return category || { id: "other", name: "기타", color: "gray" }; + }; + + // 삭제 처리 + const handleDelete = async () => { + if (!selectedComponent) return; + + try { + await deleteComponentMutation.mutateAsync(selectedComponent.component_code); + setShowDeleteModal(false); + setSelectedComponent(null); + } catch (error) { + console.error("컴포넌트 삭제 실패:", error); + } + }; + + if (loading) { + return ( +
+
+ +

컴포넌트 목록을 불러오는 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+ +

컴포넌트 목록을 불러오는데 실패했습니다.

+ +
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+
+

컴포넌트 관리

+

화면 설계에 사용되는 컴포넌트들을 관리합니다

+
+
+ + + +
+
+
+ + {/* 카테고리 통계 */} +
+ {COMPONENT_CATEGORIES.map((category) => { + const count = categoryStats[category.id] || 0; + return ( + setSelectedCategory(category.id)} + > + +
{count}
+
{category.name}
+
+
+ ); + })} +
+ + {/* 검색 및 필터 */} + + +
+ {/* 검색 */} +
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+ + {/* 카테고리 필터 */} + + + {/* 정렬 */} + + + + + +
+
+
+ + {/* 컴포넌트 목록 테이블 */} + + + + 컴포넌트 목록 ({components.length}개) + + + +
+ + + + 컴포넌트 이름 + 컴포넌트 코드 + 카테고리 + 타입 + 상태 + 수정일 + 작업 + + + + {components.map((component) => { + const categoryInfo = getCategoryInfo(component.category || "other"); + + return ( + + +
+
{component.component_name}
+ {component.component_name_eng && ( +
{component.component_name_eng}
+ )} +
+
+ + {component.component_code} + + + + {categoryInfo.name} + + + + {component.component_config ? ( + + {component.component_config.type || component.component_code} + + ) : ( + 없음 + )} + + + + {component.is_active === "Y" ? "활성" : "비활성"} + + + + {component.updated_date ? new Date(component.updated_date).toLocaleDateString() : "-"} + + +
+ + +
+
+
+ ); + })} +
+
+
+
+
+ + {/* 삭제 확인 모달 */} + setShowDeleteModal(false)} + onConfirm={handleDelete} + type="warning" + title="컴포넌트 삭제" + message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`} + confirmText="삭제" + /> +
+ ); +} diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index 1de9f6a3..e4bec481 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -1,4 +1,4 @@ -import { Users, Shield, Settings, BarChart3, Palette } from "lucide-react"; +import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react"; import Link from "next/link"; /** * 관리자 메인 페이지 @@ -7,7 +7,7 @@ export default function AdminPage() { return (
{/* 관리자 기능 카드들 */} -
+
@@ -73,6 +73,68 @@ export default function AdminPage() {
+ {/* 표준 관리 섹션 */} +
+

표준 관리

+
+ +
+
+
+ +
+
+

웹타입 관리

+

입력 컴포넌트 웹타입 표준 관리

+
+
+
+ + + +
+
+
+ +
+
+

템플릿 관리

+

화면 디자이너 템플릿 표준 관리

+
+
+
+ + + +
+
+
+ +
+
+

테이블 관리

+

데이터베이스 테이블 및 웹타입 매핑

+
+
+
+ + + +
+
+
+ +
+
+

컴포넌트 관리

+

화면 디자이너 컴포넌트 표준 관리

+
+
+
+ +
+
+ {/* 최근 활동 */}

최근 관리자 활동

diff --git a/frontend/app/(main)/admin/templates/page.tsx b/frontend/app/(main)/admin/templates/page.tsx new file mode 100644 index 00000000..800c84ac --- /dev/null +++ b/frontend/app/(main)/admin/templates/page.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Search, Plus, Edit2, Trash2, Eye, Copy, Download, Upload, ArrowUpDown, Filter, RefreshCw } from "lucide-react"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { toast } from "sonner"; +import { useTemplates, TemplateStandard } from "@/hooks/admin/useTemplates"; +import Link from "next/link"; + +export default function TemplatesManagePage() { + const [searchTerm, setSearchTerm] = useState(""); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [activeFilter, setActiveFilter] = useState("Y"); + const [sortField, setSortField] = useState("sort_order"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + + // 템플릿 데이터 조회 + const { templates, categories, isLoading, error, deleteTemplate, isDeleting, deleteError, refetch, exportTemplate } = + useTemplates({ + active: activeFilter === "all" ? undefined : activeFilter, + search: searchTerm || undefined, + category: categoryFilter === "all" ? undefined : categoryFilter, + }); + + // 필터링 및 정렬된 데이터 + const filteredAndSortedTemplates = useMemo(() => { + let filtered = [...templates]; + + // 정렬 + filtered.sort((a, b) => { + let aValue: any = a[sortField as keyof typeof a]; + let bValue: any = b[sortField as keyof typeof b]; + + // 숫자 필드 처리 + if (sortField === "sort_order") { + aValue = aValue || 0; + bValue = bValue || 0; + } + + // 문자열 필드 처리 + if (typeof aValue === "string") { + aValue = aValue.toLowerCase(); + } + if (typeof bValue === "string") { + bValue = bValue.toLowerCase(); + } + + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1; + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + + return filtered; + }, [templates, sortField, sortDirection]); + + // 정렬 변경 핸들러 + const handleSort = (field: string) => { + if (sortField === field) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortDirection("asc"); + } + }; + + // 삭제 핸들러 + const handleDelete = async (templateCode: string, templateName: string) => { + try { + await deleteTemplate(templateCode); + toast.success(`템플릿 '${templateName}'이 삭제되었습니다.`); + } catch (error) { + toast.error(`템플릿 삭제 중 오류가 발생했습니다: ${deleteError?.message || error}`); + } + }; + + // 내보내기 핸들러 + const handleExport = async (templateCode: string, templateName: string) => { + try { + const templateData = await exportTemplate(templateCode); + + // JSON 파일로 다운로드 + const blob = new Blob([JSON.stringify(templateData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `template-${templateCode}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success(`템플릿 '${templateName}'이 내보내기되었습니다.`); + } catch (error: any) { + toast.error(`템플릿 내보내기 중 오류가 발생했습니다: ${error.message}`); + } + }; + + // 아이콘 렌더링 함수 + const renderIcon = (iconName?: string) => { + if (!iconName) return null; + + // 간단한 아이콘 매핑 (실제로는 더 복잡한 시스템 필요) + const iconMap: Record = { + table:
, + "mouse-pointer":
, + upload:
, + }; + + return iconMap[iconName] ||
; + }; + + if (error) { + return ( +
+ + +

템플릿 목록을 불러오는 중 오류가 발생했습니다.

+ +
+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

템플릿 관리

+

화면 디자이너에서 사용할 템플릿을 관리합니다.

+
+
+ +
+
+ + {/* 필터 및 검색 */} + + + + + 필터 및 검색 + + + +
+ {/* 검색 */} +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + {/* 카테고리 필터 */} +
+ + +
+ + {/* 활성화 상태 필터 */} +
+ + +
+ + {/* 새로고침 버튼 */} +
+ +
+
+
+
+ + {/* 템플릿 목록 테이블 */} + + + 템플릿 목록 ({filteredAndSortedTemplates.length}개) + + +
+ + + + + + + + + + + + + 카테고리 + 설명 + 아이콘 + 기본 크기 + 공개 여부 + 활성화 + 수정일 + 작업 + + + + {isLoading ? ( + + + + 템플릿 목록을 불러오는 중... + + + ) : filteredAndSortedTemplates.length === 0 ? ( + + + 검색 조건에 맞는 템플릿이 없습니다. + + + ) : ( + filteredAndSortedTemplates.map((template) => ( + + {template.sort_order || 0} + {template.template_code} + + {template.template_name} + {template.template_name_eng && ( +
{template.template_name_eng}
+ )} +
+ + {template.category} + + {template.description || "-"} + +
{renderIcon(template.icon_name)}
+
+ + {template.default_size ? `${template.default_size.width}×${template.default_size.height}` : "-"} + + + + {template.is_public === "Y" ? "공개" : "비공개"} + + + + + {template.is_active === "Y" ? "활성화" : "비활성화"} + + + + {template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"} + + +
+ + + + + + + + + + + 템플릿 삭제 + + 템플릿 '{template.template_name}'을 정말 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + handleDelete(template.template_code, template.template_name)} + className="bg-red-600 hover:bg-red-700" + disabled={isDeleting} + > + 삭제 + + +
+
+
+
+
+ )) + )} +
+
+
+
+
+
+ ); +} diff --git a/frontend/components/admin/TemplateImportExport.tsx b/frontend/components/admin/TemplateImportExport.tsx new file mode 100644 index 00000000..e11dada6 --- /dev/null +++ b/frontend/components/admin/TemplateImportExport.tsx @@ -0,0 +1,303 @@ +"use client"; + +import React, { useState, useRef } 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Upload, Download, FileText, AlertCircle, CheckCircle } from "lucide-react"; +import { toast } from "sonner"; +import { useTemplates } from "@/hooks/admin/useTemplates"; + +interface TemplateImportExportProps { + onTemplateImported?: () => void; +} + +interface ImportData { + template_code: string; + template_name: string; + template_name_eng?: string; + description?: string; + category: string; + icon_name?: string; + default_size?: { + width: number; + height: number; + }; + layout_config: any; +} + +export function TemplateImportExport({ onTemplateImported }: TemplateImportExportProps) { + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [importData, setImportData] = useState(null); + const [importError, setImportError] = useState(""); + const [jsonInput, setJsonInput] = useState(""); + const fileInputRef = useRef(null); + + const { importTemplate, isImporting } = useTemplates(); + + // 파일 업로드 핸들러 + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.type !== "application/json") { + setImportError("JSON 파일만 업로드할 수 있습니다."); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target?.result as string; + const data = JSON.parse(content); + validateAndSetImportData(data); + setJsonInput(JSON.stringify(data, null, 2)); + } catch (error) { + setImportError("유효하지 않은 JSON 파일입니다."); + } + }; + reader.readAsText(file); + }; + + // JSON 텍스트 입력 핸들러 + const handleJsonInputChange = (value: string) => { + setJsonInput(value); + if (!value.trim()) { + setImportData(null); + setImportError(""); + return; + } + + try { + const data = JSON.parse(value); + validateAndSetImportData(data); + } catch (error) { + setImportError("유효하지 않은 JSON 형식입니다."); + setImportData(null); + } + }; + + // 가져오기 데이터 검증 + const validateAndSetImportData = (data: any) => { + setImportError(""); + + // 필수 필드 검증 + const requiredFields = ["template_code", "template_name", "category", "layout_config"]; + const missingFields = requiredFields.filter((field) => !data[field]); + + if (missingFields.length > 0) { + setImportError(`필수 필드가 누락되었습니다: ${missingFields.join(", ")}`); + setImportData(null); + return; + } + + // 템플릿 코드 형식 검증 + if (!/^[a-z0-9_-]+$/.test(data.template_code)) { + setImportError("템플릿 코드는 영문 소문자, 숫자, 하이픈, 언더스코어만 사용할 수 있습니다."); + setImportData(null); + return; + } + + // layout_config 구조 검증 + if (!data.layout_config.components || !Array.isArray(data.layout_config.components)) { + setImportError("layout_config.components가 올바른 배열 형태가 아닙니다."); + setImportData(null); + return; + } + + setImportData(data); + }; + + // 템플릿 가져오기 실행 + const handleImport = async () => { + if (!importData) return; + + try { + await importTemplate(importData); + toast.success(`템플릿 '${importData.template_name}'이 성공적으로 가져왔습니다.`); + setIsImportDialogOpen(false); + setImportData(null); + setJsonInput(""); + setImportError(""); + onTemplateImported?.(); + } catch (error: any) { + toast.error(`템플릿 가져오기 실패: ${error.message}`); + } + }; + + // 파일 선택 트리거 + const triggerFileSelect = () => { + fileInputRef.current?.click(); + }; + + return ( +
+ {/* 가져오기 버튼 */} + + + + + + + 템플릿 가져오기 + + +
+ {/* 파일 업로드 영역 */} + + + 1. JSON 파일 업로드 + + +
+ +

템플릿 JSON 파일을 선택하세요

+

또는 아래에 JSON 내용을 직접 입력하세요

+
+ +
+
+ + {/* JSON 직접 입력 */} + + + 2. JSON 직접 입력 + + +