화면관리 중간 커밋

This commit is contained in:
kjs
2025-09-01 11:48:12 +09:00
parent 6c29c68d10
commit 42dbfd98f8
40 changed files with 5833 additions and 52 deletions

View File

@@ -13,6 +13,7 @@ import authRoutes from "./routes/authRoutes";
import adminRoutes from "./routes/adminRoutes";
import multilangRoutes from "./routes/multilangRoutes";
import tableManagementRoutes from "./routes/tableManagementRoutes";
import screenManagementRoutes from "./routes/screenManagementRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@@ -63,6 +64,7 @@ app.use("/api/auth", authRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes);
app.use("/api/screen-management", screenManagementRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@@ -0,0 +1,455 @@
import { Request, Response } from "express";
import { ScreenManagementService } from "../services/screenManagementService";
import {
CreateScreenRequest,
UpdateScreenRequest,
SaveLayoutRequest,
MenuAssignmentRequest,
ColumnWebTypeSetting,
WebType,
} from "../types/screen";
import { logger } from "../utils/logger";
export class ScreenManagementController {
private screenService: ScreenManagementService;
constructor() {
this.screenService = new ScreenManagementService();
}
// ========================================
// 화면 정의 관리
// ========================================
/**
* 화면 목록 조회 (회사별)
*/
async getScreens(req: Request, res: Response): Promise<void> {
try {
const { page = 1, size = 20 } = req.query;
const userCompanyCode = (req as any).user?.company_code || "*";
const result = await this.screenService.getScreensByCompany(
userCompanyCode,
Number(page),
Number(size)
);
res.json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
logger.error("화면 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "화면 목록을 조회하는 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 화면 생성
*/
async createScreen(req: Request, res: Response): Promise<void> {
try {
const screenData: CreateScreenRequest = req.body;
const userCompanyCode = (req as any).user?.company_code || "*";
const userId = (req as any).user?.user_id || "system";
// 사용자 회사 코드 자동 설정
if (userCompanyCode !== "*") {
screenData.companyCode = userCompanyCode;
}
screenData.createdBy = userId;
const screen = await this.screenService.createScreen(
screenData,
userCompanyCode
);
res.status(201).json({
success: true,
data: screen,
message: "화면이 성공적으로 생성되었습니다.",
});
} catch (error) {
logger.error("화면 생성 실패:", error);
res.status(400).json({
success: false,
message:
error instanceof Error ? error.message : "화면 생성에 실패했습니다.",
});
}
}
/**
* 화면 조회
*/
async getScreen(req: Request, res: Response): Promise<void> {
try {
const { screenId } = req.params;
const screen = await this.screenService.getScreenById(Number(screenId));
if (!screen) {
res.status(404).json({
success: false,
message: "화면을 찾을 수 없습니다.",
});
return;
}
res.json({
success: true,
data: screen,
});
} catch (error) {
logger.error("화면 조회 실패:", error);
res.status(500).json({
success: false,
message: "화면을 조회하는 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 화면 수정
*/
async updateScreen(req: Request, res: Response): Promise<void> {
try {
const { screenId } = req.params;
const updateData: UpdateScreenRequest = req.body;
const userCompanyCode = (req as any).user?.company_code || "*";
const userId = (req as any).user?.user_id || "system";
updateData.updatedBy = userId;
const screen = await this.screenService.updateScreen(
Number(screenId),
updateData,
userCompanyCode
);
res.json({
success: true,
data: screen,
message: "화면이 성공적으로 수정되었습니다.",
});
} catch (error) {
logger.error("화면 수정 실패:", error);
res.status(400).json({
success: false,
message:
error instanceof Error ? error.message : "화면 수정에 실패했습니다.",
});
}
}
/**
* 화면 삭제
*/
async deleteScreen(req: Request, res: Response): Promise<void> {
try {
const { screenId } = req.params;
const userCompanyCode = (req as any).user?.company_code || "*";
await this.screenService.deleteScreen(Number(screenId), userCompanyCode);
res.json({
success: true,
message: "화면이 성공적으로 삭제되었습니다.",
});
} catch (error) {
logger.error("화면 삭제 실패:", error);
res.status(400).json({
success: false,
message:
error instanceof Error ? error.message : "화면 삭제에 실패했습니다.",
});
}
}
// ========================================
// 레이아웃 관리
// ========================================
/**
* 레이아웃 조회
*/
async getLayout(req: Request, res: Response): Promise<void> {
try {
const { screenId } = req.params;
const layout = await this.screenService.getLayout(Number(screenId));
if (!layout) {
res.json({
success: true,
data: {
components: [],
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
},
},
});
return;
}
res.json({
success: true,
data: layout,
});
} catch (error) {
logger.error("레이아웃 조회 실패:", error);
res.status(500).json({
success: false,
message: "레이아웃을 조회하는 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 레이아웃 저장
*/
async saveLayout(req: Request, res: Response): Promise<void> {
try {
const { screenId } = req.params;
const layoutData: SaveLayoutRequest = req.body;
await this.screenService.saveLayout(Number(screenId), layoutData);
res.json({
success: true,
message: "레이아웃이 성공적으로 저장되었습니다.",
});
} catch (error) {
logger.error("레이아웃 저장 실패:", error);
res.status(400).json({
success: false,
message:
error instanceof Error
? error.message
: "레이아웃 저장에 실패했습니다.",
});
}
}
// ========================================
// 템플릿 관리
// ========================================
/**
* 템플릿 목록 조회
*/
async getTemplates(req: Request, res: Response): Promise<void> {
try {
const { type, isPublic } = req.query;
const userCompanyCode = (req as any).user?.company_code || "*";
const templates = await this.screenService.getTemplatesByCompany(
userCompanyCode,
type as string,
isPublic === "true"
);
res.json({
success: true,
data: templates,
});
} catch (error) {
logger.error("템플릿 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "템플릿 목록을 조회하는 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 템플릿 생성
*/
async createTemplate(req: Request, res: Response): Promise<void> {
try {
const templateData = req.body;
const userCompanyCode = (req as any).user?.company_code || "*";
const userId = (req as any).user?.user_id || "system";
templateData.company_code = userCompanyCode;
templateData.created_by = userId;
const template = await this.screenService.createTemplate(templateData);
res.status(201).json({
success: true,
data: template,
message: "템플릿이 성공적으로 생성되었습니다.",
});
} catch (error) {
logger.error("템플릿 생성 실패:", error);
res.status(400).json({
success: false,
message:
error instanceof Error
? error.message
: "템플릿 생성에 실패했습니다.",
});
}
}
// ========================================
// 메뉴 할당 관리
// ========================================
/**
* 화면-메뉴 할당
*/
async assignScreenToMenu(req: Request, res: Response): Promise<void> {
try {
const { screenId } = req.params;
const assignmentData: MenuAssignmentRequest = req.body;
const userCompanyCode = (req as any).user?.company_code || "*";
const userId = (req as any).user?.user_id || "system";
// 사용자 회사 코드 자동 설정
if (userCompanyCode !== "*") {
assignmentData.companyCode = userCompanyCode;
}
assignmentData.createdBy = userId;
await this.screenService.assignScreenToMenu(
Number(screenId),
assignmentData
);
res.json({
success: true,
message: "화면이 메뉴에 성공적으로 할당되었습니다.",
});
} catch (error) {
logger.error("메뉴 할당 실패:", error);
res.status(400).json({
success: false,
message:
error instanceof Error ? error.message : "메뉴 할당에 실패했습니다.",
});
}
}
/**
* 메뉴별 화면 목록 조회
*/
async getScreensByMenu(req: Request, res: Response): Promise<void> {
try {
const { menuObjid } = req.params;
const userCompanyCode = (req as any).user?.company_code || "*";
const screens = await this.screenService.getScreensByMenu(
Number(menuObjid),
userCompanyCode
);
res.json({
success: true,
data: screens,
});
} catch (error) {
logger.error("메뉴별 화면 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "메뉴별 화면 목록을 조회하는 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
// ========================================
// 테이블 타입 연계
// ========================================
/**
* 테이블 컬럼 정보 조회
*/
async getTableColumns(req: Request, res: Response): Promise<void> {
try {
const { tableName } = req.params;
const columns = await this.screenService.getColumnInfo(tableName);
res.json({
success: true,
data: columns,
});
} catch (error) {
logger.error("테이블 컬럼 정보 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 컬럼 정보를 조회하는 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 컬럼 웹 타입 설정
*/
async setColumnWebType(req: Request, res: Response): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { webType, ...additionalSettings } = req.body;
await this.screenService.setColumnWebType(
tableName,
columnName,
webType as WebType,
additionalSettings
);
res.json({
success: true,
message: "컬럼 웹 타입이 성공적으로 설정되었습니다.",
});
} catch (error) {
logger.error("컬럼 웹 타입 설정 실패:", error);
res.status(400).json({
success: false,
message:
error instanceof Error
? error.message
: "컬럼 웹 타입 설정에 실패했습니다.",
});
}
}
/**
* 테이블 목록 조회 (화면 생성용)
*/
async getTables(req: Request, res: Response): Promise<void> {
try {
// PostgreSQL에서 사용 가능한 테이블 목록 조회
const tables = await (this.screenService as any).prisma.$queryRaw`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`;
res.json({
success: true,
data: tables,
});
} catch (error) {
logger.error("테이블 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 목록을 조회하는 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}

View File

@@ -443,3 +443,77 @@ export async function getColumnLabels(
res.status(500).json(response);
}
}
/**
* 컬럼 웹 타입 설정
*/
export async function updateColumnWebType(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { webType, detailSettings } = req.body;
logger.info(
`=== 컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===`
);
if (!tableName || !columnName || !webType) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명, 컬럼명, 웹 타입이 모두 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "필수 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
// PostgreSQL 클라이언트 생성
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
await client.connect();
try {
const tableManagementService = new TableManagementService(client);
await tableManagementService.updateColumnWebType(
tableName,
columnName,
webType,
detailSettings
);
logger.info(
`컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
);
const response: ApiResponse<null> = {
success: true,
message: "컬럼 웹 타입이 성공적으로 설정되었습니다.",
data: null,
};
res.status(200).json(response);
} finally {
await client.end();
}
} catch (error) {
logger.error("컬럼 웹 타입 설정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.",
error: {
code: "WEB_TYPE_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@@ -0,0 +1,159 @@
import express from "express";
import { ScreenManagementController } from "../controllers/screenManagementController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
const screenController = new ScreenManagementController();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ========================================
// 화면 정의 관리
// ========================================
/**
* @route POST /screens
* @desc 새 화면 생성
* @access Private
*/
router.post("/screens", screenController.createScreen.bind(screenController));
/**
* @route GET /screens
* @desc 회사별 화면 목록 조회
* @access Private
*/
router.get("/screens", screenController.getScreens.bind(screenController));
/**
* @route GET /screens/:screenId
* @desc 특정 화면 조회
* @access Private
*/
router.get(
"/screens/:screenId",
screenController.getScreen.bind(screenController)
);
/**
* @route PUT /screens/:screenId
* @desc 화면 정보 수정
* @access Private
*/
router.put(
"/screens/:screenId",
screenController.updateScreen.bind(screenController)
);
/**
* @route DELETE /screens/:screenId
* @desc 화면 삭제
* @access Private
*/
router.delete(
"/screens/:screenId",
screenController.deleteScreen.bind(screenController)
);
// ========================================
// 레이아웃 관리
// ========================================
/**
* @route GET /screens/:screenId/layout
* @desc 화면 레이아웃 조회
* @access Private
*/
router.get(
"/screens/:screenId/layout",
screenController.getLayout.bind(screenController)
);
/**
* @route POST /screens/:screenId/layout
* @desc 화면 레이아웃 저장
* @access Private
*/
router.post(
"/screens/:screenId/layout",
screenController.saveLayout.bind(screenController)
);
// ========================================
// 템플릿 관리
// ========================================
/**
* @route GET /templates
* @desc 회사별 템플릿 목록 조회
* @access Private
*/
router.get("/templates", screenController.getTemplates.bind(screenController));
/**
* @route POST /templates
* @desc 새 템플릿 생성
* @access Private
*/
router.post(
"/templates",
screenController.createTemplate.bind(screenController)
);
// ========================================
// 메뉴 할당 관리
// ========================================
/**
* @route POST /screens/:screenId/menu-assignments
* @desc 화면을 메뉴에 할당
* @access Private
*/
router.post(
"/screens/:screenId/menu-assignments",
screenController.assignScreenToMenu.bind(screenController)
);
/**
* @route GET /menus/:menuObjid/screens
* @desc 메뉴별 할당된 화면 목록 조회
* @access Private
*/
router.get(
"/menus/:menuObjid/screens",
screenController.getScreensByMenu.bind(screenController)
);
// ========================================
// 테이블 타입 연계
// ========================================
/**
* @route GET /tables
* @desc 사용 가능한 테이블 목록 조회
* @access Private
*/
router.get("/tables", screenController.getTables.bind(screenController));
/**
* @route GET /tables/:tableName/columns
* @desc 테이블의 컬럼 정보 조회
* @access Private
*/
router.get(
"/tables/:tableName/columns",
screenController.getTableColumns.bind(screenController)
);
/**
* @route PUT /tables/:tableName/columns/:columnName/web-type
* @desc 컬럼의 웹 타입 설정
* @access Private
*/
router.put(
"/tables/:tableName/columns/:columnName/web-type",
screenController.setColumnWebType.bind(screenController)
);
export default router;

View File

@@ -7,6 +7,7 @@ import {
updateAllColumnSettings,
getTableLabels,
getColumnLabels,
updateColumnWebType,
} from "../controllers/tableManagementController";
const router = express.Router();
@@ -53,4 +54,13 @@ router.get("/tables/:tableName/labels", getTableLabels);
*/
router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels);
/**
* 컬럼 웹 타입 설정
* PUT /api/table-management/tables/:tableName/columns/:columnName/web-type
*/
router.put(
"/tables/:tableName/columns/:columnName/web-type",
updateColumnWebType
);
export default router;

View File

@@ -0,0 +1,618 @@
import prisma from "../config/database";
import {
ScreenDefinition,
CreateScreenRequest,
UpdateScreenRequest,
LayoutData,
SaveLayoutRequest,
ScreenTemplate,
MenuAssignmentRequest,
PaginatedResponse,
ComponentData,
ColumnInfo,
ColumnWebTypeSetting,
WebType,
WidgetData,
} from "../types/screen";
import { generateId } from "../utils/generateId";
export class ScreenManagementService {
// ========================================
// 화면 정의 관리
// ========================================
/**
* 화면 정의 생성
*/
async createScreen(
screenData: CreateScreenRequest,
userCompanyCode: string
): Promise<ScreenDefinition> {
// 화면 코드 중복 확인
const existingScreen = await prisma.screen_definitions.findUnique({
where: { screen_code: screenData.screenCode },
});
if (existingScreen) {
throw new Error("이미 존재하는 화면 코드입니다.");
}
const screen = await prisma.screen_definitions.create({
data: {
screen_name: screenData.screenName,
screen_code: screenData.screenCode,
table_name: screenData.tableName,
company_code: screenData.companyCode,
description: screenData.description,
created_by: screenData.createdBy,
},
});
return this.mapToScreenDefinition(screen);
}
/**
* 회사별 화면 목록 조회 (페이징 지원)
*/
async getScreensByCompany(
companyCode: string,
page: number = 1,
size: number = 20
): Promise<PaginatedResponse<ScreenDefinition>> {
const whereClause =
companyCode === "*" ? {} : { company_code: companyCode };
const [screens, total] = await Promise.all([
prisma.screen_definitions.findMany({
where: whereClause,
skip: (page - 1) * size,
take: size,
orderBy: { created_date: "desc" },
}),
prisma.screen_definitions.count({ where: whereClause }),
]);
return {
data: screens.map((screen) => this.mapToScreenDefinition(screen)),
pagination: {
page,
size,
total,
totalPages: Math.ceil(total / size),
},
};
}
/**
* 화면 정의 조회
*/
async getScreenById(screenId: number): Promise<ScreenDefinition | null> {
const screen = await prisma.screen_definitions.findUnique({
where: { screen_id: screenId },
});
return screen ? this.mapToScreenDefinition(screen) : null;
}
/**
* 화면 정의 수정
*/
async updateScreen(
screenId: number,
updateData: UpdateScreenRequest,
userCompanyCode: string
): Promise<ScreenDefinition> {
// 권한 검증
const screen = await prisma.screen_definitions.findUnique({
where: { screen_id: screenId },
});
if (!screen) {
throw new Error("화면을 찾을 수 없습니다.");
}
if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) {
throw new Error("해당 화면을 수정할 권한이 없습니다.");
}
const updatedScreen = await prisma.screen_definitions.update({
where: { screen_id: screenId },
data: {
screen_name: updateData.screenName,
description: updateData.description,
is_active: updateData.isActive,
updated_by: updateData.updatedBy,
updated_date: new Date(),
},
});
return this.mapToScreenDefinition(updatedScreen);
}
/**
* 화면 정의 삭제
*/
async deleteScreen(screenId: number, userCompanyCode: string): Promise<void> {
// 권한 검증
const screen = await prisma.screen_definitions.findUnique({
where: { screen_id: screenId },
});
if (!screen) {
throw new Error("화면을 찾을 수 없습니다.");
}
if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) {
throw new Error("해당 화면을 삭제할 권한이 없습니다.");
}
// CASCADE로 인해 관련 레이아웃과 위젯도 자동 삭제됨
await prisma.screen_definitions.delete({
where: { screen_id: screenId },
});
}
// ========================================
// 레이아웃 관리
// ========================================
/**
* 레이아웃 저장
*/
async saveLayout(
screenId: number,
layoutData: SaveLayoutRequest
): Promise<void> {
// 화면 존재 확인
const screen = await prisma.screen_definitions.findUnique({
where: { screen_id: screenId },
});
if (!screen) {
throw new Error("화면을 찾을 수 없습니다.");
}
// 기존 레이아웃 삭제
await prisma.screen_layouts.deleteMany({
where: { screen_id: screenId },
});
// 새 레이아웃 저장
const layoutPromises = layoutData.components.map((component) =>
prisma.screen_layouts.create({
data: {
screen_id: screenId,
component_type: component.type,
component_id: component.id,
parent_id: component.parentId,
position_x: component.position.x,
position_y: component.position.y,
width: component.size.width,
height: component.size.height,
properties: component.properties,
display_order: component.displayOrder || 0,
},
})
);
await Promise.all(layoutPromises);
}
/**
* 레이아웃 조회
*/
async getLayout(screenId: number): Promise<LayoutData | null> {
const layouts = await prisma.screen_layouts.findMany({
where: { screen_id: screenId },
orderBy: { display_order: "asc" },
});
if (layouts.length === 0) {
return null;
}
const components: ComponentData[] = layouts.map((layout) => {
const baseComponent = {
id: layout.component_id,
type: layout.component_type as any,
position: { x: layout.position_x, y: layout.position_y },
size: { width: layout.width, height: layout.height },
properties: layout.properties as Record<string, any>,
displayOrder: layout.display_order,
};
// 컴포넌트 타입별 추가 속성 처리
switch (layout.component_type) {
case "group":
return {
...baseComponent,
type: "group",
title: (layout.properties as any)?.title,
backgroundColor: (layout.properties as any)?.backgroundColor,
border: (layout.properties as any)?.border,
borderRadius: (layout.properties as any)?.borderRadius,
shadow: (layout.properties as any)?.shadow,
padding: (layout.properties as any)?.padding,
margin: (layout.properties as any)?.margin,
collapsible: (layout.properties as any)?.collapsible,
collapsed: (layout.properties as any)?.collapsed,
children: (layout.properties as any)?.children || [],
};
case "widget":
return {
...baseComponent,
type: "widget",
tableName: (layout.properties as any)?.tableName,
columnName: (layout.properties as any)?.columnName,
widgetType: (layout.properties as any)?.widgetType,
label: (layout.properties as any)?.label,
placeholder: (layout.properties as any)?.placeholder,
required: (layout.properties as any)?.required,
readonly: (layout.properties as any)?.readonly,
validationRules: (layout.properties as any)?.validationRules,
displayProperties: (layout.properties as any)?.displayProperties,
};
default:
return baseComponent;
}
});
return {
components,
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
},
};
}
// ========================================
// 템플릿 관리
// ========================================
/**
* 템플릿 목록 조회 (회사별)
*/
async getTemplatesByCompany(
companyCode: string,
type?: string,
isPublic?: boolean
): Promise<ScreenTemplate[]> {
const whereClause: any = {};
if (companyCode !== "*") {
whereClause.company_code = companyCode;
}
if (type) {
whereClause.template_type = type;
}
if (isPublic !== undefined) {
whereClause.is_public = isPublic;
}
const templates = await prisma.screen_templates.findMany({
where: whereClause,
orderBy: { created_date: "desc" },
});
return templates.map(this.mapToScreenTemplate);
}
/**
* 템플릿 생성
*/
async createTemplate(
templateData: Partial<ScreenTemplate>
): Promise<ScreenTemplate> {
const template = await prisma.screen_templates.create({
data: {
template_name: templateData.templateName!,
template_type: templateData.templateType!,
company_code: templateData.companyCode!,
description: templateData.description,
layout_data: templateData.layoutData
? JSON.parse(JSON.stringify(templateData.layoutData))
: null,
is_public: templateData.isPublic || false,
created_by: templateData.createdBy,
},
});
return this.mapToScreenTemplate(template);
}
// ========================================
// 메뉴 할당 관리
// ========================================
/**
* 화면-메뉴 할당
*/
async assignScreenToMenu(
screenId: number,
assignmentData: MenuAssignmentRequest
): Promise<void> {
// 중복 할당 방지
const existingAssignment = await prisma.screen_menu_assignments.findFirst({
where: {
screen_id: screenId,
menu_objid: assignmentData.menuObjid,
company_code: assignmentData.companyCode,
},
});
if (existingAssignment) {
throw new Error("이미 할당된 화면입니다.");
}
await prisma.screen_menu_assignments.create({
data: {
screen_id: screenId,
menu_objid: assignmentData.menuObjid,
company_code: assignmentData.companyCode,
display_order: assignmentData.displayOrder || 0,
created_by: assignmentData.createdBy,
},
});
}
/**
* 메뉴별 화면 목록 조회
*/
async getScreensByMenu(
menuObjid: number,
companyCode: string
): Promise<ScreenDefinition[]> {
const assignments = await prisma.screen_menu_assignments.findMany({
where: {
menu_objid: menuObjid,
company_code: companyCode,
is_active: "Y",
},
include: {
screen: true,
},
orderBy: { display_order: "asc" },
});
return assignments.map((assignment) =>
this.mapToScreenDefinition(assignment.screen)
);
}
// ========================================
// 테이블 타입 연계
// ========================================
/**
* 컬럼 정보 조회 (웹 타입 포함)
*/
async getColumnInfo(tableName: string): Promise<ColumnInfo[]> {
const columns = await prisma.$queryRaw`
SELECT
c.column_name,
COALESCE(cl.column_label, c.column_name) as column_label,
c.data_type,
COALESCE(cl.web_type, 'text') as web_type,
c.is_nullable,
c.column_default,
c.character_maximum_length,
c.numeric_precision,
c.numeric_scale,
cl.detail_settings,
cl.code_category,
cl.reference_table,
cl.reference_column,
cl.is_visible,
cl.display_order,
cl.description
FROM information_schema.columns c
LEFT JOIN column_labels cl ON c.table_name = cl.table_name
AND c.column_name = cl.column_name
WHERE c.table_name = ${tableName}
ORDER BY COALESCE(cl.display_order, c.ordinal_position)
`;
return columns as ColumnInfo[];
}
/**
* 웹 타입 설정
*/
async setColumnWebType(
tableName: string,
columnName: string,
webType: WebType,
additionalSettings?: Partial<ColumnWebTypeSetting>
): Promise<void> {
await prisma.column_labels.upsert({
where: {
table_name_column_name: {
table_name: tableName,
column_name: columnName,
},
},
update: {
web_type: webType,
column_label: additionalSettings?.columnLabel,
detail_settings: additionalSettings?.detailSettings
? JSON.stringify(additionalSettings.detailSettings)
: null,
code_category: additionalSettings?.codeCategory,
reference_table: additionalSettings?.referenceTable,
reference_column: additionalSettings?.referenceColumn,
is_visible: additionalSettings?.isVisible ?? true,
display_order: additionalSettings?.displayOrder ?? 0,
description: additionalSettings?.description,
updated_date: new Date(),
},
create: {
table_name: tableName,
column_name: columnName,
column_label: additionalSettings?.columnLabel,
web_type: webType,
detail_settings: additionalSettings?.detailSettings
? JSON.stringify(additionalSettings.detailSettings)
: null,
code_category: additionalSettings?.codeCategory,
reference_table: additionalSettings?.referenceTable,
reference_column: additionalSettings?.referenceColumn,
is_visible: additionalSettings?.isVisible ?? true,
display_order: additionalSettings?.displayOrder ?? 0,
description: additionalSettings?.description,
created_date: new Date(),
},
});
}
/**
* 웹 타입별 위젯 생성
*/
generateWidgetFromColumn(column: ColumnInfo): WidgetData {
const baseWidget = {
id: generateId(),
tableName: column.tableName,
columnName: column.columnName,
type: column.webType || "text",
label: column.columnLabel || column.columnName,
required: column.isNullable === "N",
readonly: false,
};
// detail_settings JSON 파싱
const detailSettings = column.detailSettings
? JSON.parse(column.detailSettings)
: {};
switch (column.webType) {
case "text":
return {
...baseWidget,
maxLength: detailSettings.maxLength || column.characterMaximumLength,
placeholder: `Enter ${column.columnLabel || column.columnName}`,
pattern: detailSettings.pattern,
};
case "number":
return {
...baseWidget,
min: detailSettings.min,
max:
detailSettings.max ||
(column.numericPrecision
? Math.pow(10, column.numericPrecision) - 1
: undefined),
step:
detailSettings.step ||
(column.numericScale && column.numericScale > 0
? Math.pow(10, -column.numericScale)
: 1),
};
case "date":
return {
...baseWidget,
format: detailSettings.format || "YYYY-MM-DD",
minDate: detailSettings.minDate,
maxDate: detailSettings.maxDate,
};
case "code":
return {
...baseWidget,
codeCategory: column.codeCategory,
multiple: detailSettings.multiple || false,
searchable: detailSettings.searchable || false,
};
case "entity":
return {
...baseWidget,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
searchable: detailSettings.searchable || true,
multiple: detailSettings.multiple || false,
};
case "textarea":
return {
...baseWidget,
rows: detailSettings.rows || 3,
maxLength: detailSettings.maxLength || column.characterMaximumLength,
};
case "select":
return {
...baseWidget,
options: detailSettings.options || [],
multiple: detailSettings.multiple || false,
searchable: detailSettings.searchable || false,
};
case "checkbox":
return {
...baseWidget,
defaultChecked: detailSettings.defaultChecked || false,
label: detailSettings.label || column.columnLabel,
};
case "radio":
return {
...baseWidget,
options: detailSettings.options || [],
inline: detailSettings.inline || false,
};
case "file":
return {
...baseWidget,
accept: detailSettings.accept || "*/*",
maxSize: detailSettings.maxSize || 10485760, // 10MB
multiple: detailSettings.multiple || false,
};
default:
return {
...baseWidget,
type: "text",
};
}
}
// ========================================
// 유틸리티 메서드
// ========================================
private mapToScreenDefinition(data: any): ScreenDefinition {
return {
screenId: data.screen_id,
screenName: data.screen_name,
screenCode: data.screen_code,
tableName: data.table_name,
companyCode: data.company_code,
description: data.description,
isActive: data.is_active,
createdDate: data.created_date,
createdBy: data.created_by,
updatedDate: data.updated_date,
updatedBy: data.updated_by,
};
}
private mapToScreenTemplate(data: any): ScreenTemplate {
return {
templateId: data.template_id,
templateName: data.template_name,
templateType: data.template_type,
companyCode: data.company_code,
description: data.description,
layoutData: data.layout_data,
isPublic: data.is_public,
createdBy: data.created_by,
createdDate: data.created_date,
};
}
}

View File

@@ -197,14 +197,18 @@ export class TableManagementService {
// 각 컬럼 설정을 순차적으로 업데이트
for (const columnSetting of columnSettings) {
const columnName =
columnSetting.columnLabel || columnSetting.columnName;
// columnName은 실제 DB 컬럼명을 유지해야 함
const columnName = columnSetting.columnName;
if (columnName) {
await this.updateColumnSettings(
tableName,
columnName,
columnSetting
);
} else {
logger.warn(
`컬럼명이 누락된 설정: ${JSON.stringify(columnSetting)}`
);
}
}
@@ -310,4 +314,166 @@ export class TableManagementService {
);
}
}
/**
* 컬럼 웹 타입 설정
*/
async updateColumnWebType(
tableName: string,
columnName: string,
webType: string,
detailSettings?: Record<string, any>
): Promise<void> {
try {
logger.info(
`컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
);
// 웹 타입별 기본 상세 설정 생성
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
...defaultDetailSettings,
...detailSettings,
};
// column_labels 테이블에 해당 컬럼이 있는지 확인
const checkQuery = `
SELECT COUNT(*) as count
FROM column_labels
WHERE table_name = $1 AND column_name = $2
`;
const checkResult = await this.client.query(checkQuery, [
tableName,
columnName,
]);
if (checkResult.rows[0].count > 0) {
// 기존 컬럼 라벨 업데이트
const updateQuery = `
UPDATE column_labels
SET web_type = $3, detail_settings = $4, updated_date = NOW()
WHERE table_name = $1 AND column_name = $2
`;
await this.client.query(updateQuery, [
tableName,
columnName,
webType,
JSON.stringify(finalDetailSettings),
]);
logger.info(
`컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}`
);
} else {
// 새로운 컬럼 라벨 생성
const insertQuery = `
INSERT INTO column_labels (
table_name, column_name, web_type, detail_settings, created_date, updated_date
) VALUES ($1, $2, $3, $4, NOW(), NOW())
`;
await this.client.query(insertQuery, [
tableName,
columnName,
webType,
JSON.stringify(finalDetailSettings),
]);
logger.info(
`컬럼 라벨 생성 및 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
);
}
} catch (error) {
logger.error(
`컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
error
);
throw new Error(
`컬럼 웹 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 웹 타입별 기본 상세 설정 생성
*/
private generateDefaultDetailSettings(webType: string): Record<string, any> {
switch (webType) {
case "text":
return {
maxLength: 255,
pattern: null,
placeholder: null,
};
case "number":
return {
min: null,
max: null,
step: 1,
precision: 2,
};
case "date":
return {
format: "YYYY-MM-DD",
minDate: null,
maxDate: null,
};
case "code":
return {
codeCategory: null,
displayFormat: "label",
searchable: true,
multiple: false,
};
case "entity":
return {
referenceTable: null,
referenceColumn: null,
searchable: true,
multiple: false,
};
case "textarea":
return {
rows: 3,
maxLength: 1000,
placeholder: null,
};
case "select":
return {
options: [],
multiple: false,
searchable: false,
};
case "checkbox":
return {
defaultChecked: false,
label: null,
};
case "radio":
return {
options: [],
inline: false,
};
case "file":
return {
accept: "*/*",
maxSize: 10485760, // 10MB
multiple: false,
};
default:
return {};
}
}
}

View File

@@ -0,0 +1,284 @@
// 화면관리 시스템 타입 정의
// 기본 컴포넌트 타입
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
// 웹 타입 정의
export type WebType =
| "text"
| "number"
| "date"
| "code"
| "entity"
| "textarea"
| "select"
| "checkbox"
| "radio"
| "file";
// 위치 정보
export interface Position {
x: number;
y: number;
}
// 크기 정보
export interface Size {
width: number; // 1-12 그리드
height: number; // 픽셀
}
// 기본 컴포넌트 인터페이스
export interface BaseComponent {
id: string;
type: ComponentType;
position: Position;
size: Size;
properties?: Record<string, any>;
displayOrder?: number;
parentId?: string; // 부모 컴포넌트 ID 추가
}
// 컨테이너 컴포넌트
export interface ContainerComponent extends BaseComponent {
type: "container";
backgroundColor?: string;
border?: string;
borderRadius?: number;
shadow?: string;
padding?: number;
margin?: number;
}
// 그룹 컴포넌트
export interface GroupComponent extends BaseComponent {
type: "group";
title?: string;
backgroundColor?: string;
border?: string;
borderRadius?: number;
shadow?: string;
padding?: number;
margin?: number;
collapsible?: boolean;
collapsed?: boolean;
children: string[]; // 포함된 컴포넌트 ID 목록
}
// 행 컴포넌트
export interface RowComponent extends BaseComponent {
type: "row";
columns: number; // 1-12
gap: number;
alignItems: "start" | "center" | "end";
justifyContent: "start" | "center" | "end" | "space-between";
}
// 컬럼 컴포넌트
export interface ColumnComponent extends BaseComponent {
type: "column";
offset?: number;
order?: number;
}
// 위젯 컴포넌트
export interface WidgetComponent extends BaseComponent {
type: "widget";
tableName: string;
columnName: string;
widgetType: WebType;
label: string;
placeholder?: string;
required: boolean;
readonly: boolean;
validationRules?: ValidationRule[];
displayProperties?: Record<string, any>;
}
// 컴포넌트 유니온 타입
export type ComponentData =
| ContainerComponent
| GroupComponent
| RowComponent
| ColumnComponent
| WidgetComponent;
// 레이아웃 데이터
export interface LayoutData {
components: ComponentData[];
gridSettings?: GridSettings;
}
// 그리드 설정
export interface GridSettings {
columns: number; // 기본값: 12
gap: number; // 기본값: 16px
padding: number; // 기본값: 16px
}
// 유효성 검증 규칙
export interface ValidationRule {
type:
| "required"
| "minLength"
| "maxLength"
| "pattern"
| "min"
| "max"
| "email"
| "url";
value?: any;
message: string;
}
// 화면 정의
export interface ScreenDefinition {
screenId: number;
screenName: string;
screenCode: string;
tableName: string;
companyCode: string;
description?: string;
isActive: string;
createdDate: Date;
createdBy?: string;
updatedDate: Date;
updatedBy?: string;
}
// 화면 생성 요청
export interface CreateScreenRequest {
screenName: string;
screenCode: string;
tableName: string;
companyCode: string;
description?: string;
createdBy?: string;
}
// 화면 수정 요청
export interface UpdateScreenRequest {
screenName?: string;
description?: string;
isActive?: string;
updatedBy?: string;
}
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
components: ComponentData[];
gridSettings?: GridSettings;
}
// 화면 템플릿
export interface ScreenTemplate {
templateId: number;
templateName: string;
templateType: string;
companyCode: string;
description?: string;
layoutData?: LayoutData;
isPublic: boolean;
createdBy?: string;
createdDate: Date;
}
// 메뉴 할당 요청
export interface MenuAssignmentRequest {
menuObjid: number;
companyCode: string;
displayOrder?: number;
createdBy?: string;
}
// 드래그 상태
export interface DragState {
isDragging: boolean;
draggedItem: ComponentData | null;
dragSource: "toolbox" | "canvas";
dropTarget: string | null;
dropZone?: DropZone;
}
// 드롭 영역
export interface DropZone {
id: string;
accepts: ComponentType[];
position: Position;
size: Size;
}
// 그룹화 상태
export interface GroupState {
isGrouping: boolean;
selectedComponents: string[];
groupTarget: string | null;
groupMode: "create" | "add" | "remove";
}
// 컬럼 정보 (테이블 타입관리 연계용)
export interface ColumnInfo {
tableName: string;
columnName: string;
columnLabel?: string;
dataType: string;
webType?: WebType;
isNullable: string;
columnDefault?: string;
characterMaximumLength?: number;
numericPrecision?: number;
numericScale?: number;
detailSettings?: string; // JSON 문자열
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
isVisible?: boolean;
displayOrder?: number;
description?: string;
}
// 웹 타입 설정
export interface ColumnWebTypeSetting {
tableName: string;
columnName: string;
webType: WebType;
columnLabel?: string;
detailSettings?: Record<string, any>;
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
isVisible?: boolean;
displayOrder?: number;
description?: string;
}
// 위젯 데이터
export interface WidgetData {
id: string;
tableName: string;
columnName: string;
type: WebType;
label: string;
required: boolean;
readonly: boolean;
[key: string]: any; // 추가 속성들
}
// API 응답 타입
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
errorCode?: string;
}
// 페이지네이션 응답
export interface PaginatedResponse<T> {
data: T[];
pagination: {
total: number;
page: number;
size: number;
totalPages: number;
};
}

View File

@@ -107,6 +107,11 @@ export const WEB_TYPE_OPTIONS = [
label: "entity",
description: "엔티티 참조 (참조테이블 지정)",
},
{ value: "textarea", label: "textarea", description: "여러 줄 텍스트" },
{ value: "select", label: "select", description: "드롭다운 선택" },
{ value: "checkbox", label: "checkbox", description: "체크박스" },
{ value: "radio", label: "radio", description: "라디오 버튼" },
{ value: "file", label: "file", description: "파일 업로드" },
] as const;
export type WebType = (typeof WEB_TYPE_OPTIONS)[number]["value"];

View File

@@ -0,0 +1,56 @@
/**
* 고유 ID 생성 유틸리티
*/
/**
* UUID v4 생성
*/
export function generateUUID(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* 짧은 고유 ID 생성 (8자리)
*/
export function generateShortId(): string {
return Math.random().toString(36).substring(2, 10);
}
/**
* 긴 고유 ID 생성 (16자리)
*/
export function generateLongId(): string {
return Math.random().toString(36).substring(2, 18);
}
/**
* 기본 ID 생성 (UUID v4)
*/
export function generateId(): string {
return generateUUID();
}
/**
* 타임스탬프 기반 ID 생성
*/
export function generateTimestampId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
/**
* 컴포넌트 ID 생성 (화면관리 시스템용)
*/
export function generateComponentId(prefix: string = "comp"): string {
return `${prefix}_${generateShortId()}`;
}
/**
* 화면 ID 생성 (화면관리 시스템용)
*/
export function generateScreenId(prefix: string = "screen"): string {
return `${prefix}_${generateShortId()}`;
}