템플릿관리, 컴포넌트 관리
This commit is contained in:
parent
85a1e0c68a
commit
db782eb9c9
|
|
@ -5254,3 +5254,51 @@ model table_relationships {
|
|||
|
||||
@@index([to_table_name], map: "idx_table_relationships_to_table")
|
||||
}
|
||||
|
||||
// 템플릿 표준 관리 테이블
|
||||
model template_standards {
|
||||
template_code String @id @db.VarChar(50)
|
||||
template_name String @db.VarChar(100)
|
||||
template_name_eng String? @db.VarChar(100)
|
||||
description String? @db.Text
|
||||
category String @db.VarChar(50)
|
||||
icon_name String? @db.VarChar(50)
|
||||
default_size Json? // { width: number, height: number }
|
||||
layout_config Json // 템플릿의 컴포넌트 구조 정의
|
||||
preview_image String? @db.VarChar(255)
|
||||
sort_order Int? @default(0)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
is_public String? @default("Y") @db.Char(1)
|
||||
company_code String @db.VarChar(50)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([category], map: "idx_template_standards_category")
|
||||
@@index([company_code], map: "idx_template_standards_company")
|
||||
}
|
||||
|
||||
// 컴포넌트 표준 관리 테이블
|
||||
model component_standards {
|
||||
component_code String @id @db.VarChar(50)
|
||||
component_name String @db.VarChar(100)
|
||||
component_name_eng String? @db.VarChar(100)
|
||||
description String? @db.Text
|
||||
category String @db.VarChar(50)
|
||||
icon_name String? @db.VarChar(50)
|
||||
default_size Json? // { width: number, height: number }
|
||||
component_config Json // 컴포넌트의 기본 설정 및 props
|
||||
preview_image String? @db.VarChar(255)
|
||||
sort_order Int? @default(0)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
is_public String? @default("Y") @db.Char(1)
|
||||
company_code String @db.VarChar(50)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([category], map: "idx_component_standards_category")
|
||||
@@index([company_code], map: "idx_component_standards_company")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
|
|
@ -22,6 +22,8 @@ import companyManagementRoutes from "./routes/companyManagementRoutes";
|
|||
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
||||
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||
import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
||||
|
|
@ -106,6 +108,8 @@ app.use("/api/files", fileRoutes);
|
|||
app.use("/api/company-management", companyManagementRoutes);
|
||||
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||
app.use("/api/admin/template-standards", templateStandardRoutes);
|
||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -2,42 +2,6 @@ import { Request, Response } from "express";
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
// 임시 타입 확장 (Prisma Client 재생성 전까지)
|
||||
interface WebTypeStandardCreateData {
|
||||
web_type: string;
|
||||
type_name: string;
|
||||
type_name_eng?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
component_name?: string;
|
||||
config_panel?: string;
|
||||
default_config?: any;
|
||||
validation_rules?: any;
|
||||
default_style?: any;
|
||||
input_properties?: any;
|
||||
sort_order?: number;
|
||||
is_active?: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
interface WebTypeStandardUpdateData {
|
||||
type_name?: string;
|
||||
type_name_eng?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
component_name?: string;
|
||||
config_panel?: string;
|
||||
default_config?: any;
|
||||
validation_rules?: any;
|
||||
default_style?: any;
|
||||
input_properties?: any;
|
||||
sort_order?: number;
|
||||
is_active?: string;
|
||||
updated_by?: string;
|
||||
updated_date?: Date;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class WebTypeStandardController {
|
||||
|
|
@ -173,7 +137,7 @@ export class WebTypeStandardController {
|
|||
is_active,
|
||||
created_by: req.user?.userId || "system",
|
||||
updated_by: req.user?.userId || "system",
|
||||
} as WebTypeStandardCreateData,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
|
|
@ -239,7 +203,7 @@ export class WebTypeStandardController {
|
|||
is_active,
|
||||
updated_by: req.user?.userId || "system",
|
||||
updated_date: new Date(),
|
||||
} as WebTypeStandardUpdateData,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -857,8 +857,9 @@ export class TableManagementService {
|
|||
|
||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||
|
||||
// 🎯 파일 타입 컬럼 감지
|
||||
const fileColumns = await this.getFileTypeColumns(tableName);
|
||||
// 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지)
|
||||
// const fileColumns = await this.getFileTypeColumns(tableName);
|
||||
const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화
|
||||
|
||||
// WHERE 조건 구성
|
||||
let whereConditions: string[] = [];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Users, Shield, Settings, BarChart3, Palette } from "lucide-react";
|
||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
/**
|
||||
* 관리자 메인 페이지
|
||||
|
|
@ -7,7 +7,7 @@ export default function AdminPage() {
|
|||
return (
|
||||
<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">
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -73,6 +73,68 @@ export default function AdminPage() {
|
|||
</Link>
|
||||
</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">
|
||||
<h3 className="mb-4 text-lg font-semibold">최근 관리자 활동</h3>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
템플릿 '{template.template_name}'을 정말 삭제하시겠습니까?
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -102,6 +102,19 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
|||
</Badge>
|
||||
</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
|
||||
variant={panelStates.properties?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
|
|
@ -1683,13 +1683,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
|
||||
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 space-x-2">
|
||||
<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 && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
|
|
@ -1753,10 +1753,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<>
|
||||
<Separator className="my-2" />
|
||||
<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" />
|
||||
검색 필터
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
|
|
@ -1775,10 +1775,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
{/* 테이블 내용 */}
|
||||
<CardContent className="flex-1 p-0">
|
||||
<div className="flex-1 p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
|
|
@ -1803,26 +1803,14 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
{column.label}
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
visibleColumns.length +
|
||||
(component.enableDelete ? 1 : 0) +
|
||||
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
||||
}
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<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)}
|
||||
</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>
|
||||
<TableCell
|
||||
colSpan={
|
||||
visibleColumns.length +
|
||||
(component.enableDelete ? 1 : 0) +
|
||||
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
||||
}
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
|
|
@ -1980,7 +1930,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
{/* 데이터 추가 모달 */}
|
||||
<Dialog open={showAddModal} onOpenChange={handleAddModalClose}>
|
||||
|
|
@ -2502,6 +2452,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { FileUpload } from "./widgets/FileUpload";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
||||
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
|
||||
import {
|
||||
Database,
|
||||
Type,
|
||||
|
|
@ -262,122 +263,31 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
(() => {
|
||||
const dataTableComponent = component as any; // DataTableComponent 타입
|
||||
|
||||
// 메모이제이션 없이 직접 계산
|
||||
const visibleColumns = dataTableComponent.columns?.filter((col: any) => col.visible) || [];
|
||||
const filters = dataTableComponent.filters || [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded border bg-white">
|
||||
{/* 테이블 제목 */}
|
||||
{dataTableComponent.title && (
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<h3 className="text-sm font-medium">{dataTableComponent.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 및 필터 영역 */}
|
||||
{(dataTableComponent.showSearchButton || filters.length > 0) && (
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{dataTableComponent.showSearchButton && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input placeholder="검색..." className="h-8 w-48" />
|
||||
<Button size="sm" variant="outline">
|
||||
{dataTableComponent.searchButtonText || "검색"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filters.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500">필터:</span>
|
||||
{filters.slice(0, 2).map((filter: any, index: number) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{filter.label || filter.columnName}
|
||||
</Badge>
|
||||
))}
|
||||
{filters.length > 2 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{filters.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 본체 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumns.length > 0 ? (
|
||||
visibleColumns.map((col: any, index: number) => (
|
||||
<TableHead key={col.id || index} className="text-xs">
|
||||
{col.label || col.columnName}
|
||||
{col.sortable && <span className="ml-1 text-gray-400">↕</span>}
|
||||
</TableHead>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<TableHead className="text-xs">컬럼 1</TableHead>
|
||||
<TableHead className="text-xs">컬럼 2</TableHead>
|
||||
<TableHead className="text-xs">컬럼 3</TableHead>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* 샘플 데이터 행들 */}
|
||||
{[1, 2, 3].map((rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{visibleColumns.length > 0 ? (
|
||||
visibleColumns.map((col: any, colIndex: number) => (
|
||||
<TableCell key={col.id || colIndex} className="text-xs">
|
||||
{col.widgetType === "checkbox" ? (
|
||||
<input type="checkbox" className="h-3 w-3" />
|
||||
) : col.widgetType === "select" ? (
|
||||
`옵션 ${rowIndex}`
|
||||
) : col.widgetType === "date" ? (
|
||||
"2024-01-01"
|
||||
) : col.widgetType === "number" ? (
|
||||
`${rowIndex * 100}`
|
||||
) : (
|
||||
`데이터 ${rowIndex}-${colIndex + 1}`
|
||||
)}
|
||||
</TableCell>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-1</TableCell>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-2</TableCell>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-3</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{dataTableComponent.pagination && (
|
||||
<div className="border-t bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>총 3개 항목</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
이전
|
||||
</Button>
|
||||
<span>1 / 1</span>
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DataTableTemplate
|
||||
title={dataTableComponent.title || dataTableComponent.label}
|
||||
description={`${dataTableComponent.label}을 표시하는 데이터 테이블`}
|
||||
columns={dataTableComponent.columns}
|
||||
filters={dataTableComponent.filters}
|
||||
pagination={dataTableComponent.pagination}
|
||||
actions={
|
||||
dataTableComponent.actions || {
|
||||
showSearchButton: dataTableComponent.showSearchButton ?? true,
|
||||
searchButtonText: dataTableComponent.searchButtonText || "검색",
|
||||
enableExport: dataTableComponent.enableExport ?? true,
|
||||
enableRefresh: dataTableComponent.enableRefresh ?? true,
|
||||
enableAdd: dataTableComponent.enableAdd ?? true,
|
||||
enableEdit: dataTableComponent.enableEdit ?? true,
|
||||
enableDelete: dataTableComponent.enableDelete ?? true,
|
||||
addButtonText: dataTableComponent.addButtonText || "추가",
|
||||
editButtonText: dataTableComponent.editButtonText || "수정",
|
||||
deleteButtonText: dataTableComponent.deleteButtonText || "삭제",
|
||||
}
|
||||
}
|
||||
style={component.style}
|
||||
className="h-full w-full"
|
||||
isPreview={true}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import FloatingPanel from "./FloatingPanel";
|
|||
import DesignerToolbar from "./DesignerToolbar";
|
||||
import TablesPanel from "./panels/TablesPanel";
|
||||
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||
import ComponentsPanel from "./panels/ComponentsPanel";
|
||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||
import GridPanel from "./panels/GridPanel";
|
||||
|
|
@ -1211,6 +1212,121 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
[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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -1232,6 +1348,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return;
|
||||
}
|
||||
|
||||
// 컴포넌트 드래그인 경우
|
||||
if (parsedData.type === "component") {
|
||||
handleComponentDrop(e, parsedData.component);
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 테이블/컬럼 드래그 처리
|
||||
const { type, table, column } = parsedData;
|
||||
|
||||
|
|
@ -2935,9 +3057,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
>
|
||||
<TemplatesPanel
|
||||
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 = {
|
||||
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));
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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"><></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;
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">설정할 수 없는 컴포넌트입니다</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
상세 설정은 위젯 컴포넌트와 파일 컴포넌트에서만 사용할 수 있습니다.
|
||||
상세 설정은 위젯, 파일, 버튼 컴포넌트에서만 사용할 수 있습니다.
|
||||
<br />
|
||||
현재 선택된 컴포넌트: {selectedComponent.type}
|
||||
</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;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import {
|
||||
Table,
|
||||
Search,
|
||||
|
|
@ -31,7 +32,10 @@ import {
|
|||
SidebarOpen,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useTemplates, TemplateStandard } from "@/hooks/admin/useTemplates";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 템플릿 컴포넌트 타입 정의
|
||||
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",
|
||||
|
|
@ -72,7 +109,6 @@ const templateComponents: TemplateComponent[] = [
|
|||
icon: <Table className="h-4 w-4" />,
|
||||
defaultSize: { width: 1000, height: 680 },
|
||||
components: [
|
||||
// 데이터 테이블 컴포넌트 (특별한 타입)
|
||||
{
|
||||
type: "datatable",
|
||||
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 [selectedCategory, setSelectedCategory] = React.useState<string>("all");
|
||||
|
||||
const categories = [
|
||||
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
|
||||
{ id: "area", name: "영역", icon: <Layout className="h-4 w-4" /> },
|
||||
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
|
||||
{ id: "button", name: "버튼", icon: <MousePointer className="h-4 w-4" /> },
|
||||
{ id: "file", name: "파일", icon: <Upload className="h-4 w-4" /> },
|
||||
];
|
||||
// 동적 템플릿 데이터 조회
|
||||
const {
|
||||
templates: dbTemplates,
|
||||
categories: dbCategories,
|
||||
isLoading,
|
||||
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 =
|
||||
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
|
@ -494,9 +519,27 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
|
||||
<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">
|
||||
{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>
|
||||
<FileText className="mx-auto mb-2 h-8 w-8" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ import { FileTypeConfigPanel } from "@/components/screen/panels/webtype-configs/
|
|||
import { CodeTypeConfigPanel } from "@/components/screen/panels/webtype-configs/CodeTypeConfigPanel";
|
||||
import { EntityTypeConfigPanel } from "@/components/screen/panels/webtype-configs/EntityTypeConfigPanel";
|
||||
import { RatingTypeConfigPanel } from "@/components/screen/panels/webtype-configs/RatingTypeConfigPanel";
|
||||
import { ButtonConfigPanel } from "@/components/screen/config-panels/ButtonConfigPanel";
|
||||
|
||||
// 설정 패널 컴포넌트 타입
|
||||
export type ConfigPanelComponent = React.ComponentType<{
|
||||
|
|
@ -54,6 +55,9 @@ export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent
|
|||
console.log(`🔧 RatingTypeConfigPanel 타입:`, typeof RatingTypeConfigPanel);
|
||||
console.log(`🔧 RatingTypeConfigPanel 내용:`, RatingTypeConfigPanel);
|
||||
return RatingTypeConfigPanel;
|
||||
case "ButtonConfigPanel":
|
||||
console.log(`🔧 ButtonConfigPanel 컴포넌트 반환`);
|
||||
return ButtonConfigPanel;
|
||||
default:
|
||||
console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`);
|
||||
return null; // 기본 설정 (패널 없음)
|
||||
|
|
|
|||
Loading…
Reference in New Issue