Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into dataflowMng

This commit is contained in:
hyeonsu
2025-09-15 20:07:44 +09:00
252 changed files with 33302 additions and 2046 deletions

View File

@@ -5020,6 +5020,10 @@ model screen_layouts {
height Int
properties Json?
display_order Int @default(0)
layout_type String? @db.VarChar(50)
layout_config Json?
zones_config Json?
zone_id String? @db.VarChar(100)
created_date DateTime @default(now()) @db.Timestamp(6)
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
widgets screen_widgets[]
@@ -5255,6 +5259,33 @@ model component_standards {
@@index([category], map: "idx_component_standards_category")
@@index([company_code], map: "idx_component_standards_company")
}
// 레이아웃 표준 관리 테이블
model layout_standards {
layout_code String @id @db.VarChar(50)
layout_name String @db.VarChar(100)
layout_name_eng String? @db.VarChar(100)
description String? @db.Text
layout_type String @db.VarChar(50)
category String @db.VarChar(50)
icon_name String? @db.VarChar(50)
default_size Json? // { width: number, height: number }
layout_config Json // 레이아웃 설정 (그리드, 플렉스박스 등)
zones_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([layout_type], map: "idx_layout_standards_type")
@@index([category], map: "idx_layout_standards_category")
@@index([company_code], map: "idx_layout_standards_company")
}
model table_relationships {
relationship_id Int @id @default(autoincrement())
diagram_id Int // 관계도 그룹 식별자

View File

@@ -0,0 +1,105 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addMissingColumns() {
try {
console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중...");
// layout_type 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50);
`;
console.log("✅ layout_type 컬럼 추가 완료");
} catch (error) {
console.log(
" layout_type 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// layout_config 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS layout_config JSONB;
`;
console.log("✅ layout_config 컬럼 추가 완료");
} catch (error) {
console.log(
" layout_config 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// zones_config 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS zones_config JSONB;
`;
console.log("✅ zones_config 컬럼 추가 완료");
} catch (error) {
console.log(
" zones_config 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// zone_id 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100);
`;
console.log("✅ zone_id 컬럼 추가 완료");
} catch (error) {
console.log(
" zone_id 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// 인덱스 생성 (성능 향상)
try {
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type
ON screen_layouts(layout_type);
`;
console.log("✅ layout_type 인덱스 생성 완료");
} catch (error) {
console.log(" layout_type 인덱스 생성 중 오류:", error.message);
}
try {
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id
ON screen_layouts(zone_id);
`;
console.log("✅ zone_id 인덱스 생성 완료");
} catch (error) {
console.log(" zone_id 인덱스 생성 중 오류:", error.message);
}
// 최종 테이블 구조 확인
const columns = await prisma.$queryRaw`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'screen_layouts'
ORDER BY ordinal_position
`;
console.log("\n📋 screen_layouts 테이블 최종 구조:");
console.table(columns);
console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!");
} catch (error) {
console.error("❌ 컬럼 추가 중 오류 발생:", error);
} finally {
await prisma.$disconnect();
}
}
addMissingColumns();

View File

@@ -0,0 +1,309 @@
/**
* 레이아웃 표준 데이터 초기화 스크립트
* 기본 레이아웃들을 layout_standards 테이블에 삽입합니다.
*/
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// 기본 레이아웃 데이터
const PREDEFINED_LAYOUTS = [
{
layout_code: "GRID_2X2_001",
layout_name: "2x2 그리드",
layout_name_eng: "2x2 Grid",
description: "2행 2열의 균등한 그리드 레이아웃입니다.",
layout_type: "grid",
category: "basic",
icon_name: "grid",
default_size: { width: 800, height: 600 },
layout_config: {
grid: { rows: 2, columns: 2, gap: 16 },
},
zones_config: [
{
id: "zone1",
name: "상단 좌측",
position: { row: 0, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone2",
name: "상단 우측",
position: { row: 0, column: 1 },
size: { width: "50%", height: "50%" },
},
{
id: "zone3",
name: "하단 좌측",
position: { row: 1, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone4",
name: "하단 우측",
position: { row: 1, column: 1 },
size: { width: "50%", height: "50%" },
},
],
sort_order: 1,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "FORM_TWO_COLUMN_001",
layout_name: "2단 폼 레이아웃",
layout_name_eng: "Two Column Form",
description: "좌우 2단으로 구성된 폼 레이아웃입니다.",
layout_type: "grid",
category: "form",
icon_name: "columns",
default_size: { width: 800, height: 400 },
layout_config: {
grid: { rows: 1, columns: 2, gap: 24 },
},
zones_config: [
{
id: "left",
name: "좌측 입력 영역",
position: { row: 0, column: 0 },
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 입력 영역",
position: { row: 0, column: 1 },
size: { width: "50%", height: "100%" },
},
],
sort_order: 2,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "FLEXBOX_ROW_001",
layout_name: "가로 플렉스박스",
layout_name_eng: "Horizontal Flexbox",
description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.",
layout_type: "flexbox",
category: "basic",
icon_name: "flex",
default_size: { width: 800, height: 300 },
layout_config: {
flexbox: {
direction: "row",
justify: "flex-start",
align: "stretch",
wrap: "nowrap",
gap: 16,
},
},
zones_config: [
{
id: "left",
name: "좌측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
],
sort_order: 3,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "SPLIT_HORIZONTAL_001",
layout_name: "수평 분할",
layout_name_eng: "Horizontal Split",
description: "크기 조절이 가능한 수평 분할 레이아웃입니다.",
layout_type: "split",
category: "basic",
icon_name: "separator-horizontal",
default_size: { width: 800, height: 400 },
layout_config: {
split: {
direction: "horizontal",
ratio: [50, 50],
minSize: [200, 200],
resizable: true,
splitterSize: 4,
},
},
zones_config: [
{
id: "left",
name: "좌측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
{
id: "right",
name: "우측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
],
sort_order: 4,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "TABS_HORIZONTAL_001",
layout_name: "수평 탭",
layout_name_eng: "Horizontal Tabs",
description: "상단에 탭이 있는 탭 레이아웃입니다.",
layout_type: "tabs",
category: "navigation",
icon_name: "tabs",
default_size: { width: 800, height: 500 },
layout_config: {
tabs: {
position: "top",
variant: "default",
size: "md",
defaultTab: "tab1",
closable: false,
},
},
zones_config: [
{
id: "tab1",
name: "첫 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab2",
name: "두 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab3",
name: "세 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
],
sort_order: 5,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "TABLE_WITH_FILTERS_001",
layout_name: "필터가 있는 테이블",
layout_name_eng: "Table with Filters",
description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.",
layout_type: "flexbox",
category: "table",
icon_name: "table",
default_size: { width: 1000, height: 600 },
layout_config: {
flexbox: {
direction: "column",
justify: "flex-start",
align: "stretch",
wrap: "nowrap",
gap: 16,
},
},
zones_config: [
{
id: "filters",
name: "검색 필터",
position: {},
size: { width: "100%", height: "auto" },
},
{
id: "table",
name: "데이터 테이블",
position: {},
size: { width: "100%", height: "1fr" },
},
],
sort_order: 6,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
];
async function initializeLayoutStandards() {
try {
console.log("🏗️ 레이아웃 표준 데이터 초기화 시작...");
// 기존 데이터 확인
const existingLayouts = await prisma.layout_standards.count();
if (existingLayouts > 0) {
console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`);
console.log(
"기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)"
);
// 기존 데이터가 있으면 건너뛰기 (안전을 위해)
console.log("💡 기존 데이터를 유지하고 건너뜁니다.");
return;
}
// 데이터 삽입
let insertedCount = 0;
for (const layoutData of PREDEFINED_LAYOUTS) {
try {
await prisma.layout_standards.create({
data: {
...layoutData,
created_date: new Date(),
updated_date: new Date(),
created_by: "SYSTEM",
updated_by: "SYSTEM",
},
});
console.log(`${layoutData.layout_name} 생성 완료`);
insertedCount++;
} catch (error) {
console.error(`${layoutData.layout_name} 생성 실패:`, error.message);
}
}
console.log(
`🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})`
);
} catch (error) {
console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error);
throw error;
}
}
// 스크립트 실행
if (require.main === module) {
initializeLayoutStandards()
.then(() => {
console.log("✨ 스크립트 실행 완료");
process.exit(0);
})
.catch((error) => {
console.error("💥 스크립트 실행 실패:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
}
module.exports = { initializeLayoutStandards };

View File

@@ -0,0 +1,46 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function getComponents() {
try {
const components = await prisma.component_standards.findMany({
where: { is_active: "Y" },
select: {
component_code: true,
component_name: true,
category: true,
component_config: true,
},
orderBy: [{ category: "asc" }, { sort_order: "asc" }],
});
console.log("📋 데이터베이스 컴포넌트 목록:");
console.log("=".repeat(60));
const grouped = components.reduce((acc, comp) => {
if (!acc[comp.category]) {
acc[comp.category] = [];
}
acc[comp.category].push(comp);
return acc;
}, {});
Object.entries(grouped).forEach(([category, comps]) => {
console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`);
comps.forEach((comp) => {
const type = comp.component_config?.type || "unknown";
console.log(
` - ${comp.component_code}: ${comp.component_name} (type: ${type})`
);
});
});
console.log(`\n${components.length}개 컴포넌트 발견`);
} catch (error) {
console.error("Error:", error);
} finally {
await prisma.$disconnect();
}
}
getComponents();

View File

@@ -26,6 +26,8 @@ import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
import screenStandardRoutes from "./routes/screenStandardRoutes";
import templateStandardRoutes from "./routes/templateStandardRoutes";
import componentStandardRoutes from "./routes/componentStandardRoutes";
import layoutRoutes from "./routes/layoutRoutes";
import dataRoutes from "./routes/dataRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@@ -114,7 +116,9 @@ 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/layouts", layoutRoutes);
app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@@ -204,7 +204,15 @@ class ComponentStandardController {
});
return;
} catch (error) {
console.error("컴포넌트 수정 실패:", error);
const { component_code } = req.params;
const updateData = req.body;
console.error("컴포넌트 수정 실패 [상세]:", {
component_code,
updateData,
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined,
});
res.status(400).json({
success: false,
message: "컴포넌트 수정에 실패했습니다.",
@@ -382,6 +390,52 @@ class ComponentStandardController {
return;
}
}
/**
* 컴포넌트 코드 중복 체크
*/
async checkDuplicate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { component_code } = req.params;
if (!component_code) {
res.status(400).json({
success: false,
message: "컴포넌트 코드가 필요합니다.",
});
return;
}
const isDuplicate = await componentStandardService.checkDuplicate(
component_code,
req.user?.companyCode
);
console.log(
`🔍 중복 체크 결과: component_code=${component_code}, company_code=${req.user?.companyCode}, isDuplicate=${isDuplicate}`
);
res.status(200).json({
success: true,
data: { isDuplicate, component_code },
message: isDuplicate
? "이미 사용 중인 컴포넌트 코드입니다."
: "사용 가능한 컴포넌트 코드입니다.",
});
return;
} catch (error) {
console.error("컴포넌트 코드 중복 체크 실패:", error);
res.status(500).json({
success: false,
message: "컴포넌트 코드 중복 체크에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
return;
}
}
}
export default new ComponentStandardController();

View File

@@ -0,0 +1,276 @@
import { Request, Response } from "express";
import { layoutService } from "../services/layoutService";
import {
CreateLayoutRequest,
UpdateLayoutRequest,
GetLayoutsRequest,
DuplicateLayoutRequest,
} from "../types/layout";
export class LayoutController {
/**
* 레이아웃 목록 조회
*/
async getLayouts(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const {
page = 1,
size = 20,
category,
layoutType,
searchTerm,
includePublic = true,
} = req.query as any;
const params = {
page: parseInt(page, 10),
size: parseInt(size, 10),
category,
layoutType,
searchTerm,
companyCode: user.companyCode,
includePublic: includePublic === "true",
};
const result = await layoutService.getLayouts(params);
const response = {
...result,
page: params.page,
size: params.size,
totalPages: Math.ceil(result.total / params.size),
};
res.json({
success: true,
data: response,
});
} catch (error) {
console.error("레이아웃 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 레이아웃 상세 조회
*/
async getLayoutById(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
const layout = await layoutService.getLayoutById(
layoutCode,
user.companyCode
);
if (!layout) {
res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
return;
}
res.json({
success: true,
data: layout,
});
} catch (error) {
console.error("레이아웃 상세 조회 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 상세 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 레이아웃 생성
*/
async createLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const layoutRequest: CreateLayoutRequest = req.body;
// 요청 데이터 검증
if (
!layoutRequest.layoutName ||
!layoutRequest.layoutType ||
!layoutRequest.category
) {
res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (layoutName, layoutType, category)",
});
return;
}
if (!layoutRequest.layoutConfig || !layoutRequest.zonesConfig) {
res.status(400).json({
success: false,
message: "레이아웃 설정과 존 설정은 필수입니다.",
});
return;
}
const layout = await layoutService.createLayout(
layoutRequest,
user.companyCode,
user.userId
);
res.status(201).json({
success: true,
data: layout,
message: "레이아웃이 성공적으로 생성되었습니다.",
});
} catch (error) {
console.error("레이아웃 생성 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 생성에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 레이아웃 수정
*/
async updateLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
const updateRequest: Partial<CreateLayoutRequest> = req.body;
const updatedLayout = await layoutService.updateLayout(
{ ...updateRequest, layoutCode },
user.companyCode,
user.userId
);
if (!updatedLayout) {
res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없거나 수정 권한이 없습니다.",
});
return;
}
res.json({
success: true,
data: updatedLayout,
message: "레이아웃이 성공적으로 수정되었습니다.",
});
} catch (error) {
console.error("레이아웃 수정 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 수정에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 레이아웃 삭제
*/
async deleteLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
await layoutService.deleteLayout(
layoutCode,
user.companyCode,
user.userId
);
res.json({
success: true,
message: "레이아웃이 성공적으로 삭제되었습니다.",
});
} catch (error) {
console.error("레이아웃 삭제 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 삭제에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 레이아웃 복제
*/
async duplicateLayout(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const { id: layoutCode } = req.params;
const { newName }: DuplicateLayoutRequest = req.body;
if (!newName) {
res.status(400).json({
success: false,
message: "새 레이아웃 이름이 필요합니다.",
});
return;
}
const duplicatedLayout = await layoutService.duplicateLayout(
layoutCode,
newName,
user.companyCode,
user.userId
);
res.status(201).json({
success: true,
data: duplicatedLayout,
message: "레이아웃이 성공적으로 복제되었습니다.",
});
} catch (error) {
console.error("레이아웃 복제 오류:", error);
res.status(500).json({
success: false,
message: "레이아웃 복제에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 카테고리별 레이아웃 개수 조회
*/
async getLayoutCountsByCategory(req: Request, res: Response): Promise<void> {
try {
const { user } = req as any;
const counts = await layoutService.getLayoutCountsByCategory(
user.companyCode
);
res.json({
success: true,
data: counts,
});
} catch (error) {
console.error("카테고리별 레이아웃 개수 조회 오류:", error);
res.status(500).json({
success: false,
message: "카테고리별 레이아웃 개수 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
export const layoutController = new LayoutController();

View File

@@ -1,8 +1,14 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { Request, Response } from "express";
import { templateStandardService } from "../services/templateStandardService";
import { handleError } from "../utils/errorHandler";
import { checkMissingFields } from "../utils/validation";
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
companyCode: string;
company_code?: string;
[key: string]: any;
};
}
/**
* 템플릿 표준 관리 컨트롤러
@@ -11,26 +17,26 @@ export class TemplateStandardController {
/**
* 템플릿 목록 조회
*/
async getTemplates(req: AuthenticatedRequest, res: Response) {
async getTemplates(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const {
active = "Y",
category,
search,
companyCode,
company_code,
is_public = "Y",
page = "1",
limit = "50",
} = req.query;
const user = req.user;
const userCompanyCode = user?.companyCode || "DEFAULT";
const userCompanyCode = user?.company_code || "DEFAULT";
const result = await templateStandardService.getTemplates({
active: active as string,
category: category as string,
search: search as string,
company_code: (companyCode as string) || userCompanyCode,
company_code: (company_code as string) || userCompanyCode,
is_public: is_public as string,
page: parseInt(page as string),
limit: parseInt(limit as string),
@@ -47,23 +53,24 @@ export class TemplateStandardController {
},
});
} catch (error) {
return handleError(
res,
error,
"템플릿 목록 조회 중 오류가 발생했습니다."
);
console.error("템플릿 목록 조회 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 상세 조회
*/
async getTemplate(req: AuthenticatedRequest, res: Response) {
async getTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { templateCode } = req.params;
if (!templateCode) {
return res.status(400).json({
res.status(400).json({
success: false,
error: "템플릿 코드가 필요합니다.",
});
@@ -72,7 +79,7 @@ export class TemplateStandardController {
const template = await templateStandardService.getTemplate(templateCode);
if (!template) {
return res.status(404).json({
res.status(404).json({
success: false,
error: "템플릿을 찾을 수 없습니다.",
});
@@ -83,40 +90,46 @@ export class TemplateStandardController {
data: template,
});
} catch (error) {
return handleError(res, error, "템플릿 조회 중 오류가 발생했습니다.");
console.error("템플릿 조회 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 생성
*/
async createTemplate(req: AuthenticatedRequest, res: Response) {
async createTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const user = req.user;
const templateData = req.body;
// 필수 필드 검증
const requiredFields = [
"template_code",
"template_name",
"category",
"layout_config",
];
const missingFields = checkMissingFields(templateData, requiredFields);
if (missingFields.length > 0) {
return res.status(400).json({
if (
!templateData.template_code ||
!templateData.template_name ||
!templateData.category ||
!templateData.layout_config
) {
res.status(400).json({
success: false,
error: `필수 필드가 누락되었습니다: ${missingFields.join(", ")}`,
message:
"필수 필드가 누락되었습니다. (template_code, template_name, category, layout_config)",
});
}
// 회사 코드와 생성자 정보 추가
const templateWithMeta = {
...templateData,
company_code: user?.companyCode || "DEFAULT",
created_by: user?.userId || "system",
updated_by: user?.userId || "system",
company_code: user?.company_code || "DEFAULT",
created_by: user?.user_id || "system",
updated_by: user?.user_id || "system",
};
const newTemplate =
@@ -128,21 +141,29 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 생성되었습니다.",
});
} catch (error) {
return handleError(res, error, "템플릿 생성 중 오류가 발생했습니다.");
console.error("템플릿 생성 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 수정
*/
async updateTemplate(req: AuthenticatedRequest, res: Response) {
async updateTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { templateCode } = req.params;
const templateData = req.body;
const user = req.user;
if (!templateCode) {
return res.status(400).json({
res.status(400).json({
success: false,
error: "템플릿 코드가 필요합니다.",
});
@@ -151,7 +172,7 @@ export class TemplateStandardController {
// 수정자 정보 추가
const templateWithMeta = {
...templateData,
updated_by: user?.userId || "system",
updated_by: user?.user_id || "system",
};
const updatedTemplate = await templateStandardService.updateTemplate(
@@ -160,7 +181,7 @@ export class TemplateStandardController {
);
if (!updatedTemplate) {
return res.status(404).json({
res.status(404).json({
success: false,
error: "템플릿을 찾을 수 없습니다.",
});
@@ -172,19 +193,27 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 수정되었습니다.",
});
} catch (error) {
return handleError(res, error, "템플릿 수정 중 오류가 발생했습니다.");
console.error("템플릿 수정 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 삭제
*/
async deleteTemplate(req: AuthenticatedRequest, res: Response) {
async deleteTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { templateCode } = req.params;
if (!templateCode) {
return res.status(400).json({
res.status(400).json({
success: false,
error: "템플릿 코드가 필요합니다.",
});
@@ -194,7 +223,7 @@ export class TemplateStandardController {
await templateStandardService.deleteTemplate(templateCode);
if (!deleted) {
return res.status(404).json({
res.status(404).json({
success: false,
error: "템플릿을 찾을 수 없습니다.",
});
@@ -205,19 +234,27 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 삭제되었습니다.",
});
} catch (error) {
return handleError(res, error, "템플릿 삭제 중 오류가 발생했습니다.");
console.error("템플릿 삭제 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 정렬 순서 일괄 업데이트
*/
async updateSortOrder(req: AuthenticatedRequest, res: Response) {
async updateSortOrder(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { templates } = req.body;
if (!Array.isArray(templates)) {
return res.status(400).json({
res.status(400).json({
success: false,
error: "templates는 배열이어야 합니다.",
});
@@ -230,25 +267,29 @@ export class TemplateStandardController {
message: "템플릿 정렬 순서가 성공적으로 업데이트되었습니다.",
});
} catch (error) {
return handleError(
res,
error,
"템플릿 정렬 순서 업데이트 중 오류가 발생했습니다."
);
console.error("템플릿 정렬 순서 업데이트 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 정렬 순서 업데이트 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 복제
*/
async duplicateTemplate(req: AuthenticatedRequest, res: Response) {
async duplicateTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { templateCode } = req.params;
const { new_template_code, new_template_name } = req.body;
const user = req.user;
if (!templateCode || !new_template_code || !new_template_name) {
return res.status(400).json({
res.status(400).json({
success: false,
error: "필수 필드가 누락되었습니다.",
});
@@ -259,8 +300,8 @@ export class TemplateStandardController {
originalCode: templateCode,
newCode: new_template_code,
newName: new_template_name,
company_code: user?.companyCode || "DEFAULT",
created_by: user?.userId || "system",
company_code: user?.company_code || "DEFAULT",
created_by: user?.user_id || "system",
});
res.status(201).json({
@@ -269,17 +310,22 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 복제되었습니다.",
});
} catch (error) {
return handleError(res, error, "템플릿 복제 중 오류가 발생했습니다.");
console.error("템플릿 복제 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 복제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 카테고리 목록 조회
*/
async getCategories(req: AuthenticatedRequest, res: Response) {
async getCategories(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const user = req.user;
const companyCode = user?.companyCode || "DEFAULT";
const companyCode = user?.company_code || "DEFAULT";
const categories =
await templateStandardService.getCategories(companyCode);
@@ -289,24 +335,28 @@ export class TemplateStandardController {
data: categories,
});
} catch (error) {
return handleError(
res,
error,
"템플릿 카테고리 조회 중 오류가 발생했습니다."
);
console.error("템플릿 카테고리 조회 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 카테고리 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 가져오기 (JSON 파일에서)
*/
async importTemplate(req: AuthenticatedRequest, res: Response) {
async importTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const user = req.user;
const templateData = req.body;
if (!templateData.layout_config) {
return res.status(400).json({
res.status(400).json({
success: false,
error: "유효한 템플릿 데이터가 아닙니다.",
});
@@ -315,9 +365,9 @@ export class TemplateStandardController {
// 회사 코드와 생성자 정보 추가
const templateWithMeta = {
...templateData,
company_code: user?.companyCode || "DEFAULT",
created_by: user?.userId || "system",
updated_by: user?.userId || "system",
company_code: user?.company_code || "DEFAULT",
created_by: user?.user_id || "system",
updated_by: user?.user_id || "system",
};
const importedTemplate =
@@ -329,31 +379,41 @@ export class TemplateStandardController {
message: "템플릿이 성공적으로 가져왔습니다.",
});
} catch (error) {
return handleError(res, error, "템플릿 가져오기 중 오류가 발생했습니다.");
console.error("템플릿 가져오기 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 가져오기 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 템플릿 내보내기 (JSON 형태로)
*/
async exportTemplate(req: AuthenticatedRequest, res: Response) {
async exportTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { templateCode } = req.params;
if (!templateCode) {
return res.status(400).json({
res.status(400).json({
success: false,
error: "템플릿 코드가 필요합니다.",
});
return;
}
const template = await templateStandardService.getTemplate(templateCode);
if (!template) {
return res.status(404).json({
res.status(404).json({
success: false,
error: "템플릿을 찾을 수 없습니다.",
});
return;
}
// 내보내기용 데이터 (메타데이터 제외)
@@ -373,7 +433,12 @@ export class TemplateStandardController {
data: exportData,
});
} catch (error) {
return handleError(res, error, "템플릿 내보내기 중 오류가 발생했습니다.");
console.error("템플릿 내보내기 중 오류:", error);
res.status(500).json({
success: false,
message: "템플릿 내보내기 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}

View File

@@ -25,6 +25,12 @@ router.get(
componentStandardController.getStatistics.bind(componentStandardController)
);
// 컴포넌트 코드 중복 체크
router.get(
"/check-duplicate/:component_code",
componentStandardController.checkDuplicate.bind(componentStandardController)
);
// 컴포넌트 상세 조회
router.get(
"/:component_code",

View File

@@ -0,0 +1,130 @@
import express from "express";
import { dataService } from "../services/dataService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
const router = express.Router();
/**
* 동적 테이블 데이터 조회 API
* GET /api/data/{tableName}
*/
router.get(
"/:tableName",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName } = req.params;
const { limit = "10", offset = "0", orderBy, ...filters } = req.query;
// 입력값 검증
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
error: "INVALID_TABLE_NAME",
});
}
// SQL 인젝션 방지를 위한 테이블명 검증
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
error: "INVALID_TABLE_NAME",
});
}
console.log(`📊 데이터 조회 요청: ${tableName}`, {
limit: parseInt(limit as string),
offset: parseInt(offset as string),
orderBy: orderBy as string,
filters,
user: req.user?.userId,
});
// 데이터 조회
const result = await dataService.getTableData({
tableName,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
orderBy: orderBy as string,
filters: filters as Record<string, string>,
userCompany: req.user?.companyCode,
});
if (!result.success) {
return res.status(400).json(result);
}
console.log(
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`
);
return res.json(result.data);
} catch (error) {
console.error("데이터 조회 오류:", error);
return res.status(500).json({
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
);
/**
* 테이블 컬럼 정보 조회 API
* GET /api/data/{tableName}/columns
*/
router.get(
"/:tableName/columns",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName } = req.params;
// 입력값 검증
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
error: "INVALID_TABLE_NAME",
});
}
// SQL 인젝션 방지를 위한 테이블명 검증
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
error: "INVALID_TABLE_NAME",
});
}
console.log(`📋 컬럼 정보 조회: ${tableName}`);
// 컬럼 정보 조회
const result = await dataService.getTableColumns(tableName);
if (!result.success) {
return res.status(400).json(result);
}
console.log(
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`
);
return res.json(result);
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
return res.status(500).json({
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
);
export default router;

View File

@@ -0,0 +1,73 @@
import { Router } from "express";
import { layoutController } from "../controllers/layoutController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 레이아웃 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
* @route GET /api/layouts
* @desc 레이아웃 목록 조회
* @access Private
* @params page, size, category, layoutType, searchTerm, includePublic
*/
router.get("/", layoutController.getLayouts.bind(layoutController));
/**
* @route GET /api/layouts/counts-by-category
* @desc 카테고리별 레이아웃 개수 조회
* @access Private
*/
router.get(
"/counts-by-category",
layoutController.getLayoutCountsByCategory.bind(layoutController)
);
/**
* @route GET /api/layouts/:id
* @desc 레이아웃 상세 조회
* @access Private
* @params id (layoutCode)
*/
router.get("/:id", layoutController.getLayoutById.bind(layoutController));
/**
* @route POST /api/layouts
* @desc 레이아웃 생성
* @access Private
* @body CreateLayoutRequest
*/
router.post("/", layoutController.createLayout.bind(layoutController));
/**
* @route PUT /api/layouts/:id
* @desc 레이아웃 수정
* @access Private
* @params id (layoutCode)
* @body Partial<CreateLayoutRequest>
*/
router.put("/:id", layoutController.updateLayout.bind(layoutController));
/**
* @route DELETE /api/layouts/:id
* @desc 레이아웃 삭제
* @access Private
* @params id (layoutCode)
*/
router.delete("/:id", layoutController.deleteLayout.bind(layoutController));
/**
* @route POST /api/layouts/:id/duplicate
* @desc 레이아웃 복제
* @access Private
* @params id (layoutCode)
* @body { newName: string }
*/
router.post(
"/:id/duplicate",
layoutController.duplicateLayout.bind(layoutController)
);
export default router;

View File

@@ -131,9 +131,16 @@ class ComponentStandardService {
);
}
// 'active' 필드를 'is_active'로 변환
const createData = { ...data };
if ("active" in createData) {
createData.is_active = (createData as any).active;
delete (createData as any).active;
}
const component = await prisma.component_standards.create({
data: {
...data,
...createData,
created_date: new Date(),
updated_date: new Date(),
},
@@ -151,10 +158,17 @@ class ComponentStandardService {
) {
const existing = await this.getComponent(component_code);
// 'active' 필드를 'is_active'로 변환
const updateData = { ...data };
if ("active" in updateData) {
updateData.is_active = (updateData as any).active;
delete (updateData as any).active;
}
const component = await prisma.component_standards.update({
where: { component_code },
data: {
...data,
...updateData,
updated_date: new Date(),
},
});
@@ -216,21 +230,19 @@ class ComponentStandardService {
data: {
component_code: new_code,
component_name: new_name,
component_name_eng: source.component_name_eng,
description: source.description,
category: source.category,
icon_name: source.icon_name,
default_size: source.default_size as any,
component_config: source.component_config as any,
preview_image: source.preview_image,
sort_order: source.sort_order,
is_active: source.is_active,
is_public: source.is_public,
company_code: source.company_code,
component_name_eng: source?.component_name_eng,
description: source?.description,
category: source?.category,
icon_name: source?.icon_name,
default_size: source?.default_size as any,
component_config: source?.component_config as any,
preview_image: source?.preview_image,
sort_order: source?.sort_order,
is_active: source?.is_active,
is_public: source?.is_public,
company_code: source?.company_code || "DEFAULT",
created_date: new Date(),
created_by: source.created_by,
updated_date: new Date(),
updated_by: source.updated_by,
},
});
@@ -297,6 +309,27 @@ class ComponentStandardService {
})),
};
}
/**
* 컴포넌트 코드 중복 체크
*/
async checkDuplicate(
component_code: string,
company_code?: string
): Promise<boolean> {
const whereClause: any = { component_code };
// 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가
if (company_code && company_code !== "*") {
whereClause.company_code = company_code;
}
const existingComponent = await prisma.component_standards.findFirst({
where: whereClause,
});
return !!existingComponent;
}
}
export default new ComponentStandardService();

View File

@@ -0,0 +1,328 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
interface GetTableDataParams {
tableName: string;
limit?: number;
offset?: number;
orderBy?: string;
filters?: Record<string, string>;
userCompany?: string;
}
interface ServiceResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
/**
* 안전한 테이블명 목록 (화이트리스트)
* SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능
*/
const ALLOWED_TABLES = [
"company_mng",
"user_info",
"dept_info",
"code_info",
"code_category",
"menu_info",
"approval",
"approval_kind",
"board",
"comm_code",
"product_mng",
"part_mng",
"material_mng",
"order_mng_master",
"inventory_mng",
"contract_mgmt",
"project_mgmt",
"screen_definitions",
"screen_layouts",
"layout_standards",
"component_standards",
"web_type_standards",
"button_action_standards",
"template_standards",
"grid_standards",
"style_templates",
"multi_lang_key_master",
"multi_lang_text",
"language_master",
"table_labels",
"column_labels",
"dynamic_form_data",
];
/**
* 회사별 필터링이 필요한 테이블 목록
*/
const COMPANY_FILTERED_TABLES = [
"company_mng",
"user_info",
"dept_info",
"approval",
"board",
"product_mng",
"part_mng",
"material_mng",
"order_mng_master",
"inventory_mng",
"contract_mgmt",
"project_mgmt",
];
class DataService {
/**
* 테이블 데이터 조회
*/
async getTableData(
params: GetTableDataParams
): Promise<ServiceResponse<any[]>> {
const {
tableName,
limit = 10,
offset = 0,
orderBy,
filters = {},
userCompany,
} = params;
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
}
// 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
return {
success: false,
message: `테이블을 찾을 수 없습니다: ${tableName}`,
error: "TABLE_NOT_FOUND",
};
}
// 동적 SQL 쿼리 생성
let query = `SELECT * FROM "${tableName}"`;
const queryParams: any[] = [];
let paramIndex = 1;
// WHERE 조건 생성
const whereConditions: string[] = [];
// 회사별 필터링 추가
if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) {
// 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용
if (userCompany !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(userCompany);
paramIndex++;
}
}
// 사용자 정의 필터 추가
for (const [key, value] of Object.entries(filters)) {
if (
value &&
key !== "limit" &&
key !== "offset" &&
key !== "orderBy" &&
key !== "userLang"
) {
// 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
continue; // 유효하지 않은 컬럼명은 무시
}
whereConditions.push(`"${key}" ILIKE $${paramIndex}`);
queryParams.push(`%${value}%`);
paramIndex++;
}
}
// WHERE 절 추가
if (whereConditions.length > 0) {
query += ` WHERE ${whereConditions.join(" AND ")}`;
}
// ORDER BY 절 추가
if (orderBy) {
// ORDER BY 검증 (SQL 인젝션 방지)
const orderParts = orderBy.split(" ");
const columnName = orderParts[0];
const direction = orderParts[1]?.toUpperCase();
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
const validDirection = direction === "DESC" ? "DESC" : "ASC";
query += ` ORDER BY "${columnName}" ${validDirection}`;
}
} else {
// 기본 정렬: 최신순 (가능한 컬럼 시도)
const dateColumns = [
"created_date",
"regdate",
"reg_date",
"updated_date",
"upd_date",
];
const tableColumns = await this.getTableColumnsSimple(tableName);
const availableDateColumn = dateColumns.find((col) =>
tableColumns.some((tableCol) => tableCol.column_name === col)
);
if (availableDateColumn) {
query += ` ORDER BY "${availableDateColumn}" DESC`;
}
}
// LIMIT과 OFFSET 추가
query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
queryParams.push(limit, offset);
console.log("🔍 실행할 쿼리:", query);
console.log("📊 쿼리 파라미터:", queryParams);
// 쿼리 실행
const result = await prisma.$queryRawUnsafe(query, ...queryParams);
return {
success: true,
data: result as any[],
};
} catch (error) {
console.error(`데이터 조회 오류 (${tableName}):`, error);
return {
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 테이블 컬럼 정보 조회
*/
async getTableColumns(tableName: string): Promise<ServiceResponse<any[]>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
}
const columns = await this.getTableColumnsSimple(tableName);
// 컬럼 라벨 정보 추가
const columnsWithLabels = await Promise.all(
columns.map(async (column) => {
const label = await this.getColumnLabel(
tableName,
column.column_name
);
return {
columnName: column.column_name,
columnLabel: label || column.column_name,
dataType: column.data_type,
isNullable: column.is_nullable === "YES",
defaultValue: column.column_default,
};
})
);
return {
success: true,
data: columnsWithLabels,
};
} catch (error) {
console.error(`컬럼 정보 조회 오류 (${tableName}):`, error);
return {
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 테이블 존재 여부 확인
*/
private async checkTableExists(tableName: string): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
);
`,
tableName
);
return (result as any)[0]?.exists || false;
} catch (error) {
console.error("테이블 존재 확인 오류:", error);
return false;
}
}
/**
* 테이블 컬럼 정보 조회 (간단 버전)
*/
private async getTableColumnsSimple(tableName: string): Promise<any[]> {
const result = await prisma.$queryRawUnsafe(
`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position;
`,
tableName
);
return result as any[];
}
/**
* 컬럼 라벨 조회
*/
private async getColumnLabel(
tableName: string,
columnName: string
): Promise<string | null> {
try {
// column_labels 테이블에서 라벨 조회
const result = await prisma.$queryRawUnsafe(
`
SELECT label_ko
FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1;
`,
tableName,
columnName
);
const labelResult = result as any[];
return labelResult[0]?.label_ko || null;
} catch (error) {
// column_labels 테이블이 없거나 오류가 발생하면 null 반환
return null;
}
}
}
export const dataService = new DataService();

View File

@@ -0,0 +1,425 @@
import { PrismaClient } from "@prisma/client";
import {
CreateLayoutRequest,
UpdateLayoutRequest,
LayoutStandard,
LayoutType,
LayoutCategory,
} from "../types/layout";
const prisma = new PrismaClient();
// JSON 데이터를 안전하게 파싱하는 헬퍼 함수
function safeJSONParse(data: any): any {
if (data === null || data === undefined) {
return null;
}
// 이미 객체인 경우 그대로 반환
if (typeof data === "object") {
return data;
}
// 문자열인 경우 파싱 시도
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (error) {
console.error("JSON 파싱 오류:", error, "Data:", data);
return null;
}
}
return data;
}
// JSON 데이터를 안전하게 문자열화하는 헬퍼 함수
function safeJSONStringify(data: any): string | null {
if (data === null || data === undefined) {
return null;
}
// 이미 문자열인 경우 그대로 반환
if (typeof data === "string") {
return data;
}
// 객체인 경우 문자열로 변환
try {
return JSON.stringify(data);
} catch (error) {
console.error("JSON 문자열화 오류:", error, "Data:", data);
return null;
}
}
export class LayoutService {
/**
* 레이아웃 목록 조회
*/
async getLayouts(params: {
page?: number;
size?: number;
category?: string;
layoutType?: string;
searchTerm?: string;
companyCode: string;
includePublic?: boolean;
}): Promise<{ data: LayoutStandard[]; total: number }> {
const {
page = 1,
size = 20,
category,
layoutType,
searchTerm,
companyCode,
includePublic = true,
} = params;
const skip = (page - 1) * size;
// 검색 조건 구성
const where: any = {
is_active: "Y",
OR: [
{ company_code: companyCode },
...(includePublic ? [{ is_public: "Y" }] : []),
],
};
if (category) {
where.category = category;
}
if (layoutType) {
where.layout_type = layoutType;
}
if (searchTerm) {
where.OR = [
...where.OR,
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
{ layout_name_eng: { contains: searchTerm, mode: "insensitive" } },
{ description: { contains: searchTerm, mode: "insensitive" } },
];
}
const [data, total] = await Promise.all([
prisma.layout_standards.findMany({
where,
skip,
take: size,
orderBy: [{ sort_order: "asc" }, { created_date: "desc" }],
}),
prisma.layout_standards.count({ where }),
]);
return {
data: data.map(
(layout) =>
({
layoutCode: layout.layout_code,
layoutName: layout.layout_name,
layoutNameEng: layout.layout_name_eng,
description: layout.description,
layoutType: layout.layout_type as LayoutType,
category: layout.category as LayoutCategory,
iconName: layout.icon_name,
defaultSize: safeJSONParse(layout.default_size),
layoutConfig: safeJSONParse(layout.layout_config),
zonesConfig: safeJSONParse(layout.zones_config),
previewImage: layout.preview_image,
sortOrder: layout.sort_order,
isActive: layout.is_active,
isPublic: layout.is_public,
companyCode: layout.company_code,
createdDate: layout.created_date,
createdBy: layout.created_by,
updatedDate: layout.updated_date,
updatedBy: layout.updated_by,
}) as LayoutStandard
),
total,
};
}
/**
* 레이아웃 상세 조회
*/
async getLayoutById(
layoutCode: string,
companyCode: string
): Promise<LayoutStandard | null> {
const layout = await prisma.layout_standards.findFirst({
where: {
layout_code: layoutCode,
is_active: "Y",
OR: [{ company_code: companyCode }, { is_public: "Y" }],
},
});
if (!layout) return null;
return {
layoutCode: layout.layout_code,
layoutName: layout.layout_name,
layoutNameEng: layout.layout_name_eng,
description: layout.description,
layoutType: layout.layout_type as LayoutType,
category: layout.category as LayoutCategory,
iconName: layout.icon_name,
defaultSize: safeJSONParse(layout.default_size),
layoutConfig: safeJSONParse(layout.layout_config),
zonesConfig: safeJSONParse(layout.zones_config),
previewImage: layout.preview_image,
sortOrder: layout.sort_order,
isActive: layout.is_active,
isPublic: layout.is_public,
companyCode: layout.company_code,
createdDate: layout.created_date,
createdBy: layout.created_by,
updatedDate: layout.updated_date,
updatedBy: layout.updated_by,
} as LayoutStandard;
}
/**
* 레이아웃 생성
*/
async createLayout(
request: CreateLayoutRequest,
companyCode: string,
userId: string
): Promise<LayoutStandard> {
// 레이아웃 코드 생성 (자동)
const layoutCode = await this.generateLayoutCode(
request.layoutType,
companyCode
);
const layout = await prisma.layout_standards.create({
data: {
layout_code: layoutCode,
layout_name: request.layoutName,
layout_name_eng: request.layoutNameEng,
description: request.description,
layout_type: request.layoutType,
category: request.category,
icon_name: request.iconName,
default_size: safeJSONStringify(request.defaultSize) as any,
layout_config: safeJSONStringify(request.layoutConfig) as any,
zones_config: safeJSONStringify(request.zonesConfig) as any,
is_public: request.isPublic ? "Y" : "N",
company_code: companyCode,
created_by: userId,
updated_by: userId,
},
});
return this.mapToLayoutStandard(layout);
}
/**
* 레이아웃 수정
*/
async updateLayout(
request: UpdateLayoutRequest,
companyCode: string,
userId: string
): Promise<LayoutStandard | null> {
// 수정 권한 확인
const existing = await prisma.layout_standards.findFirst({
where: {
layout_code: request.layoutCode,
company_code: companyCode,
is_active: "Y",
},
});
if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다.");
}
const updateData: any = {
updated_by: userId,
updated_date: new Date(),
};
// 수정할 필드만 업데이트
if (request.layoutName !== undefined)
updateData.layout_name = request.layoutName;
if (request.layoutNameEng !== undefined)
updateData.layout_name_eng = request.layoutNameEng;
if (request.description !== undefined)
updateData.description = request.description;
if (request.layoutType !== undefined)
updateData.layout_type = request.layoutType;
if (request.category !== undefined) updateData.category = request.category;
if (request.iconName !== undefined) updateData.icon_name = request.iconName;
if (request.defaultSize !== undefined)
updateData.default_size = safeJSONStringify(request.defaultSize) as any;
if (request.layoutConfig !== undefined)
updateData.layout_config = safeJSONStringify(request.layoutConfig) as any;
if (request.zonesConfig !== undefined)
updateData.zones_config = safeJSONStringify(request.zonesConfig) as any;
if (request.isPublic !== undefined)
updateData.is_public = request.isPublic ? "Y" : "N";
const updated = await prisma.layout_standards.update({
where: { layout_code: request.layoutCode },
data: updateData,
});
return this.mapToLayoutStandard(updated);
}
/**
* 레이아웃 삭제 (소프트 삭제)
*/
async deleteLayout(
layoutCode: string,
companyCode: string,
userId: string
): Promise<boolean> {
const existing = await prisma.layout_standards.findFirst({
where: {
layout_code: layoutCode,
company_code: companyCode,
is_active: "Y",
},
});
if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다.");
}
await prisma.layout_standards.update({
where: { layout_code: layoutCode },
data: {
is_active: "N",
updated_by: userId,
updated_date: new Date(),
},
});
return true;
}
/**
* 레이아웃 복제
*/
async duplicateLayout(
layoutCode: string,
newName: string,
companyCode: string,
userId: string
): Promise<LayoutStandard> {
const original = await this.getLayoutById(layoutCode, companyCode);
if (!original) {
throw new Error("복제할 레이아웃을 찾을 수 없습니다.");
}
const duplicateRequest: CreateLayoutRequest = {
layoutName: newName,
layoutNameEng: original.layoutNameEng
? `${original.layoutNameEng} Copy`
: undefined,
description: original.description,
layoutType: original.layoutType,
category: original.category,
iconName: original.iconName,
defaultSize: original.defaultSize,
layoutConfig: original.layoutConfig,
zonesConfig: original.zonesConfig,
isPublic: false, // 복제본은 비공개로 시작
};
return this.createLayout(duplicateRequest, companyCode, userId);
}
/**
* 카테고리별 레이아웃 개수 조회
*/
async getLayoutCountsByCategory(
companyCode: string
): Promise<Record<string, number>> {
const counts = await prisma.layout_standards.groupBy({
by: ["category"],
_count: {
layout_code: true,
},
where: {
is_active: "Y",
OR: [{ company_code: companyCode }, { is_public: "Y" }],
},
});
return counts.reduce(
(acc: Record<string, number>, item: any) => {
acc[item.category] = item._count.layout_code;
return acc;
},
{} as Record<string, number>
);
}
/**
* 레이아웃 코드 자동 생성
*/
private async generateLayoutCode(
layoutType: string,
companyCode: string
): Promise<string> {
const prefix = `${layoutType.toUpperCase()}_${companyCode}`;
const existingCodes = await prisma.layout_standards.findMany({
where: {
layout_code: {
startsWith: prefix,
},
},
select: {
layout_code: true,
},
});
const maxNumber = existingCodes.reduce((max: number, item: any) => {
const match = item.layout_code.match(/_(\d+)$/);
if (match) {
const number = parseInt(match[1], 10);
return Math.max(max, number);
}
return max;
}, 0);
return `${prefix}_${String(maxNumber + 1).padStart(3, "0")}`;
}
/**
* 데이터베이스 모델을 LayoutStandard 타입으로 변환
*/
private mapToLayoutStandard(layout: any): LayoutStandard {
return {
layoutCode: layout.layout_code,
layoutName: layout.layout_name,
layoutNameEng: layout.layout_name_eng,
description: layout.description,
layoutType: layout.layout_type,
category: layout.category,
iconName: layout.icon_name,
defaultSize: layout.default_size,
layoutConfig: layout.layout_config,
zonesConfig: layout.zones_config,
previewImage: layout.preview_image,
sortOrder: layout.sort_order,
isActive: layout.is_active,
isPublic: layout.is_public,
companyCode: layout.company_code,
createdDate: layout.created_date,
createdBy: layout.created_by,
updatedDate: layout.updated_date,
updatedBy: layout.updated_by,
};
}
}
export const layoutService = new LayoutService();

View File

@@ -0,0 +1,198 @@
// 레이아웃 관련 타입 정의
// 레이아웃 타입
export type LayoutType =
| "grid"
| "flexbox"
| "split"
| "card"
| "tabs"
| "accordion"
| "sidebar"
| "header-footer"
| "three-column"
| "dashboard"
| "form"
| "table"
| "custom";
// 레이아웃 카테고리
export type LayoutCategory =
| "basic"
| "form"
| "table"
| "dashboard"
| "navigation"
| "content"
| "business";
// 레이아웃 존 정의
export interface LayoutZone {
id: string;
name: string;
position: {
row?: number;
column?: number;
x?: number;
y?: number;
};
size: {
width: number | string;
height: number | string;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
};
style?: Record<string, any>;
allowedComponents?: string[];
isResizable?: boolean;
isRequired?: boolean;
}
// 레이아웃 설정
export interface LayoutConfig {
grid?: {
rows: number;
columns: number;
gap: number;
rowGap?: number;
columnGap?: number;
autoRows?: string;
autoColumns?: string;
};
flexbox?: {
direction: "row" | "column" | "row-reverse" | "column-reverse";
justify:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly";
align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
wrap: "nowrap" | "wrap" | "wrap-reverse";
gap: number;
};
split?: {
direction: "horizontal" | "vertical";
ratio: number[];
minSize: number[];
resizable: boolean;
splitterSize: number;
};
tabs?: {
position: "top" | "bottom" | "left" | "right";
variant: "default" | "pills" | "underline";
size: "sm" | "md" | "lg";
defaultTab: string;
closable: boolean;
};
accordion?: {
multiple: boolean;
defaultExpanded: string[];
collapsible: boolean;
};
sidebar?: {
position: "left" | "right";
width: number | string;
collapsible: boolean;
collapsed: boolean;
overlay: boolean;
};
headerFooter?: {
headerHeight: number | string;
footerHeight: number | string;
stickyHeader: boolean;
stickyFooter: boolean;
};
dashboard?: {
columns: number;
rowHeight: number;
margin: [number, number];
padding: [number, number];
isDraggable: boolean;
isResizable: boolean;
};
custom?: {
cssProperties: Record<string, string>;
className: string;
template: string;
};
}
// 레이아웃 표준 정의
export interface LayoutStandard {
layoutCode: string;
layoutName: string;
layoutNameEng?: string;
description?: string;
layoutType: LayoutType;
category: LayoutCategory;
iconName?: string;
defaultSize?: { width: number; height: number };
layoutConfig: LayoutConfig;
zonesConfig: LayoutZone[];
previewImage?: string;
sortOrder?: number;
isActive?: string;
isPublic?: string;
companyCode: string;
createdDate?: Date;
createdBy?: string;
updatedDate?: Date;
updatedBy?: string;
}
// 레이아웃 생성 요청
export interface CreateLayoutRequest {
layoutName: string;
layoutNameEng?: string;
description?: string;
layoutType: LayoutType;
category: LayoutCategory;
iconName?: string;
defaultSize?: { width: number; height: number };
layoutConfig: LayoutConfig;
zonesConfig: LayoutZone[];
isPublic?: boolean;
}
// 레이아웃 수정 요청
export interface UpdateLayoutRequest extends Partial<CreateLayoutRequest> {
layoutCode: string;
}
// 레이아웃 목록 조회 요청
export interface GetLayoutsRequest {
page?: number;
size?: number;
category?: LayoutCategory;
layoutType?: LayoutType;
searchTerm?: string;
includePublic?: boolean;
}
// 레이아웃 목록 응답
export interface GetLayoutsResponse {
data: LayoutStandard[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 레이아웃 복제 요청
export interface DuplicateLayoutRequest {
layoutCode: string;
newName: string;
}

View File

@@ -1,69 +0,0 @@
import { Response } from "express";
import { logger } from "./logger";
/**
* 에러 처리 유틸리티
*/
export const handleError = (
res: Response,
error: any,
message: string = "서버 오류가 발생했습니다."
) => {
logger.error(`Error: ${message}`, error);
res.status(500).json({
success: false,
error: {
code: "SERVER_ERROR",
details: message,
},
});
};
/**
* 잘못된 요청 에러 처리
*/
export const handleBadRequest = (
res: Response,
message: string = "잘못된 요청입니다."
) => {
res.status(400).json({
success: false,
error: {
code: "BAD_REQUEST",
details: message,
},
});
};
/**
* 찾을 수 없음 에러 처리
*/
export const handleNotFound = (
res: Response,
message: string = "요청한 리소스를 찾을 수 없습니다."
) => {
res.status(404).json({
success: false,
error: {
code: "NOT_FOUND",
details: message,
},
});
};
/**
* 권한 없음 에러 처리
*/
export const handleUnauthorized = (
res: Response,
message: string = "권한이 없습니다."
) => {
res.status(403).json({
success: false,
error: {
code: "UNAUTHORIZED",
details: message,
},
});
};

View File

@@ -1,101 +0,0 @@
/**
* 유효성 검증 유틸리티
*/
/**
* 필수 값 검증
*/
export const validateRequired = (value: any, fieldName: string): void => {
if (value === null || value === undefined || value === "") {
throw new Error(`${fieldName}은(는) 필수 입력값입니다.`);
}
};
/**
* 여러 필수 값 검증
*/
export const validateRequiredFields = (
data: Record<string, any>,
requiredFields: string[]
): void => {
for (const field of requiredFields) {
validateRequired(data[field], field);
}
};
/**
* 문자열 길이 검증
*/
export const validateStringLength = (
value: string,
fieldName: string,
minLength?: number,
maxLength?: number
): void => {
if (minLength !== undefined && value.length < minLength) {
throw new Error(
`${fieldName}은(는) 최소 ${minLength}자 이상이어야 합니다.`
);
}
if (maxLength !== undefined && value.length > maxLength) {
throw new Error(`${fieldName}은(는) 최대 ${maxLength}자 이하여야 합니다.`);
}
};
/**
* 이메일 형식 검증
*/
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* 숫자 범위 검증
*/
export const validateNumberRange = (
value: number,
fieldName: string,
min?: number,
max?: number
): void => {
if (min !== undefined && value < min) {
throw new Error(`${fieldName}은(는) ${min} 이상이어야 합니다.`);
}
if (max !== undefined && value > max) {
throw new Error(`${fieldName}은(는) ${max} 이하여야 합니다.`);
}
};
/**
* 배열이 비어있지 않은지 검증
*/
export const validateNonEmptyArray = (
array: any[],
fieldName: string
): void => {
if (!Array.isArray(array) || array.length === 0) {
throw new Error(`${fieldName}은(는) 비어있을 수 없습니다.`);
}
};
/**
* 필수 필드 검증 후 누락된 필드 목록 반환
*/
export const checkMissingFields = (
data: Record<string, any>,
requiredFields: string[]
): string[] => {
const missingFields: string[] = [];
for (const field of requiredFields) {
const value = data[field];
if (value === null || value === undefined || value === "") {
missingFields.push(field);
}
}
return missingFields;
};

View File

@@ -0,0 +1,4 @@
회사 코드: COMPANY_2
생성일: 2025-09-11T02:07:40.033Z
폴더 구조: YYYY/MM/DD/파일명
관리자: 시스템 자동 생성

View File

@@ -0,0 +1,4 @@
회사 코드: COMPANY_3
생성일: 2025-09-11T02:08:06.303Z
폴더 구조: YYYY/MM/DD/파일명
관리자: 시스템 자동 생성