템플릿관리, 컴포넌트 관리

This commit is contained in:
kjs 2025-09-09 17:42:23 +09:00
parent 85a1e0c68a
commit db782eb9c9
32 changed files with 5585 additions and 287 deletions

View File

@ -5254,3 +5254,51 @@ model table_relationships {
@@index([to_table_name], map: "idx_table_relationships_to_table") @@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")
}

View File

@ -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();

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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();

View File

@ -22,6 +22,8 @@ import companyManagementRoutes from "./routes/companyManagementRoutes";
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes"; import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes"; import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
import screenStandardRoutes from "./routes/screenStandardRoutes"; import screenStandardRoutes from "./routes/screenStandardRoutes";
import templateStandardRoutes from "./routes/templateStandardRoutes";
import componentStandardRoutes from "./routes/componentStandardRoutes";
// import userRoutes from './routes/userRoutes'; // import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes'; // import menuRoutes from './routes/menuRoutes';
@ -106,6 +108,8 @@ app.use("/api/files", fileRoutes);
app.use("/api/company-management", companyManagementRoutes); app.use("/api/company-management", companyManagementRoutes);
app.use("/api/admin/web-types", webTypeStandardRoutes); app.use("/api/admin/web-types", webTypeStandardRoutes);
app.use("/api/admin/button-actions", buttonActionStandardRoutes); 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/screen", screenStandardRoutes);
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes); // app.use('/api/menus', menuRoutes);

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@ -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();

View File

@ -2,42 +2,6 @@ import { Request, Response } from "express";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { AuthenticatedRequest } from "../types/auth"; 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(); const prisma = new PrismaClient();
export class WebTypeStandardController { export class WebTypeStandardController {
@ -173,7 +137,7 @@ export class WebTypeStandardController {
is_active, is_active,
created_by: req.user?.userId || "system", created_by: req.user?.userId || "system",
updated_by: req.user?.userId || "system", updated_by: req.user?.userId || "system",
} as WebTypeStandardCreateData, },
}); });
return res.status(201).json({ return res.status(201).json({
@ -239,7 +203,7 @@ export class WebTypeStandardController {
is_active, is_active,
updated_by: req.user?.userId || "system", updated_by: req.user?.userId || "system",
updated_date: new Date(), updated_date: new Date(),
} as WebTypeStandardUpdateData, },
}); });
return res.json({ return res.json({

View File

@ -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;

View File

@ -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;

View File

@ -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<ComponentStandardData>
) {
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();

View File

@ -857,8 +857,9 @@ export class TableManagementService {
logger.info(`테이블 데이터 조회: ${tableName}`, options); logger.info(`테이블 데이터 조회: ${tableName}`, options);
// 🎯 파일 타입 컬럼 감지 // 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지)
const fileColumns = await this.getFileTypeColumns(tableName); // const fileColumns = await this.getFileTypeColumns(tableName);
const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화
// WHERE 조건 구성 // WHERE 조건 구성
let whereConditions: string[] = []; let whereConditions: string[] = [];

View File

@ -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();

View File

@ -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,
},
});
};

View File

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

View File

@ -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<string>("all");
const [sortBy, setSortBy] = useState<string>("sort_order");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedComponent, setSelectedComponent] = useState<any>(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<string, number> = {};
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 (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<RefreshCw className="mx-auto h-8 w-8 animate-spin text-gray-400" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Package className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-2 text-sm text-red-600"> .</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-4">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col p-6">
{/* 헤더 */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="text-sm text-gray-500"> </p>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm">
<Upload className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
</Button>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 카테고리 통계 */}
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{COMPONENT_CATEGORIES.map((category) => {
const count = categoryStats[category.id] || 0;
return (
<Card
key={category.id}
className="cursor-pointer hover:shadow-md"
onClick={() => setSelectedCategory(category.id)}
>
<CardContent className="p-4 text-center">
<div className={`mb-2 text-2xl font-bold text-${category.color}-600`}>{count}</div>
<div className="text-sm text-gray-600">{category.name}</div>
</CardContent>
</Card>
);
})}
</div>
{/* 검색 및 필터 */}
<Card className="mb-6">
<CardContent className="p-4">
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-y-0 md:space-x-4">
{/* 검색 */}
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="컴포넌트 이름, 타입, 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{/* 카테고리 필터 */}
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-40">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="카테고리" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{COMPONENT_CATEGORIES.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-32">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sort_order"></SelectItem>
<SelectItem value="type_name"></SelectItem>
<SelectItem value="web_type"></SelectItem>
<SelectItem value="category"></SelectItem>
<SelectItem value="updated_date"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}>
{sortOrder === "asc" ? "↑" : "↓"}
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 컴포넌트 목록 테이블 */}
<Card className="flex-1">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({components.length})</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{components.map((component) => {
const categoryInfo = getCategoryInfo(component.category || "other");
return (
<TableRow key={component.component_code}>
<TableCell>
<div>
<div className="font-medium">{component.component_name}</div>
{component.component_name_eng && (
<div className="text-xs text-gray-500">{component.component_name_eng}</div>
)}
</div>
</TableCell>
<TableCell>
<code className="rounded bg-gray-100 px-2 py-1 text-xs">{component.component_code}</code>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`text-${categoryInfo.color}-600 border-${categoryInfo.color}-200`}
>
{categoryInfo.name}
</Badge>
</TableCell>
<TableCell>
{component.component_config ? (
<code className="text-xs text-blue-600">
{component.component_config.type || component.component_code}
</code>
) : (
<span className="text-xs text-gray-400"></span>
)}
</TableCell>
<TableCell>
<Badge variant={component.is_active === "Y" ? "default" : "secondary"}>
{component.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-xs text-gray-500">
{component.updated_date ? new Date(component.updated_date).toLocaleDateString() : "-"}
</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<Button variant="ghost" size="sm">
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedComponent(component);
setShowDeleteModal(true);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 삭제 확인 모달 */}
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDelete}
type="warning"
title="컴포넌트 삭제"
message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`}
confirmText="삭제"
/>
</div>
);
}

View File

@ -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"; import Link from "next/link";
/** /**
* *
@ -7,7 +7,7 @@ export default function AdminPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 관리자 기능 카드들 */} {/* 관리자 기능 카드들 */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Link href="/admin/userMng" className="block"> <Link href="/admin/userMng" className="block">
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50"> <div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -73,6 +73,68 @@ export default function AdminPage() {
</Link> </Link>
</div> </div>
{/* 표준 관리 섹션 */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900"> </h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Link href="/admin/standards" className="block">
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-teal-50">
<Database className="h-6 w-6 text-teal-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/templates" className="block">
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-50">
<Layout className="h-6 w-6 text-emerald-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">릿 </h3>
<p className="text-sm text-gray-600"> 릿 </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/tableMng" className="block">
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-50">
<Database className="h-6 w-6 text-cyan-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/components" className="block">
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-violet-50">
<Package className="h-6 w-6 text-violet-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
</Link>
</div>
</div>
{/* 최근 활동 */} {/* 최근 활동 */}
<div className="rounded-lg border bg-white p-6 shadow-sm"> <div className="rounded-lg border bg-white p-6 shadow-sm">
<h3 className="mb-4 text-lg font-semibold"> </h3> <h3 className="mb-4 text-lg font-semibold"> </h3>

View File

@ -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<string>("all");
const [activeFilter, setActiveFilter] = useState<string>("Y");
const [sortField, setSortField] = useState<string>("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<string, JSX.Element> = {
table: <div className="h-4 w-4 border border-gray-400" />,
"mouse-pointer": <div className="h-4 w-4 rounded bg-blue-500" />,
upload: <div className="h-4 w-4 border-2 border-dashed border-gray-400" />,
};
return iconMap[iconName] || <div className="h-4 w-4 rounded bg-gray-300" />;
};
if (error) {
return (
<div className="container mx-auto p-6">
<Card>
<CardContent className="flex flex-col items-center justify-center py-8">
<p className="mb-4 text-red-600">릿 .</p>
<Button onClick={() => refetch()} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto space-y-6 p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">릿 </h1>
<p className="text-muted-foreground"> 릿 .</p>
</div>
<div className="flex space-x-2">
<Button asChild>
<Link href="/admin/templates/new">
<Plus className="mr-2 h-4 w-4" /> 릿
</Link>
</Button>
</div>
</div>
{/* 필터 및 검색 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Filter className="mr-2 h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* 검색 */}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="템플릿명, 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* 카테고리 필터 */}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 활성화 상태 필터 */}
<div className="space-y-2">
<label className="text-sm font-medium"> </label>
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 새로고침 버튼 */}
<div className="flex items-end">
<Button onClick={() => refetch()} variant="outline" className="w-full">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 템플릿 목록 테이블 */}
<Card>
<CardHeader>
<CardTitle>릿 ({filteredAndSortedTemplates.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">
<Button
variant="ghost"
size="sm"
onClick={() => handleSort("sort_order")}
className="h-8 p-0 font-medium"
>
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
onClick={() => handleSort("template_code")}
className="h-8 p-0 font-medium"
>
릿
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
onClick={() => handleSort("template_name")}
className="h-8 p-0 font-medium"
>
릿
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={11} className="py-8 text-center">
<LoadingSpinner />
<span className="ml-2">릿 ...</span>
</TableCell>
</TableRow>
) : filteredAndSortedTemplates.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="py-8 text-center text-gray-500">
릿 .
</TableCell>
</TableRow>
) : (
filteredAndSortedTemplates.map((template) => (
<TableRow key={template.template_code}>
<TableCell className="font-mono">{template.sort_order || 0}</TableCell>
<TableCell className="font-mono">{template.template_code}</TableCell>
<TableCell className="font-medium">
{template.template_name}
{template.template_name_eng && (
<div className="text-muted-foreground text-xs">{template.template_name_eng}</div>
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{template.category}</Badge>
</TableCell>
<TableCell className="max-w-xs truncate">{template.description || "-"}</TableCell>
<TableCell>
<div className="flex items-center justify-center">{renderIcon(template.icon_name)}</div>
</TableCell>
<TableCell className="font-mono text-xs">
{template.default_size ? `${template.default_size.width}×${template.default_size.height}` : "-"}
</TableCell>
<TableCell>
<Badge variant={template.is_public === "Y" ? "default" : "secondary"}>
{template.is_public === "Y" ? "공개" : "비공개"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={template.is_active === "Y" ? "default" : "secondary"}>
{template.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
<TableCell>
<div className="flex space-x-1">
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/templates/${template.template_code}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/templates/${template.template_code}/edit`}>
<Edit2 className="h-4 w-4" />
</Link>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleExport(template.template_code, template.template_name)}
>
<Download className="h-4 w-4" />
</Button>
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/templates/${template.template_code}/duplicate`}>
<Copy className="h-4 w-4" />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>릿 </AlertDialogTitle>
<AlertDialogDescription>
릿 &apos;{template.template_name}&apos; ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(template.template_code, template.template_name)}
className="bg-red-600 hover:bg-red-700"
disabled={isDeleting}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -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<ImportData | null>(null);
const [importError, setImportError] = useState<string>("");
const [jsonInput, setJsonInput] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const { importTemplate, isImporting } = useTemplates();
// 파일 업로드 핸들러
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="flex space-x-2">
{/* 가져오기 버튼 */}
<Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>릿 </DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 파일 업로드 영역 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">1. JSON </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div
className="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors hover:border-gray-400"
onClick={triggerFileSelect}
>
<Upload className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium text-gray-900">릿 JSON </p>
<p className="text-sm text-gray-500"> JSON </p>
</div>
<input ref={fileInputRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
</CardContent>
</Card>
{/* JSON 직접 입력 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">2. JSON </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder="JSON 템플릿 데이터를 여기에 붙여넣으세요..."
value={jsonInput}
onChange={(e) => handleJsonInputChange(e.target.value)}
className="min-h-[200px] font-mono text-sm"
/>
</CardContent>
</Card>
{/* 오류 메시지 */}
{importError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{importError}</AlertDescription>
</Alert>
)}
{/* 미리보기 */}
{importData && (
<Card>
<CardHeader>
<CardTitle className="flex items-center text-lg">
<CheckCircle className="mr-2 h-5 w-5 text-green-600" />
3. 릿
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>릿 </Label>
<Input value={importData.template_code} readOnly />
</div>
<div>
<Label>릿</Label>
<Input value={importData.template_name} readOnly />
</div>
<div>
<Label></Label>
<Input value={importData.category} readOnly />
</div>
<div>
<Label> </Label>
<Input value={`${importData.layout_config?.components?.length || 0}`} readOnly />
</div>
</div>
{importData.description && (
<div>
<Label></Label>
<Textarea value={importData.description} readOnly />
</div>
)}
</CardContent>
</Card>
)}
{/* 가져오기 버튼 */}
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setIsImportDialogOpen(false)}>
</Button>
<Button onClick={handleImport} disabled={!importData || isImporting}>
{isImporting ? "가져오는 중..." : "가져오기"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// 템플릿 가져오기/내보내기 샘플 데이터 생성 유틸리티
export const generateSampleTemplate = () => {
return {
template_code: "sample-form",
template_name: "샘플 폼 템플릿",
template_name_eng: "Sample Form Template",
description: "기본적인 폼 입력 요소들을 포함한 샘플 템플릿",
category: "form",
icon_name: "form",
default_size: {
width: 400,
height: 300,
},
layout_config: {
components: [
{
type: "widget",
widgetType: "text",
label: "이름",
position: { x: 0, y: 0 },
size: { width: 200, height: 36 },
style: {
border: "1px solid #d1d5db",
borderRadius: "4px",
padding: "8px",
},
},
{
type: "widget",
widgetType: "email",
label: "이메일",
position: { x: 0, y: 50 },
size: { width: 200, height: 36 },
style: {
border: "1px solid #d1d5db",
borderRadius: "4px",
padding: "8px",
},
},
{
type: "widget",
widgetType: "button",
label: "저장",
position: { x: 0, y: 100 },
size: { width: 80, height: 36 },
style: {
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "6px",
},
},
],
},
};
};

View File

@ -102,6 +102,19 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
</Badge> </Badge>
</Button> </Button>
<Button
variant={panelStates.components?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("components")}
className={cn("flex items-center space-x-2", panelStates.components?.isOpen && "bg-blue-600 text-white")}
>
<Cog className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
C
</Badge>
</Button>
<Button <Button
variant={panelStates.properties?.isOpen ? "default" : "outline"} variant={panelStates.properties?.isOpen ? "default" : "outline"}
size="sm" size="sm"

View File

@ -15,7 +15,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; // Card 컴포넌트 제거 - 외부 박스 없이 직접 렌더링
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
@ -1683,13 +1683,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}; };
return ( return (
<Card className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}> <div className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
{/* 헤더 */} {/* 헤더 */}
<CardHeader className="pb-3"> <div className="p-6 pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4" /> <Database className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-lg">{component.title || component.label}</CardTitle> <h3 className="text-lg font-semibold">{component.title || component.label}</h3>
{loading && ( {loading && (
<Badge variant="secondary" className="flex items-center gap-1"> <Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
@ -1753,10 +1753,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<> <>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="space-y-3"> <div className="space-y-3">
<CardDescription className="flex items-center gap-2"> <div className="text-muted-foreground flex items-center gap-2 text-sm">
<Search className="h-3 w-3" /> <Search className="h-3 w-3" />
</CardDescription> </div>
<div <div
className="grid gap-3" className="grid gap-3"
style={{ style={{
@ -1775,10 +1775,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div> </div>
</> </>
)} )}
</CardHeader> </div>
{/* 테이블 내용 */} {/* 테이블 내용 */}
<CardContent className="flex-1 p-0"> <div className="flex-1 p-0">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{visibleColumns.length > 0 ? ( {visibleColumns.length > 0 ? (
<> <>
@ -1803,26 +1803,14 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{column.label} {column.label}
</TableHead> </TableHead>
))} ))}
{/* 기본 파일 컬럼은 가상 파일 컬럼이 있으면 완전히 숨김 */} {/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
{!visibleColumns.some((col) => col.widgetType === "file") && (
<TableHead className="w-16 px-4 text-center">
<div className="flex items-center justify-center gap-1">
<Folder className="h-4 w-4" />
<span className="text-xs"></span>
</div>
</TableHead>
)}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={ colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
visibleColumns.length +
(component.enableDelete ? 1 : 0) +
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
}
className="h-32 text-center" className="h-32 text-center"
> >
<div className="text-muted-foreground flex items-center justify-center gap-2"> <div className="text-muted-foreground flex items-center justify-center gap-2">
@ -1848,51 +1836,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{formatCellValue(row[column.columnName], column, row)} {formatCellValue(row[column.columnName], column, row)}
</TableCell> </TableCell>
))} ))}
{/* 기본 파일 셀은 가상 파일 컬럼이 있으면 완전히 숨김 */} {/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
{!visibleColumns.some((col) => col.widgetType === "file") && (
<TableCell className="w-16 px-4 text-center">
{(() => {
const primaryKeyField = Object.keys(row)[0];
const recordId = row[primaryKeyField];
const fileStatus = fileStatusMap[recordId];
const hasFiles = fileStatus?.hasFiles || false;
const fileCount = fileStatus?.fileCount || 0;
return (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-blue-50"
onClick={() => handleFileIconClick(row)}
title={hasFiles ? `${fileCount}개 파일 보기` : "파일 업로드"}
>
{hasFiles ? (
<div className="relative">
<FolderOpen className="h-4 w-4 text-blue-600" />
{fileCount > 0 && (
<div className="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
{fileCount > 9 ? "9+" : fileCount}
</div>
)}
</div>
) : (
<Folder className="h-4 w-4 text-gray-400" />
)}
</Button>
);
})()}
</TableCell>
)}
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={ colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
visibleColumns.length +
(component.enableDelete ? 1 : 0) +
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
}
className="h-32 text-center" className="h-32 text-center"
> >
<div className="text-muted-foreground flex flex-col items-center gap-2"> <div className="text-muted-foreground flex flex-col items-center gap-2">
@ -1980,7 +1930,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div> </div>
)} )}
</div> </div>
</CardContent> </div>
{/* 데이터 추가 모달 */} {/* 데이터 추가 모달 */}
<Dialog open={showAddModal} onOpenChange={handleAddModalClose}> <Dialog open={showAddModal} onOpenChange={handleAddModalClose}>
@ -2502,6 +2452,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Card> </div>
); );
}; };

View File

@ -12,6 +12,7 @@ import { Separator } from "@/components/ui/separator";
import { FileUpload } from "./widgets/FileUpload"; import { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry"; import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
import { import {
Database, Database,
Type, Type,
@ -262,122 +263,31 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
(() => { (() => {
const dataTableComponent = component as any; // DataTableComponent 타입 const dataTableComponent = component as any; // DataTableComponent 타입
// 메모이제이션 없이 직접 계산
const visibleColumns = dataTableComponent.columns?.filter((col: any) => col.visible) || [];
const filters = dataTableComponent.filters || [];
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden rounded border bg-white"> <DataTableTemplate
{/* 테이블 제목 */} title={dataTableComponent.title || dataTableComponent.label}
{dataTableComponent.title && ( description={`${dataTableComponent.label}을 표시하는 데이터 테이블`}
<div className="border-b bg-gray-50 px-4 py-2"> columns={dataTableComponent.columns}
<h3 className="text-sm font-medium">{dataTableComponent.title}</h3> filters={dataTableComponent.filters}
</div> pagination={dataTableComponent.pagination}
)} actions={
dataTableComponent.actions || {
{/* 검색 및 필터 영역 */} showSearchButton: dataTableComponent.showSearchButton ?? true,
{(dataTableComponent.showSearchButton || filters.length > 0) && ( searchButtonText: dataTableComponent.searchButtonText || "검색",
<div className="border-b bg-gray-50 px-4 py-2"> enableExport: dataTableComponent.enableExport ?? true,
<div className="flex items-center space-x-2"> enableRefresh: dataTableComponent.enableRefresh ?? true,
{dataTableComponent.showSearchButton && ( enableAdd: dataTableComponent.enableAdd ?? true,
<div className="flex items-center space-x-2"> enableEdit: dataTableComponent.enableEdit ?? true,
<Input placeholder="검색..." className="h-8 w-48" /> enableDelete: dataTableComponent.enableDelete ?? true,
<Button size="sm" variant="outline"> addButtonText: dataTableComponent.addButtonText || "추가",
{dataTableComponent.searchButtonText || "검색"} editButtonText: dataTableComponent.editButtonText || "수정",
</Button> deleteButtonText: dataTableComponent.deleteButtonText || "삭제",
</div> }
)} }
{filters.length > 0 && ( style={component.style}
<div className="flex items-center space-x-2"> className="h-full w-full"
<span className="text-xs text-gray-500">:</span> isPreview={true}
{filters.slice(0, 2).map((filter: any, index: number) => ( />
<Badge key={index} variant="secondary" className="text-xs">
{filter.label || filter.columnName}
</Badge>
))}
{filters.length > 2 && (
<Badge variant="secondary" className="text-xs">
+{filters.length - 2}
</Badge>
)}
</div>
)}
</div>
</div>
)}
{/* 테이블 본체 */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{visibleColumns.length > 0 ? (
visibleColumns.map((col: any, index: number) => (
<TableHead key={col.id || index} className="text-xs">
{col.label || col.columnName}
{col.sortable && <span className="ml-1 text-gray-400"></span>}
</TableHead>
))
) : (
<>
<TableHead className="text-xs"> 1</TableHead>
<TableHead className="text-xs"> 2</TableHead>
<TableHead className="text-xs"> 3</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{/* 샘플 데이터 행들 */}
{[1, 2, 3].map((rowIndex) => (
<TableRow key={rowIndex}>
{visibleColumns.length > 0 ? (
visibleColumns.map((col: any, colIndex: number) => (
<TableCell key={col.id || colIndex} className="text-xs">
{col.widgetType === "checkbox" ? (
<input type="checkbox" className="h-3 w-3" />
) : col.widgetType === "select" ? (
`옵션 ${rowIndex}`
) : col.widgetType === "date" ? (
"2024-01-01"
) : col.widgetType === "number" ? (
`${rowIndex * 100}`
) : (
`데이터 ${rowIndex}-${colIndex + 1}`
)}
</TableCell>
))
) : (
<>
<TableCell className="text-xs"> {rowIndex}-1</TableCell>
<TableCell className="text-xs"> {rowIndex}-2</TableCell>
<TableCell className="text-xs"> {rowIndex}-3</TableCell>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{dataTableComponent.pagination && (
<div className="border-t bg-gray-50 px-4 py-2">
<div className="flex items-center justify-between text-xs text-gray-600">
<span> 3 </span>
<div className="flex items-center space-x-2">
<Button size="sm" variant="outline" disabled>
</Button>
<span>1 / 1</span>
<Button size="sm" variant="outline" disabled>
</Button>
</div>
</div>
</div>
)}
</div>
); );
})()} })()}

View File

@ -45,6 +45,7 @@ import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar"; import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel"; import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import ComponentsPanel from "./panels/ComponentsPanel";
import PropertiesPanel from "./panels/PropertiesPanel"; import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel"; import GridPanel from "./panels/GridPanel";
@ -1211,6 +1212,121 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel], [layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
); );
// 컴포넌트 드래그 처리
const handleComponentDrop = useCallback(
(e: React.DragEvent, component: any) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🧩 컴포넌트 드롭:", {
componentName: component.name,
webType: component.webType,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (webType: string) => {
switch (webType) {
case "button":
return {
actionType: "custom",
variant: "default",
confirmationMessage: "",
popupTitle: "",
popupContent: "",
navigateUrl: "",
};
case "date":
return {
format: "YYYY-MM-DD",
showTime: false,
placeholder: "날짜를 선택하세요",
};
case "number":
return {
format: "integer",
placeholder: "숫자를 입력하세요",
};
case "select":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "file":
return {
accept: ["*/*"],
maxSize: 10485760, // 10MB
multiple: false,
showPreview: true,
autoUpload: false,
};
default:
return {};
}
};
// 새 컴포넌트 생성
const newComponent: ComponentData = {
id: generateComponentId(),
type: component.webType === "button" ? "button" : "widget",
label: component.name,
widgetType: component.webType,
position: snappedPosition,
size: component.defaultSize,
webTypeConfig: getDefaultWebTypeConfig(component.webType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "4px",
},
};
// 레이아웃에 컴포넌트 추가
const newLayout: LayoutData = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 새 컴포넌트 선택
setSelectedComponent(newComponent);
openPanel("properties");
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
);
// 드래그 앤 드롭 처리 // 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -1232,6 +1348,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return; return;
} }
// 컴포넌트 드래그인 경우
if (parsedData.type === "component") {
handleComponentDrop(e, parsedData.component);
return;
}
// 기존 테이블/컬럼 드래그 처리 // 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData; const { type, table, column } = parsedData;
@ -2935,9 +3057,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
> >
<TemplatesPanel <TemplatesPanel
onDragStart={(e, template) => { onDragStart={(e, template) => {
// React 컴포넌트(icon)를 제외하고 JSON으로 직렬화 가능한 데이터만 전송
const serializableTemplate = {
id: template.id,
name: template.name,
description: template.description,
category: template.category,
defaultSize: template.defaultSize,
components: template.components,
};
const dragData = { const dragData = {
type: "template", type: "template",
template, template: serializableTemplate,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel>
<FloatingPanel
id="components"
title="컴포넌트"
isOpen={panelStates.components?.isOpen || false}
onClose={() => closePanel("components")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<ComponentsPanel
onDragStart={(e, component) => {
const dragData = {
type: "component",
component: {
id: component.id,
name: component.name,
description: component.description,
category: component.category,
webType: component.webType,
defaultSize: component.defaultSize,
},
}; };
e.dataTransfer.setData("application/json", JSON.stringify(dragData)); e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}} }}

View File

@ -0,0 +1,283 @@
"use client";
import React, { useState, useMemo } from "react";
import { Plus, Layers, Search, Filter } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useComponents } from "@/hooks/admin/useComponents";
interface ComponentsPanelProps {
onDragStart: (e: React.DragEvent, component: ComponentItem) => void;
}
interface ComponentItem {
id: string;
name: string;
description: string;
category: string;
componentType: string;
componentConfig: any;
icon: React.ReactNode;
defaultSize: { width: number; height: number };
}
// 컴포넌트 카테고리 정의 (실제 생성된 컴포넌트에 맞게)
const COMPONENT_CATEGORIES = [
{ id: "action", name: "액션", description: "사용자 동작을 처리하는 컴포넌트" },
{ id: "layout", name: "레이아웃", description: "화면 구조를 제공하는 컴포넌트" },
{ id: "data", name: "데이터", description: "데이터를 표시하는 컴포넌트" },
{ id: "navigation", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" },
{ id: "feedback", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" },
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
{ id: "other", name: "기타", description: "기타 컴포넌트" },
];
export const ComponentsPanel: React.FC<ComponentsPanelProps> = ({ onDragStart }) => {
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
// 데이터베이스에서 컴포넌트 가져오기
const {
data: componentsData,
isLoading: loading,
error,
} = useComponents({
active: "Y",
});
// 컴포넌트를 ComponentItem으로 변환
const componentItems = useMemo(() => {
if (!componentsData?.components) return [];
return componentsData.components.map((component) => ({
id: component.component_code,
name: component.component_name,
description: component.description || `${component.component_name} 컴포넌트`,
category: component.category || "other",
componentType: component.component_config?.type || component.component_code,
componentConfig: component.component_config,
icon: getComponentIcon(component.icon_name || component.component_config?.type),
defaultSize: component.default_size || getDefaultSize(component.component_config?.type),
}));
}, [componentsData]);
// 필터링된 컴포넌트
const filteredComponents = useMemo(() => {
return componentItems.filter((component) => {
const matchesSearch =
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === "all" || component.category === selectedCategory;
return matchesSearch && matchesCategory;
});
}, [componentItems, searchTerm, selectedCategory]);
// 카테고리별 그룹화
const groupedComponents = useMemo(() => {
const groups: Record<string, ComponentItem[]> = {};
COMPONENT_CATEGORIES.forEach((category) => {
groups[category.id] = filteredComponents.filter((component) => component.category === category.id);
});
return groups;
}, [filteredComponents]);
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Layers className="mx-auto h-8 w-8 animate-pulse text-gray-400" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Layers className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-2 text-sm text-red-500"> </p>
<p className="text-xs text-gray-500">{error.message}</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Layers className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"></h3>
<Badge variant="secondary" className="text-xs">
{filteredComponents.length}
</Badge>
</div>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
{/* 검색 및 필터 */}
<div className="space-y-3 border-b border-gray-200 p-4">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="컴포넌트 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-8 pl-9 text-xs"
/>
</div>
{/* 카테고리 필터 */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{COMPONENT_CATEGORIES.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-y-auto">
{selectedCategory === "all" ? (
// 카테고리별 그룹 표시
<div className="space-y-4 p-4">
{COMPONENT_CATEGORIES.map((category) => {
const categoryComponents = groupedComponents[category.id];
if (categoryComponents.length === 0) return null;
return (
<div key={category.id}>
<div className="mb-2 flex items-center space-x-2">
<h4 className="text-sm font-medium text-gray-700">{category.name}</h4>
<Badge variant="outline" className="text-xs">
{categoryComponents.length}
</Badge>
</div>
<p className="mb-3 text-xs text-gray-500">{category.description}</p>
<div className="grid gap-2">
{categoryComponents.map((component) => (
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} />
))}
</div>
</div>
);
})}
</div>
) : (
// 선택된 카테고리만 표시
<div className="p-4">
<div className="grid gap-2">
{filteredComponents.map((component) => (
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} />
))}
</div>
</div>
)}
{filteredComponents.length === 0 && (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Layers className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
</div>
)}
</div>
</div>
);
};
// 컴포넌트 카드 컴포넌트
const ComponentCard: React.FC<{
component: ComponentItem;
onDragStart: (e: React.DragEvent, component: ComponentItem) => void;
}> = ({ component, onDragStart }) => {
return (
<div
draggable
onDragStart={(e) => onDragStart(e, component)}
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
{component.icon}
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium text-gray-900">{component.name}</h4>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{component.description}</p>
<div className="mt-2 flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{component.webType}
</Badge>
</div>
</div>
</div>
</div>
);
};
// 웹타입별 아이콘 매핑
function getComponentIcon(webType: string): React.ReactNode {
const iconMap: Record<string, React.ReactNode> = {
text: <span className="text-xs">Aa</span>,
number: <span className="text-xs">123</span>,
date: <span className="text-xs">📅</span>,
select: <span className="text-xs"></span>,
checkbox: <span className="text-xs"></span>,
radio: <span className="text-xs"></span>,
textarea: <span className="text-xs">📝</span>,
file: <span className="text-xs">📎</span>,
button: <span className="text-xs">🔘</span>,
email: <span className="text-xs">📧</span>,
tel: <span className="text-xs">📞</span>,
password: <span className="text-xs">🔒</span>,
code: <span className="text-xs">&lt;&gt;</span>,
entity: <span className="text-xs">🔗</span>,
};
return iconMap[webType] || <span className="text-xs"></span>;
}
// 웹타입별 기본 크기
function getDefaultSize(webType: string): { width: number; height: number } {
const sizeMap: Record<string, { width: number; height: number }> = {
text: { width: 200, height: 36 },
number: { width: 150, height: 36 },
date: { width: 180, height: 36 },
select: { width: 200, height: 36 },
checkbox: { width: 150, height: 36 },
radio: { width: 200, height: 80 },
textarea: { width: 300, height: 100 },
file: { width: 300, height: 120 },
button: { width: 120, height: 36 },
email: { width: 250, height: 36 },
tel: { width: 180, height: 36 },
password: { width: 200, height: 36 },
code: { width: 200, height: 36 },
entity: { width: 200, height: 36 },
};
return sizeMap[webType] || { width: 200, height: 36 };
}
export default ComponentsPanel;

View File

@ -106,13 +106,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
); );
} }
if (selectedComponent.type !== "widget" && selectedComponent.type !== "file") { if (selectedComponent.type !== "widget" && selectedComponent.type !== "file" && selectedComponent.type !== "button") {
return ( return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center"> <div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" /> <Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3> <h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
. , , .
<br /> <br />
: {selectedComponent.type} : {selectedComponent.type}
</p> </p>
@ -152,6 +152,40 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
); );
} }
// 버튼 컴포넌트인 경우 ButtonConfigPanel 렌더링
if (selectedComponent.type === "button") {
const buttonWidget = selectedComponent as WidgetComponent;
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800"></span>
</div>
<div className="mt-1 text-xs text-gray-500">: {buttonWidget.label || "버튼"}</div>
</div>
{/* 버튼 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4">
<ButtonConfigPanel
component={buttonWidget}
onUpdateComponent={(updates) => {
Object.entries(updates).forEach(([key, value]) => {
onUpdateProperty(buttonWidget.id, key, value);
});
}}
/>
</div>
</div>
);
}
const widget = selectedComponent as WidgetComponent; const widget = selectedComponent as WidgetComponent;
return ( return (

View File

@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { import {
Table, Table,
Search, Search,
@ -31,7 +32,10 @@ import {
SidebarOpen, SidebarOpen,
Folder, Folder,
ChevronDown, ChevronDown,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
import { useTemplates, TemplateStandard } from "@/hooks/admin/useTemplates";
import { toast } from "sonner";
// 템플릿 컴포넌트 타입 정의 // 템플릿 컴포넌트 타입 정의
export interface TemplateComponent { export interface TemplateComponent {
@ -61,8 +65,41 @@ export interface TemplateComponent {
}>; }>;
} }
// 미리 정의된 템플릿 컴포넌트들 // 아이콘 매핑 함수
const templateComponents: TemplateComponent[] = [ const getIconByName = (iconName?: string): React.ReactNode => {
const iconMap: Record<string, React.ReactNode> = {
table: <Table className="h-4 w-4" />,
"mouse-pointer": <MousePointer className="h-4 w-4" />,
upload: <Upload className="h-4 w-4" />,
layout: <Layout className="h-4 w-4" />,
form: <FormInput className="h-4 w-4" />,
grid: <Grid3x3 className="h-4 w-4" />,
folder: <Folder className="h-4 w-4" />,
square: <Square className="h-4 w-4" />,
columns: <Columns className="h-4 w-4" />,
rows: <Rows className="h-4 w-4" />,
card: <CreditCard className="h-4 w-4" />,
sidebar: <SidebarOpen className="h-4 w-4" />,
};
return iconMap[iconName || ""] || <Grid3x3 className="h-4 w-4" />;
};
// TemplateStandard를 TemplateComponent로 변환하는 함수
const convertTemplateStandardToComponent = (template: TemplateStandard): TemplateComponent => {
return {
id: template.template_code,
name: template.template_name,
description: template.description || "",
category: template.category as TemplateComponent["category"],
icon: getIconByName(template.icon_name),
defaultSize: template.default_size || { width: 300, height: 200 },
components: template.layout_config?.components || [],
};
};
// 폴백 템플릿 (데이터베이스 연결 실패 시)
const fallbackTemplates: TemplateComponent[] = [
// 고급 데이터 테이블 템플릿 // 고급 데이터 테이블 템플릿
{ {
id: "advanced-data-table", id: "advanced-data-table",
@ -72,7 +109,6 @@ const templateComponents: TemplateComponent[] = [
icon: <Table className="h-4 w-4" />, icon: <Table className="h-4 w-4" />,
defaultSize: { width: 1000, height: 680 }, defaultSize: { width: 1000, height: 680 },
components: [ components: [
// 데이터 테이블 컴포넌트 (특별한 타입)
{ {
type: "datatable", type: "datatable",
label: "데이터 테이블", label: "데이터 테이블",
@ -88,57 +124,6 @@ const templateComponents: TemplateComponent[] = [
], ],
}, },
// 범용 버튼 템플릿
{
id: "universal-button",
name: "버튼",
description: "다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.",
category: "button",
icon: <MousePointer className="h-4 w-4" />,
defaultSize: { width: 80, height: 36 },
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",
},
},
],
},
// 파일 첨부 템플릿
{
id: "file-upload",
name: "파일 첨부",
description: "파일 업로드, 미리보기, 다운로드가 가능한 파일 첨부 컴포넌트",
category: "file",
icon: <Upload className="h-4 w-4" />,
defaultSize: { width: 600, height: 300 },
components: [
{
type: "file",
label: "파일 첨부",
position: { x: 0, y: 0 },
size: { width: 600, height: 300 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "16px",
},
},
],
},
// === 영역 템플릿들 === // === 영역 템플릿들 ===
// 기본 박스 영역 // 기본 박스 영역
@ -445,15 +430,55 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
const [searchTerm, setSearchTerm] = React.useState(""); const [searchTerm, setSearchTerm] = React.useState("");
const [selectedCategory, setSelectedCategory] = React.useState<string>("all"); const [selectedCategory, setSelectedCategory] = React.useState<string>("all");
const categories = [ // 동적 템플릿 데이터 조회
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> }, const {
{ id: "area", name: "영역", icon: <Layout className="h-4 w-4" /> }, templates: dbTemplates,
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> }, categories: dbCategories,
{ id: "button", name: "버튼", icon: <MousePointer className="h-4 w-4" /> }, isLoading,
{ id: "file", name: "파일", icon: <Upload className="h-4 w-4" /> }, error,
]; refetch,
} = useTemplates({
active: "Y", // 활성화된 템플릿만 조회
is_public: "Y", // 공개 템플릿만 조회 (회사별 템플릿도 포함됨)
});
const filteredTemplates = templateComponents.filter((template) => { // 데이터베이스 템플릿을 TemplateComponent 형태로 변환
const dynamicTemplates = React.useMemo(() => {
if (error || !dbTemplates) {
// 오류 발생 시 폴백 템플릿 사용
console.warn("템플릿 로딩 실패, 폴백 템플릿 사용:", error);
return fallbackTemplates;
}
return dbTemplates.map(convertTemplateStandardToComponent);
}, [dbTemplates, error]);
// 카테고리 목록 동적 생성
const categories = React.useMemo(() => {
const allCategories = [{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> }];
if (dbCategories && dbCategories.length > 0) {
// 데이터베이스에서 가져온 카테고리 사용
dbCategories.forEach((category) => {
const icon = getIconByName(category);
allCategories.push({
id: category,
name: category,
icon,
});
});
} else {
// 폴백 카테고리 (실제 템플릿만)
allCategories.push(
{ id: "area", name: "영역", icon: <Layout className="h-4 w-4" /> },
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
);
}
return allCategories;
}, [dbCategories]);
const filteredTemplates = dynamicTemplates.filter((template) => {
const matchesSearch = const matchesSearch =
template.name.toLowerCase().includes(searchTerm.toLowerCase()) || template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase()); template.description.toLowerCase().includes(searchTerm.toLowerCase());
@ -494,9 +519,27 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
<Separator /> <Separator />
{/* 새로고침 버튼 */}
{error && (
<div className="flex items-center justify-between rounded-lg bg-yellow-50 p-3 text-yellow-800">
<div className="flex items-center space-x-2">
<Info className="h-4 w-4" />
<span className="text-sm">릿 , 릿 </span>
</div>
<Button size="sm" variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
)}
{/* 템플릿 목록 */} {/* 템플릿 목록 */}
<div className="flex-1 space-y-2 overflow-y-auto"> <div className="flex-1 space-y-2 overflow-y-auto">
{filteredTemplates.length === 0 ? ( {isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
<span className="ml-2 text-sm text-gray-500">릿 ...</span>
</div>
) : filteredTemplates.length === 0 ? (
<div className="flex h-32 items-center justify-center text-center text-gray-500"> <div className="flex h-32 items-center justify-center text-center text-gray-500">
<div> <div>
<FileText className="mx-auto mb-2 h-8 w-8" /> <FileText className="mx-auto mb-2 h-8 w-8" />

View File

@ -0,0 +1,479 @@
"use client";
import React from "react";
import { Table, Filter, Search, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { Checkbox } from "@/components/ui/checkbox";
/**
* 릿
*
*/
export interface DataTableTemplateProps {
/**
*
*/
title?: string;
/**
*
*/
description?: string;
/**
*
*/
columns?: Array<{
id: string;
label: string;
type: string;
visible: boolean;
sortable: boolean;
filterable: boolean;
width?: number;
}>;
/**
*
*/
filters?: Array<{
id: string;
label: string;
type: "text" | "select" | "date" | "number";
options?: Array<{ label: string; value: string }>;
}>;
/**
*
*/
pagination?: {
enabled: boolean;
pageSize: number;
pageSizeOptions: number[];
showPageSizeSelector: boolean;
showPageInfo: boolean;
showFirstLast: boolean;
};
/**
*
*/
actions?: {
showSearchButton: boolean;
searchButtonText: string;
enableExport: boolean;
enableRefresh: boolean;
enableAdd: boolean;
enableEdit: boolean;
enableDelete: boolean;
addButtonText: string;
editButtonText: string;
deleteButtonText: string;
};
/**
*
*/
style?: React.CSSProperties;
/**
*
*/
className?: string;
/**
* ( )
*/
isPreview?: boolean;
}
export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
title = "데이터 테이블",
description = "데이터를 표시하고 관리하는 테이블",
columns = [],
filters = [],
pagination = {
enabled: true,
pageSize: 10,
pageSizeOptions: [5, 10, 20, 50],
showPageSizeSelector: true,
showPageInfo: true,
showFirstLast: true,
},
actions = {
showSearchButton: true,
searchButtonText: "검색",
enableExport: true,
enableRefresh: true,
enableAdd: true,
enableEdit: true,
enableDelete: true,
addButtonText: "추가",
editButtonText: "수정",
deleteButtonText: "삭제",
},
style,
className = "",
isPreview = true,
}) => {
// 미리보기용 기본 컬럼 데이터
const defaultColumns = React.useMemo(() => {
if (columns.length > 0) return columns;
return [
{ 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,
},
];
}, [columns]);
// 미리보기용 샘플 데이터
const sampleData = React.useMemo(() => {
if (!isPreview) return [];
return [
{ id: 1, name: "홍길동", email: "hong@example.com", status: "활성", created_date: "2024-01-15" },
{ id: 2, name: "김철수", email: "kim@example.com", status: "비활성", created_date: "2024-01-14" },
{ id: 3, name: "이영희", email: "lee@example.com", status: "활성", created_date: "2024-01-13" },
];
}, [isPreview]);
const visibleColumns = defaultColumns.filter((col) => col.visible);
return (
<Card className={`h-full w-full ${className}`} style={style}>
{/* 헤더 영역 */}
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center space-x-2">
<Table className="h-5 w-5" />
<span>{title}</span>
</CardTitle>
{description && <p className="text-muted-foreground mt-1 text-sm">{description}</p>}
</div>
{/* 액션 버튼들 */}
<div className="flex items-center space-x-2">
{actions.enableRefresh && (
<Button variant="outline" size="sm">
<RefreshCw className="h-4 w-4" />
</Button>
)}
{actions.enableExport && (
<Button variant="outline" size="sm">
<Download className="mr-1 h-4 w-4" />
</Button>
)}
{actions.enableAdd && (
<Button size="sm">
<Plus className="mr-1 h-4 w-4" />
{actions.addButtonText}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 및 필터 영역 */}
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
{/* 검색 입력 */}
<div className="flex items-center space-x-2">
<div className="relative min-w-[200px] flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input placeholder="검색어를 입력하세요..." className="pl-10" disabled={isPreview} />
</div>
{actions.showSearchButton && (
<Button variant="outline" disabled={isPreview}>
{actions.searchButtonText}
</Button>
)}
</div>
{/* 필터 영역 */}
{filters.length > 0 && (
<div className="flex items-center space-x-2">
<Filter className="text-muted-foreground h-4 w-4" />
{filters.slice(0, 3).map((filter) => (
<Select key={filter.id} disabled={isPreview}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder={filter.label} />
</SelectTrigger>
<SelectContent>
{filter.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
)}
</div>
{/* 데이터 테이블 */}
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full">
{/* 테이블 헤더 */}
<thead className="bg-muted/50 border-b">
<tr>
{/* 선택 체크박스 */}
<th className="w-12 p-3">
<Checkbox disabled={isPreview} />
</th>
{/* 컬럼 헤더 */}
{visibleColumns.map((column) => (
<th key={column.id} className="p-3 text-left font-medium" style={{ width: column.width }}>
<div className="flex items-center space-x-1">
<span>{column.label}</span>
{column.sortable && (
<div className="flex flex-col">
<div className="bg-muted-foreground h-1 w-1"></div>
<div className="bg-muted-foreground mt-0.5 h-1 w-1"></div>
</div>
)}
</div>
</th>
))}
{/* 액션 컬럼 */}
{(actions.enableEdit || actions.enableDelete) && <th className="w-24 p-3 text-center"></th>}
</tr>
</thead>
{/* 테이블 바디 */}
<tbody>
{isPreview ? (
// 미리보기 데이터
sampleData.map((row, index) => (
<tr key={index} className="hover:bg-muted/30 border-b">
<td className="p-3">
<Checkbox disabled />
</td>
{visibleColumns.map((column) => (
<td key={column.id} className="p-3">
{column.type === "select" && column.id === "status" ? (
<Badge variant={row[column.id] === "활성" ? "default" : "secondary"}>
{row[column.id]}
</Badge>
) : (
<span className="text-sm">{row[column.id]}</span>
)}
</td>
))}
{(actions.enableEdit || actions.enableDelete) && (
<td className="p-3">
<div className="flex items-center justify-center space-x-1">
{actions.enableEdit && (
<Button variant="ghost" size="sm" disabled>
<Edit className="h-3 w-3" />
</Button>
)}
{actions.enableDelete && (
<Button variant="ghost" size="sm" disabled>
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
)}
</div>
</td>
)}
</tr>
))
) : (
// 실제 데이터가 없는 경우 플레이스홀더
<tr>
<td colSpan={visibleColumns.length + 2} className="text-muted-foreground p-8 text-center">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 페이징 영역 */}
{pagination.enabled && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
{pagination.showPageInfo && <span>{isPreview ? "1-3 of 3" : "0-0 of 0"} </span>}
</div>
<div className="flex items-center space-x-2">
{/* 페이지 크기 선택 */}
{pagination.showPageSizeSelector && (
<div className="flex items-center space-x-2">
<span className="text-muted-foreground text-sm">:</span>
<Select disabled={isPreview}>
<SelectTrigger className="w-16">
<SelectValue placeholder={pagination.pageSize.toString()} />
</SelectTrigger>
<SelectContent>
{pagination.pageSizeOptions.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 페이지 네비게이션 */}
<div className="flex items-center space-x-1">
{pagination.showFirstLast && (
<Button variant="outline" size="sm" disabled>
«
</Button>
)}
<Button variant="outline" size="sm" disabled>
</Button>
<Button variant="outline" size="sm" className="bg-primary text-primary-foreground">
1
</Button>
<Button variant="outline" size="sm" disabled>
</Button>
{pagination.showFirstLast && (
<Button variant="outline" size="sm" disabled>
»
</Button>
)}
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
};
/**
* 릿
*/
export const getDefaultDataTableConfig = () => ({
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: "취소",
},
},
],
},
});
export default DataTableTemplate;

View File

@ -0,0 +1,228 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
// 컴포넌트 표준 타입 정의
export interface ComponentStandard {
component_code: string;
component_name: string;
component_name_eng?: string;
description?: string;
category: string;
icon_name?: string;
default_size?: { width: number; height: number };
component_config: any;
preview_image?: string;
sort_order?: number;
is_active?: string;
is_public?: string;
company_code: string;
created_date?: string;
created_by?: string;
updated_date?: string;
updated_by?: string;
}
export interface ComponentQueryParams {
category?: string;
active?: string;
is_public?: string;
search?: string;
sort?: string;
order?: "asc" | "desc";
limit?: number;
offset?: number;
}
export interface ComponentListResponse {
components: ComponentStandard[];
total: number;
limit?: number;
offset?: number;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// API 함수들
const componentApi = {
// 컴포넌트 목록 조회
getComponents: async (params: ComponentQueryParams = {}): Promise<ComponentListResponse> => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
searchParams.append(key, value.toString());
}
});
const response = await apiClient.get<ApiResponse<ComponentListResponse>>(
`/admin/component-standards?${searchParams.toString()}`,
);
return response.data.data;
},
// 컴포넌트 상세 조회
getComponent: async (component_code: string): Promise<ComponentStandard> => {
const response = await apiClient.get<ApiResponse<ComponentStandard>>(
`/admin/component-standards/${component_code}`,
);
return response.data.data;
},
// 컴포넌트 생성
createComponent: async (data: Partial<ComponentStandard>): Promise<ComponentStandard> => {
const response = await apiClient.post<ApiResponse<ComponentStandard>>("/admin/component-standards", data);
return response.data.data;
},
// 컴포넌트 수정
updateComponent: async (component_code: string, data: Partial<ComponentStandard>): Promise<ComponentStandard> => {
const response = await apiClient.put<ApiResponse<ComponentStandard>>(
`/admin/component-standards/${component_code}`,
data,
);
return response.data.data;
},
// 컴포넌트 삭제
deleteComponent: async (component_code: string): Promise<void> => {
await apiClient.delete(`/admin/component-standards/${component_code}`);
},
// 정렬 순서 업데이트
updateSortOrder: async (updates: Array<{ component_code: string; sort_order: number }>): Promise<void> => {
await apiClient.put("/admin/component-standards/sort/order", { updates });
},
// 컴포넌트 복제
duplicateComponent: async (data: {
source_code: string;
new_code: string;
new_name: string;
}): Promise<ComponentStandard> => {
const response = await apiClient.post<ApiResponse<ComponentStandard>>("/admin/component-standards/duplicate", data);
return response.data.data;
},
// 카테고리 목록 조회
getCategories: async (): Promise<string[]> => {
const response = await apiClient.get<ApiResponse<string[]>>("/admin/component-standards/categories");
return response.data.data;
},
// 통계 조회
getStatistics: async (): Promise<{
total: number;
byCategory: Array<{ category: string; count: number }>;
byStatus: Array<{ status: string; count: number }>;
}> => {
const response = await apiClient.get<ApiResponse<any>>("/admin/component-standards/statistics");
return response.data.data;
},
};
// React Query 훅들
export const useComponents = (params: ComponentQueryParams = {}) => {
return useQuery({
queryKey: ["components", params],
queryFn: () => componentApi.getComponents(params),
staleTime: 5 * 60 * 1000, // 5분
});
};
export const useComponent = (component_code: string) => {
return useQuery({
queryKey: ["component", component_code],
queryFn: () => componentApi.getComponent(component_code),
enabled: !!component_code,
});
};
export const useComponentCategories = () => {
return useQuery({
queryKey: ["component-categories"],
queryFn: componentApi.getCategories,
staleTime: 10 * 60 * 1000, // 10분
});
};
export const useComponentStatistics = () => {
return useQuery({
queryKey: ["component-statistics"],
queryFn: componentApi.getStatistics,
staleTime: 2 * 60 * 1000, // 2분
});
};
// Mutation 훅들
export const useCreateComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.createComponent,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-categories"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};
export const useUpdateComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ component_code, data }: { component_code: string; data: Partial<ComponentStandard> }) =>
componentApi.updateComponent(component_code, data),
onSuccess: (data, variables) => {
// 특정 컴포넌트와 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["component", variables.component_code] });
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};
export const useDeleteComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.deleteComponent,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-categories"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};
export const useUpdateSortOrder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.updateSortOrder,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
},
});
};
export const useDuplicateComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: componentApi.duplicateComponent,
onSuccess: () => {
// 컴포넌트 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["components"] });
queryClient.invalidateQueries({ queryKey: ["component-statistics"] });
},
});
};

View File

@ -0,0 +1,376 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
// 템플릿 데이터 인터페이스
export interface TemplateStandard {
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; // 템플릿의 컴포넌트 구조
preview_image?: string;
sort_order?: number;
is_active: string;
is_public?: string;
company_code: string;
created_date?: string;
created_by?: string;
updated_date?: string;
updated_by?: string;
}
// 템플릿 생성/수정 데이터
export interface TemplateFormData {
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;
preview_image?: string;
sort_order?: number;
is_active?: string;
is_public?: string;
}
// API 응답 인터페이스
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// 쿼리 파라미터 인터페이스
interface TemplateQueryParams {
active?: string;
category?: string;
search?: string;
company_code?: string;
is_public?: string;
page?: number;
limit?: number;
}
/**
* 릿
*/
export const useTemplates = (params?: TemplateQueryParams) => {
const queryClient = useQueryClient();
// 템플릿 목록 조회
const {
data: templatesData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["templates", params],
queryFn: async (): Promise<{ templates: TemplateStandard[]; pagination: any }> => {
const searchParams = new URLSearchParams();
if (params?.active) searchParams.append("active", params.active);
if (params?.category) searchParams.append("category", params.category);
if (params?.search) searchParams.append("search", params.search);
if (params?.company_code) searchParams.append("company_code", params.company_code);
if (params?.is_public) searchParams.append("is_public", params.is_public);
if (params?.page) searchParams.append("page", params.page.toString());
if (params?.limit) searchParams.append("limit", params.limit.toString());
const response = await apiClient.get(`/admin/template-standards?${searchParams.toString()}`);
const result: ApiResponse<TemplateStandard[]> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to fetch templates");
}
return {
templates: result.data || [],
pagination: result.pagination,
};
},
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
cacheTime: 10 * 60 * 1000, // 10분간 메모리 보관
});
const templates = templatesData?.templates || [];
const pagination = templatesData?.pagination;
// 템플릿 상세 조회
const getTemplate = useCallback(async (templateCode: string) => {
const response = await apiClient.get(`/admin/template-standards/${templateCode}`);
const result: ApiResponse<TemplateStandard> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to fetch template");
}
return result.data!;
}, []);
// 템플릿 생성
const createTemplateMutation = useMutation({
mutationFn: async (data: TemplateFormData): Promise<TemplateStandard> => {
const response = await apiClient.post("/admin/template-standards", data);
const result: ApiResponse<TemplateStandard> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to create template");
}
return result.data!;
},
onSuccess: () => {
// 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
// 템플릿 수정
const updateTemplateMutation = useMutation({
mutationFn: async ({
templateCode,
data,
}: {
templateCode: string;
data: Partial<TemplateFormData>;
}): Promise<TemplateStandard> => {
const response = await apiClient.put(`/admin/template-standards/${templateCode}`, data);
const result: ApiResponse<TemplateStandard> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to update template");
}
return result.data!;
},
onSuccess: () => {
// 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
// 템플릿 삭제
const deleteTemplateMutation = useMutation({
mutationFn: async (templateCode: string): Promise<void> => {
const response = await apiClient.delete(`/admin/template-standards/${templateCode}`);
const result: ApiResponse<void> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to delete template");
}
},
onSuccess: () => {
// 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
// 정렬 순서 업데이트
const updateSortOrderMutation = useMutation({
mutationFn: async (templates: { template_code: string; sort_order: number }[]): Promise<void> => {
const response = await apiClient.put("/admin/template-standards/sort-order/bulk", { templates });
const result: ApiResponse<void> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to update sort order");
}
},
onSuccess: () => {
// 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
// 템플릿 복제
const duplicateTemplateMutation = useMutation({
mutationFn: async ({
templateCode,
newTemplateCode,
newTemplateName,
}: {
templateCode: string;
newTemplateCode: string;
newTemplateName: string;
}): Promise<TemplateStandard> => {
const response = await apiClient.post(`/admin/template-standards/${templateCode}/duplicate`, {
new_template_code: newTemplateCode,
new_template_name: newTemplateName,
});
const result: ApiResponse<TemplateStandard> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to duplicate template");
}
return result.data!;
},
onSuccess: () => {
// 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
// 템플릿 가져오기
const importTemplateMutation = useMutation({
mutationFn: async (templateData: any): Promise<TemplateStandard> => {
const response = await apiClient.post("/admin/template-standards/import", templateData);
const result: ApiResponse<TemplateStandard> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to import template");
}
return result.data!;
},
onSuccess: () => {
// 목록 새로고침
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
// 템플릿 내보내기
const exportTemplate = useCallback(async (templateCode: string) => {
const response = await apiClient.get(`/admin/template-standards/${templateCode}/export`);
const result: ApiResponse<any> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to export template");
}
return result.data;
}, []);
// 카테고리 목록 조회
const {
data: categories,
isLoading: categoriesLoading,
error: categoriesError,
} = useQuery({
queryKey: ["template-categories"],
queryFn: async (): Promise<string[]> => {
const response = await apiClient.get("/admin/template-standards/categories");
const result: ApiResponse<string[]> = response.data;
if (!result.success) {
throw new Error(result.error || "Failed to fetch categories");
}
return result.data || [];
},
staleTime: 10 * 60 * 1000, // 10분간 캐시 유지
});
// 편의 메서드들
const createTemplate = useCallback(
(data: TemplateFormData) => {
return createTemplateMutation.mutateAsync(data);
},
[createTemplateMutation],
);
const updateTemplate = useCallback(
(templateCode: string, data: Partial<TemplateFormData>) => {
return updateTemplateMutation.mutateAsync({ templateCode, data });
},
[updateTemplateMutation],
);
const deleteTemplate = useCallback(
(templateCode: string) => {
return deleteTemplateMutation.mutateAsync(templateCode);
},
[deleteTemplateMutation],
);
const updateSortOrder = useCallback(
(templates: { template_code: string; sort_order: number }[]) => {
return updateSortOrderMutation.mutateAsync(templates);
},
[updateSortOrderMutation],
);
const duplicateTemplate = useCallback(
(templateCode: string, newTemplateCode: string, newTemplateName: string) => {
return duplicateTemplateMutation.mutateAsync({
templateCode,
newTemplateCode,
newTemplateName,
});
},
[duplicateTemplateMutation],
);
const importTemplate = useCallback(
(templateData: any) => {
return importTemplateMutation.mutateAsync(templateData);
},
[importTemplateMutation],
);
return {
// 데이터
templates,
pagination,
categories: categories || [],
// 로딩 상태
isLoading,
categoriesLoading,
isCreating: createTemplateMutation.isPending,
isUpdating: updateTemplateMutation.isPending,
isDeleting: deleteTemplateMutation.isPending,
isDuplicating: duplicateTemplateMutation.isPending,
isImporting: importTemplateMutation.isPending,
isSortOrderUpdating: updateSortOrderMutation.isPending,
// 에러 상태
error,
categoriesError,
createError: createTemplateMutation.error,
updateError: updateTemplateMutation.error,
deleteError: deleteTemplateMutation.error,
duplicateError: duplicateTemplateMutation.error,
importError: importTemplateMutation.error,
sortOrderError: updateSortOrderMutation.error,
// 메서드
getTemplate,
createTemplate,
updateTemplate,
deleteTemplate,
updateSortOrder,
duplicateTemplate,
importTemplate,
exportTemplate,
refetch,
};
};

View File

@ -11,6 +11,7 @@ import { FileTypeConfigPanel } from "@/components/screen/panels/webtype-configs/
import { CodeTypeConfigPanel } from "@/components/screen/panels/webtype-configs/CodeTypeConfigPanel"; import { CodeTypeConfigPanel } from "@/components/screen/panels/webtype-configs/CodeTypeConfigPanel";
import { EntityTypeConfigPanel } from "@/components/screen/panels/webtype-configs/EntityTypeConfigPanel"; import { EntityTypeConfigPanel } from "@/components/screen/panels/webtype-configs/EntityTypeConfigPanel";
import { RatingTypeConfigPanel } from "@/components/screen/panels/webtype-configs/RatingTypeConfigPanel"; import { RatingTypeConfigPanel } from "@/components/screen/panels/webtype-configs/RatingTypeConfigPanel";
import { ButtonConfigPanel } from "@/components/screen/config-panels/ButtonConfigPanel";
// 설정 패널 컴포넌트 타입 // 설정 패널 컴포넌트 타입
export type ConfigPanelComponent = React.ComponentType<{ export type ConfigPanelComponent = React.ComponentType<{
@ -54,6 +55,9 @@ export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent
console.log(`🔧 RatingTypeConfigPanel 타입:`, typeof RatingTypeConfigPanel); console.log(`🔧 RatingTypeConfigPanel 타입:`, typeof RatingTypeConfigPanel);
console.log(`🔧 RatingTypeConfigPanel 내용:`, RatingTypeConfigPanel); console.log(`🔧 RatingTypeConfigPanel 내용:`, RatingTypeConfigPanel);
return RatingTypeConfigPanel; return RatingTypeConfigPanel;
case "ButtonConfigPanel":
console.log(`🔧 ButtonConfigPanel 컴포넌트 반환`);
return ButtonConfigPanel;
default: default:
console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`); console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`);
return null; // 기본 설정 (패널 없음) return null; // 기본 설정 (패널 없음)