화면관리 중간 커밋

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

@ -0,0 +1,36 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function cleanScreenTables() {
try {
console.log("🧹 기존 화면관리 테이블들을 정리합니다...");
// 기존 테이블들을 순서대로 삭제 (외래키 제약조건 때문에 순서 중요)
await prisma.$executeRaw`DROP VIEW IF EXISTS v_screen_definitions_with_auth CASCADE`;
console.log("✅ 뷰 삭제 완료");
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_menu_assignments CASCADE`;
console.log("✅ screen_menu_assignments 테이블 삭제 완료");
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_widgets CASCADE`;
console.log("✅ screen_widgets 테이블 삭제 완료");
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_layouts CASCADE`;
console.log("✅ screen_layouts 테이블 삭제 완료");
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_templates CASCADE`;
console.log("✅ screen_templates 테이블 삭제 완료");
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_definitions CASCADE`;
console.log("✅ screen_definitions 테이블 삭제 완료");
console.log("🎉 모든 화면관리 테이블 정리 완료!");
} catch (error) {
console.error("❌ 테이블 정리 중 오류 발생:", error);
} finally {
await prisma.$disconnect();
}
}
cleanScreenTables();

View File

@ -5174,7 +5174,7 @@ model swhd010a_tbl {
empno String @id(map: "pk_swhd010a_tbl") @db.Char(6) empno String @id(map: "pk_swhd010a_tbl") @db.Char(6)
ltdcd String @db.Char(1) ltdcd String @db.Char(1)
namehan String? @db.Char(10) namehan String? @db.Char(10)
deptcd String? @db.Char(5) deptcd String? @db.VarChar(5)
resigngucd String? @db.VarChar(1) resigngucd String? @db.VarChar(1)
} }
@ -6838,3 +6838,97 @@ model zz_230410_user_info {
@@ignore @@ignore
} }
// 화면관리 시스템 Prisma 스키마
// 기존 schema.prisma에 추가할 모델들
model screen_definitions {
screen_id Int @id @default(autoincrement())
screen_name String @db.VarChar(100)
screen_code String @unique @db.VarChar(50)
table_name String @db.VarChar(100)
company_code String @db.VarChar(50)
description String? @db.Text
is_active String @default("Y") @db.Char(1)
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)
// 관계
layouts screen_layouts[]
menu_assignments screen_menu_assignments[]
@@index([company_code])
}
model screen_layouts {
layout_id Int @id @default(autoincrement())
screen_id Int
component_type String @db.VarChar(50)
component_id String @unique @db.VarChar(100)
parent_id String? @db.VarChar(100)
position_x Int
position_y Int
width Int
height Int
properties Json?
display_order Int @default(0)
created_date DateTime @default(now()) @db.Timestamp(6)
// 관계
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
widgets screen_widgets[]
@@index([screen_id])
}
model screen_widgets {
widget_id Int @id @default(autoincrement())
layout_id Int
table_name String @db.VarChar(100)
column_name String @db.VarChar(100)
widget_type String @db.VarChar(50)
label String? @db.VarChar(200)
placeholder String? @db.VarChar(200)
is_required Boolean @default(false)
is_readonly Boolean @default(false)
validation_rules Json?
display_properties Json?
created_date DateTime @default(now()) @db.Timestamp(6)
// 관계
layout screen_layouts @relation(fields: [layout_id], references: [layout_id], onDelete: Cascade)
@@index([layout_id])
}
model screen_templates {
template_id Int @id @default(autoincrement())
template_name String @db.VarChar(100)
template_type String @db.VarChar(50)
company_code String @db.VarChar(50)
description String? @db.Text
layout_data Json?
is_public Boolean @default(false)
created_by String? @db.VarChar(50)
created_date DateTime @default(now()) @db.Timestamp(6)
@@index([company_code])
}
model screen_menu_assignments {
assignment_id Int @id @default(autoincrement())
screen_id Int
menu_objid Decimal @db.Decimal
company_code String @db.VarChar(50)
display_order Int @default(0)
is_active String @default("Y") @db.Char(1)
created_date DateTime @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
// 관계
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
@@unique([screen_id, menu_objid, company_code])
@@index([company_code])
}

View File

@ -0,0 +1,94 @@
// 화면관리 시스템 Prisma 스키마
// 기존 schema.prisma에 추가할 모델들
model screen_definitions {
screen_id Int @id @default(autoincrement())
screen_name String @db.VarChar(100)
screen_code String @unique @db.VarChar(50)
table_name String @db.VarChar(100)
company_code String @db.VarChar(50)
description String? @db.Text
is_active String @default("Y") @db.Char(1)
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)
// 관계
layouts screen_layouts[]
menu_assignments screen_menu_assignments[]
@@index([company_code])
}
model screen_layouts {
layout_id Int @id @default(autoincrement())
screen_id Int
component_type String @db.VarChar(50)
component_id String @unique @db.VarChar(100)
parent_id String? @db.VarChar(100)
position_x Int
position_y Int
width Int
height Int
properties Json?
display_order Int @default(0)
created_date DateTime @default(now()) @db.Timestamp(6)
// 관계
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
widgets screen_widgets[]
@@index([screen_id])
}
model screen_widgets {
widget_id Int @id @default(autoincrement())
layout_id Int
table_name String @db.VarChar(100)
column_name String @db.VarChar(100)
widget_type String @db.VarChar(50)
label String? @db.VarChar(200)
placeholder String? @db.VarChar(200)
is_required Boolean @default(false)
is_readonly Boolean @default(false)
validation_rules Json?
display_properties Json?
created_date DateTime @default(now()) @db.Timestamp(6)
// 관계
layout screen_layouts @relation(fields: [layout_id], references: [layout_id], onDelete: Cascade)
@@index([layout_id])
}
model screen_templates {
template_id Int @id @default(autoincrement())
template_name String @db.VarChar(100)
template_type String @db.VarChar(50)
company_code String @db.VarChar(50)
description String? @db.Text
layout_data Json?
is_public Boolean @default(false)
created_by String? @db.VarChar(50)
created_date DateTime @default(now()) @db.Timestamp(6)
@@index([company_code])
}
model screen_menu_assignments {
assignment_id Int @id @default(autoincrement())
screen_id Int
menu_objid Decimal @db.Decimal
company_code String @db.VarChar(50)
display_order Int @default(0)
is_active String @default("Y") @db.Char(1)
created_date DateTime @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
// 관계
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
@@unique([screen_id, menu_objid, company_code])
@@index([company_code])
}

View File

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

View File

@ -0,0 +1,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); 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, updateAllColumnSettings,
getTableLabels, getTableLabels,
getColumnLabels, getColumnLabels,
updateColumnWebType,
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -53,4 +54,13 @@ router.get("/tables/:tableName/labels", getTableLabels);
*/ */
router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels); 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; 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) { for (const columnSetting of columnSettings) {
const columnName = // columnName은 실제 DB 컬럼명을 유지해야 함
columnSetting.columnLabel || columnSetting.columnName; const columnName = columnSetting.columnName;
if (columnName) { if (columnName) {
await this.updateColumnSettings( await this.updateColumnSettings(
tableName, tableName,
columnName, columnName,
columnSetting 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", label: "entity",
description: "엔티티 참조 (참조테이블 지정)", 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; ] as const;
export type WebType = (typeof WEB_TYPE_OPTIONS)[number]["value"]; 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()}`;
}

View File

@ -40,8 +40,8 @@ public class TableManagementService {
Map<String, Object> paramMap = new HashMap<>(); Map<String, Object> paramMap = new HashMap<>();
paramMap.put("tableName", tableName); paramMap.put("tableName", tableName);
paramMap.put("columnName", columnName); paramMap.put("columnName", columnName); // 실제 DB 컬럼명 (변경 불가)
paramMap.put("columnLabel", settings.get("columnLabel")); paramMap.put("columnLabel", settings.get("columnLabel")); // 사용자가 입력한 표시명
paramMap.put("webType", settings.get("webType")); paramMap.put("webType", settings.get("webType"));
paramMap.put("detailSettings", settings.get("detailSettings")); paramMap.put("detailSettings", settings.get("detailSettings"));
paramMap.put("codeCategory", settings.get("codeCategory")); paramMap.put("codeCategory", settings.get("codeCategory"));
@ -49,6 +49,13 @@ public class TableManagementService {
paramMap.put("referenceTable", settings.get("referenceTable")); paramMap.put("referenceTable", settings.get("referenceTable"));
paramMap.put("referenceColumn", settings.get("referenceColumn")); paramMap.put("referenceColumn", settings.get("referenceColumn"));
// 디버깅을 위한 로그 추가
System.out.println("저장할 컬럼 설정:");
System.out.println(" tableName: " + tableName);
System.out.println(" columnName: " + columnName);
System.out.println(" columnLabel: " + settings.get("columnLabel"));
System.out.println(" webType: " + settings.get("webType"));
sqlSessionTemplate.update("tableManagement.updateColumnSettings", paramMap); sqlSessionTemplate.update("tableManagement.updateColumnSettings", paramMap);
} }

View File

@ -76,6 +76,7 @@
- **자동 상세 설정**: 웹 타입 선택 시 해당 타입에 맞는 기본 상세 설정을 자동으로 제공 - **자동 상세 설정**: 웹 타입 선택 시 해당 타입에 맞는 기본 상세 설정을 자동으로 제공
- **실시간 저장**: 웹 타입 변경 시 즉시 백엔드 데이터베이스에 저장 - **실시간 저장**: 웹 타입 변경 시 즉시 백엔드 데이터베이스에 저장
- **오류 복구**: 저장 실패 시 원래 상태로 자동 복원 - **오류 복구**: 저장 실패 시 원래 상태로 자동 복원
- **상세 설정 편집**: 웹 타입별 상세 설정을 모달에서 JSON 형태로 편집 가능
#### 2. 웹 타입별 상세 설정 #### 2. 웹 타입별 상세 설정
@ -98,7 +99,8 @@
2. **컬럼 확인**: 해당 테이블의 모든 컬럼 정보 표시 2. **컬럼 확인**: 해당 테이블의 모든 컬럼 정보 표시
3. **웹 타입 설정**: 각 컬럼의 웹 타입을 드롭다운에서 선택 3. **웹 타입 설정**: 각 컬럼의 웹 타입을 드롭다운에서 선택
4. **자동 저장**: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용 4. **자동 저장**: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용
5. **추가 설정**: 필요시 상세 설정을 사용자 정의로 수정 5. **상세 설정 편집**: "상세 설정 편집" 버튼을 클릭하여 JSON 형태로 추가 설정 수정
6. **설정 저장**: 수정된 상세 설정을 저장하여 완료
## 🏗️ 아키텍처 구조 ## 🏗️ 아키텍처 구조
@ -1193,6 +1195,7 @@ return c;
}; };
return ( return (
<div className="screen-designer"> <div className="screen-designer">
<Toolbox onComponentSelect={addComponent} /> <Toolbox onComponentSelect={addComponent} />
<Canvas <Canvas

View File

@ -1,4 +1,5 @@
import { Users, Shield, Settings, BarChart3 } from "lucide-react"; import { Users, Shield, Settings, BarChart3, Palette } from "lucide-react";
import Link from "next/link";
/** /**
* *
*/ */
@ -6,18 +7,20 @@ export default function AdminPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 관리자 기능 카드들 */} {/* 관리자 기능 카드들 */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-5">
<div className="rounded-lg border bg-white p-6 shadow-sm"> <Link href="/admin/userMng" className="block">
<div className="flex items-center gap-4"> <div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-50"> <div className="flex items-center gap-4">
<Users className="h-6 w-6 text-blue-600" /> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-50">
</div> <Users className="h-6 w-6 text-blue-600" />
<div> </div>
<h3 className="font-semibold text-gray-900"> </h3> <div>
<p className="text-sm text-gray-600"> </p> <h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div> </div>
</div> </div>
</div> </Link>
<div className="rounded-lg border bg-white p-6 shadow-sm"> <div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -54,6 +57,20 @@ export default function AdminPage() {
</div> </div>
</div> </div>
</div> </div>
<Link href="/admin/screenMng" className="block">
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50">
<Palette className="h-6 w-6 text-indigo-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
</Link>
</div> </div>
{/* 최근 활동 */} {/* 최근 활동 */}

View File

@ -0,0 +1,122 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, Grid3X3, Palette, Settings, FileText } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenDefinition } from "@/types/screen";
export default function ScreenManagementPage() {
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [activeTab, setActiveTab] = useState("screens");
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 메인 컨텐츠 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="screens" className="flex items-center gap-2">
<Grid3X3 className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="designer" className="flex items-center gap-2">
<Palette className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="templates" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
릿
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 화면 관리 탭 */}
<TabsContent value="screens" className="space-y-4">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<ScreenList onScreenSelect={setSelectedScreen} selectedScreen={selectedScreen} />
</CardContent>
</Card>
</TabsContent>
{/* 화면 설계기 탭 */}
<TabsContent value="designer" className="space-y-4">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
{selectedScreen ? (
<ScreenDesigner screen={selectedScreen} />
) : (
<div className="py-12 text-center text-gray-500">
<Palette className="mx-auto mb-4 h-16 w-16 text-gray-300" />
<p> </p>
<p className="text-sm"> </p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 템플릿 관리 탭 */}
<TabsContent value="templates" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>릿 </CardTitle>
</CardHeader>
<CardContent>
<TemplateManager />
</CardContent>
</Card>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h3 className="mb-2 text-lg font-medium"> </h3>
<p className="text-sm text-gray-600"> .</p>
</div>
<div>
<h3 className="mb-2 text-lg font-medium"> </h3>
<p className="text-sm text-gray-600"> .</p>
</div>
<div>
<h3 className="mb-2 text-lg font-medium"> </h3>
<p className="text-sm text-gray-600"> , .</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -93,6 +93,15 @@ export default function TableManagementPage() {
description: getTextFromUI(option.descriptionKey, option.value), description: getTextFromUI(option.descriptionKey, option.value),
})); }));
// 웹타입 옵션 확인 (디버깅용)
useEffect(() => {
console.log("테이블 타입관리 - 웹타입 옵션 로드됨:", webTypeOptions);
console.log("테이블 타입관리 - 웹타입 옵션 개수:", webTypeOptions.length);
webTypeOptions.forEach((option, index) => {
console.log(`${index + 1}. ${option.value}: ${option.label}`);
});
}, [webTypeOptions]);
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴)
const referenceTableOptions = [ const referenceTableOptions = [
{ value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") }, { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") },
@ -258,16 +267,18 @@ export default function TableManagementPage() {
try { try {
const columnSetting = { const columnSetting = {
columnName: column.columnName, columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, columnLabel: column.displayName, // 사용자가 입력한 표시명
webType: column.webType, webType: column.webType || "text",
detailSettings: column.detailSettings, detailSettings: column.detailSettings || "",
codeCategory: column.codeCategory, codeCategory: column.codeCategory || "",
codeValue: column.codeValue, codeValue: column.codeValue || "",
referenceTable: column.referenceTable, referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn, referenceColumn: column.referenceColumn || "",
}; };
console.log("저장할 컬럼 설정:", columnSetting);
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [ const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
columnSetting, columnSetting,
]); ]);
@ -276,6 +287,11 @@ export default function TableManagementPage() {
toast.success("컬럼 설정이 성공적으로 저장되었습니다."); toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
// 원본 데이터 업데이트 // 원본 데이터 업데이트
setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col))); setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col)));
// 저장 후 데이터 확인을 위해 다시 로드
setTimeout(() => {
loadColumnTypes(selectedTable);
}, 1000);
} else { } else {
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다."); toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
} }
@ -292,16 +308,18 @@ export default function TableManagementPage() {
try { try {
// 모든 컬럼의 설정 데이터 준비 // 모든 컬럼의 설정 데이터 준비
const columnSettings = columns.map((column) => ({ const columnSettings = columns.map((column) => ({
columnName: column.columnName, columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 라벨 추가 columnLabel: column.displayName, // 사용자가 입력한 표시명
webType: column.webType, webType: column.webType || "text",
detailSettings: column.detailSettings, detailSettings: column.detailSettings || "",
codeCategory: column.codeCategory, codeCategory: column.codeCategory || "",
codeValue: column.codeValue, codeValue: column.codeValue || "",
referenceTable: column.referenceTable, referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn, referenceColumn: column.referenceColumn || "",
})); }));
console.log("저장할 전체 컬럼 설정:", columnSettings);
// 전체 테이블 설정을 한 번에 저장 // 전체 테이블 설정을 한 번에 저장
const response = await apiClient.post( const response = await apiClient.post(
`/table-management/tables/${selectedTable}/columns/settings`, `/table-management/tables/${selectedTable}/columns/settings`,
@ -312,6 +330,11 @@ export default function TableManagementPage() {
// 저장 성공 후 원본 데이터 업데이트 // 저장 성공 후 원본 데이터 업데이트
setOriginalColumns([...columns]); setOriginalColumns([...columns]);
toast.success(`${columns.length}개의 컬럼 설정이 성공적으로 저장되었습니다.`); toast.success(`${columns.length}개의 컬럼 설정이 성공적으로 저장되었습니다.`);
// 저장 후 데이터 확인을 위해 다시 로드
setTimeout(() => {
loadColumnTypes(selectedTable);
}, 1000);
} else { } else {
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다."); toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
} }
@ -503,24 +526,30 @@ export default function TableManagementPage() {
</TableCell> </TableCell>
<TableCell className="font-mono text-sm">{column.dbType}</TableCell> <TableCell className="font-mono text-sm">{column.dbType}</TableCell>
<TableCell> <TableCell>
<Select <div className="space-y-2">
value={column.webType || "text"} <Select
onValueChange={(value) => handleWebTypeChange(column.columnName, value)} value={column.webType || "text"}
> onValueChange={(value) => handleWebTypeChange(column.columnName, value)}
<SelectTrigger className="w-32"> >
<SelectValue /> <SelectTrigger className="w-32">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
{webTypeOptions.map((option) => ( <SelectContent>
<SelectItem key={option.value} value={option.value}> {webTypeOptions.map((option) => (
<div> <SelectItem key={option.value} value={option.value}>
<div className="font-medium">{option.label}</div> <div>
<div className="text-xs text-gray-500">{option.description}</div> <div className="font-medium">{option.label}</div>
</div> <div className="text-xs text-gray-500">{option.description}</div>
</SelectItem> </div>
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
{/* 웹타입 옵션 개수 표시 */}
<div className="text-xs text-gray-500">
: {webTypeOptions.length}
</div>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input

View File

@ -64,6 +64,7 @@ const getMenuIcon = (menuName: string) => {
if (name.includes("설정") || name.includes("setting")) return <Settings className="h-4 w-4" />; if (name.includes("설정") || name.includes("setting")) return <Settings className="h-4 w-4" />;
if (name.includes("로그") || name.includes("log")) return <FileText className="h-4 w-4" />; if (name.includes("로그") || name.includes("log")) return <FileText className="h-4 w-4" />;
if (name.includes("메뉴") || name.includes("menu")) return <Menu className="h-4 w-4" />; if (name.includes("메뉴") || name.includes("menu")) return <Menu className="h-4 w-4" />;
if (name.includes("화면관리") || name.includes("screen")) return <FileText className="h-4 w-4" />;
return <FileText className="h-4 w-4" />; return <FileText className="h-4 w-4" />;
}; };

View File

@ -0,0 +1,840 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Palette,
Grid3X3,
Type,
Calendar,
Hash,
CheckSquare,
Radio,
FileText,
Save,
Undo,
Redo,
Eye,
Group,
Ungroup,
Database,
Trash2,
} from "lucide-react";
import {
ScreenDefinition,
ComponentData,
LayoutData,
DragState,
GroupState,
ComponentType,
WebType,
WidgetComponent,
ColumnInfo,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import ContainerComponent from "./layout/ContainerComponent";
import RowComponent from "./layout/RowComponent";
import ColumnComponent from "./layout/ColumnComponent";
import WidgetFactory from "./WidgetFactory";
import TableTypeSelector from "./TableTypeSelector";
import ScreenPreview from "./ScreenPreview";
import TemplateManager from "./TemplateManager";
import StyleEditor from "./StyleEditor";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
interface ScreenDesignerProps {
screen: ScreenDefinition;
}
interface ComponentMoveState {
isMoving: boolean;
movingComponent: ComponentData | null;
originalPosition: { x: number; y: number };
currentPosition: { x: number; y: number };
}
export default function ScreenDesigner({ screen }: ScreenDesignerProps) {
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16 },
});
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
draggedItem: null,
draggedComponent: null,
dragSource: "toolbox",
dropTarget: null,
dragOffset: { x: 0, y: 0 },
});
const [groupState, setGroupState] = useState<GroupState>({
isGrouping: false,
selectedComponents: [],
groupTarget: null,
groupMode: "create",
});
const [moveState, setMoveState] = useState<ComponentMoveState>({
isMoving: false,
movingComponent: null,
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
});
// 기본 컴포넌트 정의
const basicComponents = [
{ type: "text", label: "텍스트 입력", color: "bg-blue-500", icon: "Type" },
{ type: "number", label: "숫자 입력", color: "bg-green-500", icon: "Hash" },
{ type: "date", label: "날짜 선택", color: "bg-purple-500", icon: "Calendar" },
{ type: "select", label: "선택 박스", color: "bg-orange-500", icon: "CheckSquare" },
{ type: "textarea", label: "텍스트 영역", color: "bg-indigo-500", icon: "FileText" },
{ type: "checkbox", label: "체크박스", color: "bg-pink-500", icon: "CheckSquare" },
{ type: "radio", label: "라디오 버튼", color: "bg-yellow-500", icon: "Radio" },
{ type: "file", label: "파일 업로드", color: "bg-red-500", icon: "FileText" },
{ type: "code", label: "코드 입력", color: "bg-gray-500", icon: "Hash" },
{ type: "entity", label: "엔티티 선택", color: "bg-teal-500", icon: "Database" },
];
const layoutComponents = [
{ type: "container", label: "컨테이너", color: "bg-gray-500" },
{ type: "row", label: "행", color: "bg-yellow-500" },
{ type: "column", label: "열", color: "bg-red-500" },
{ type: "group", label: "그룹", color: "bg-teal-500" },
];
// 드래그 시작
const startDrag = useCallback((componentType: ComponentType, source: "toolbox" | "canvas") => {
let componentData: ComponentData;
if (componentType === "widget") {
// 위젯 컴포넌트 생성
componentData = {
id: generateComponentId(componentType),
type: "widget",
position: { x: 0, y: 0 },
size: { width: 6, height: 50 },
label: "새 위젯",
tableName: "",
columnName: "",
widgetType: "text",
required: false,
readonly: false,
};
} else if (componentType === "container") {
// 컨테이너 컴포넌트 생성
componentData = {
id: generateComponentId(componentType),
type: "container",
position: { x: 0, y: 0 },
size: { width: 12, height: 100 },
title: "새 컨테이너",
children: [],
};
} else if (componentType === "row") {
// 행 컴포넌트 생성
componentData = {
id: generateComponentId(componentType),
type: "row",
position: { x: 0, y: 0 },
size: { width: 12, height: 100 },
children: [],
};
} else if (componentType === "column") {
// 열 컴포넌트 생성
componentData = {
id: generateComponentId(componentType),
type: "column",
position: { x: 0, y: 0 },
size: { width: 6, height: 100 },
children: [],
};
} else if (componentType === "group") {
// 그룹 컴포넌트 생성
componentData = {
id: generateComponentId(componentType),
type: "group",
position: { x: 0, y: 0 },
size: { width: 12, height: 200 },
title: "새 그룹",
children: [],
};
} else {
throw new Error(`지원하지 않는 컴포넌트 타입: ${componentType}`);
}
setDragState((prev) => ({
...prev,
isDragging: true,
draggedComponent: componentData,
dragOffset: { x: 0, y: 0 },
}));
}, []);
// 드래그 종료
const endDrag = useCallback(() => {
setDragState({
isDragging: false,
draggedComponent: null,
dragOffset: { x: 0, y: 0 },
});
}, []);
// 컴포넌트 추가
const addComponent = useCallback((component: ComponentData, position: { x: number; y: number }) => {
const newComponent = {
...component,
id: generateComponentId(component.type),
position,
};
setLayout((prev) => ({
...prev,
components: [...prev.components, newComponent],
}));
}, []);
// 컴포넌트 선택
const selectComponent = useCallback((component: ComponentData) => {
setSelectedComponent(component);
}, []);
// 컴포넌트 삭제
const removeComponent = useCallback((componentId: string) => {
setLayout((prev) => ({
...prev,
components: prev.components.filter((c) => c.id !== componentId),
}));
setSelectedComponent(null);
}, []);
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback((componentId: string, path: string, value: any) => {
setLayout((prev) => ({
...prev,
components: prev.components.map((c) => {
if (c.id === componentId) {
const newComponent = { ...c } as any;
const keys = path.split(".");
let current = newComponent;
for (let i = 0; i < keys.length - 1; i++) {
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
return newComponent;
}
return c;
}),
}));
}, []);
// 레이아웃 저장
const saveLayout = useCallback(() => {
console.log("레이아웃 저장:", layout);
// TODO: API 호출로 레이아웃 저장
}, [layout]);
// 컴포넌트 재배치 시작
const startComponentMove = useCallback((component: ComponentData, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setMoveState({
isMoving: true,
movingComponent: component,
originalPosition: { ...component.position },
currentPosition: { ...component.position },
});
setDragState((prev) => ({
...prev,
isDragging: true,
draggedComponent: component,
dragOffset: { x: 0, y: 0 },
}));
}, []);
// 컴포넌트 재배치 중
const handleComponentMove = useCallback(
(e: MouseEvent) => {
if (!moveState.isMoving || !moveState.movingComponent) return;
const canvas = document.getElementById("design-canvas");
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / 50);
const y = Math.floor((e.clientY - rect.top) / 50);
setMoveState((prev) => ({
...prev,
currentPosition: { x, y },
}));
},
[moveState.isMoving, moveState.movingComponent],
);
// 컴포넌트 재배치 완료
const endComponentMove = useCallback(() => {
if (!moveState.isMoving || !moveState.movingComponent) return;
const { movingComponent, currentPosition } = moveState;
// 위치 업데이트
setLayout((prev) => ({
...prev,
components: prev.components.map((c) => (c.id === movingComponent.id ? { ...c, position: currentPosition } : c)),
}));
// 상태 초기화
setMoveState({
isMoving: false,
movingComponent: null,
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
});
setDragState((prev) => ({
...prev,
isDragging: false,
draggedComponent: null,
dragOffset: { x: 0, y: 0 },
}));
}, [moveState]);
// 마우스 이벤트 리스너 등록/해제
useEffect(() => {
if (moveState.isMoving) {
document.addEventListener("mousemove", handleComponentMove);
document.addEventListener("mouseup", endComponentMove);
return () => {
document.removeEventListener("mousemove", handleComponentMove);
document.removeEventListener("mouseup", endComponentMove);
};
}
}, [moveState.isMoving, handleComponentMove, endComponentMove]);
// 컴포넌트 렌더링
const renderComponent = useCallback(
(component: ComponentData) => {
const isSelected = selectedComponent === component;
const isMoving = moveState.isMoving && moveState.movingComponent?.id === component.id;
const currentPosition = isMoving ? moveState.currentPosition : component.position;
switch (component.type) {
case "container":
return (
<ContainerComponent
key={component.id}
component={{
...component,
position: currentPosition,
}}
isSelected={isSelected}
onClick={() => selectComponent(component)}
onMouseDown={(e) => startComponentMove(component, e)}
isMoving={isMoving}
>
{/* 컨테이너 내부의 자식 컴포넌트들 */}
{layout.components.filter((c) => c.parentId === component.id).map(renderComponent)}
</ContainerComponent>
);
case "row":
return (
<RowComponent
key={component.id}
component={{
...component,
position: currentPosition,
}}
isSelected={isSelected}
onClick={() => selectComponent(component)}
onMouseDown={(e) => startComponentMove(component, e)}
isMoving={isMoving}
>
{/* 행 내부의 자식 컴포넌트들 */}
{layout.components.filter((c) => c.parentId === component.id).map(renderComponent)}
</RowComponent>
);
case "column":
return (
<ColumnComponent
key={component.id}
component={{
...component,
position: currentPosition,
}}
isSelected={isSelected}
onClick={() => selectComponent(component)}
onMouseDown={(e) => startComponentMove(component, e)}
isMoving={isMoving}
>
{/* 열 내부의 자식 컴포넌트들 */}
{layout.components.filter((c) => c.parentId === component.id).map(renderComponent)}
</ColumnComponent>
);
case "widget":
return (
<div
key={component.id}
className={`absolute cursor-move rounded border-2 border-transparent hover:border-blue-300 ${
isSelected ? "border-blue-500" : ""
} ${isMoving ? "z-50" : ""}`}
style={{
left: `${currentPosition.x * 50}px`,
top: `${currentPosition.y * 50}px`,
width: `${component.size.width * 50}px`,
height: `${component.size.height}px`,
}}
onClick={() => selectComponent(component)}
onMouseDown={(e) => startComponentMove(component, e)}
>
<WidgetFactory widget={component} />
</div>
);
default:
return null;
}
},
[selectedComponent, moveState, selectComponent, startComponentMove, layout.components],
);
// 테이블 타입에서 컬럼 선택 시 위젯 생성
const handleColumnSelect = useCallback(
(column: ColumnInfo) => {
const widgetComponent: WidgetComponent = {
id: generateComponentId("widget"),
type: "widget",
position: { x: 0, y: 0 },
size: { width: 6, height: 50 },
parentId: undefined,
tableName: column.tableName,
columnName: column.columnName,
widgetType: column.webType || "text",
label: column.columnLabel || column.columnName,
placeholder: `${column.columnLabel || column.columnName}을(를) 입력하세요`,
required: column.isNullable === "NO",
readonly: false,
validationRules: [],
displayProperties: {},
style: {
// 웹 타입별 기본 스타일
...(column.webType === "date" && {
backgroundColor: "#fef3c7",
border: "1px solid #f59e0b",
}),
...(column.webType === "number" && {
backgroundColor: "#dbeafe",
border: "1px solid #3b82f6",
}),
...(column.webType === "select" && {
backgroundColor: "#f3e8ff",
border: "1px solid #8b5cf6",
}),
...(column.webType === "checkbox" && {
backgroundColor: "#dcfce7",
border: "1px solid #22c55e",
}),
...(column.webType === "radio" && {
backgroundColor: "#fef3c7",
border: "1px solid #f59e0b",
}),
...(column.webType === "textarea" && {
backgroundColor: "#f1f5f9",
border: "1px solid #64748b",
}),
...(column.webType === "file" && {
backgroundColor: "#fef2f2",
border: "1px solid #ef4444",
}),
...(column.webType === "code" && {
backgroundColor: "#fef2f2",
border: "1px solid #ef4444",
fontFamily: "monospace",
}),
...(column.webType === "entity" && {
backgroundColor: "#f0f9ff",
border: "1px solid #0ea5e9",
}),
},
};
// 현재 캔버스의 빈 위치 찾기
const occupiedPositions = new Set();
layout.components.forEach((comp) => {
for (let x = comp.position.x; x < comp.position.x + comp.size.width; x++) {
for (let y = comp.position.y; y < comp.position.y + comp.size.height; y++) {
occupiedPositions.add(`${x},${y}`);
}
}
});
// 빈 위치 찾기
let newX = 0,
newY = 0;
for (let y = 0; y < 20; y++) {
for (let x = 0; x < 12; x++) {
let canPlace = true;
for (let dx = 0; dx < widgetComponent.size.width; dx++) {
for (let dy = 0; dy < Math.ceil(widgetComponent.size.height / 50); dy++) {
if (occupiedPositions.has(`${x + dx},${y + dy}`)) {
canPlace = false;
break;
}
}
if (!canPlace) break;
}
if (canPlace) {
newX = x;
newY = y;
break;
}
}
if (newX !== 0 || newY !== 0) break;
}
widgetComponent.position = { x: newX, y: newY };
addComponent(widgetComponent, { x: newX, y: newY });
},
[layout.components, addComponent],
);
return (
<div className="flex h-full space-x-4">
{/* 왼쪽 툴바 */}
<div className="w-64 space-y-4">
{/* 기본 컴포넌트 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{basicComponents.map((component) => (
<div
key={component.type}
className={`flex cursor-pointer items-center rounded border p-2 hover:bg-gray-50 ${
dragState.isDragging && dragState.draggedComponent?.type === "widget"
? "border-blue-500 bg-blue-50"
: "border-gray-200"
}`}
draggable
onDragStart={() => startDrag(component.type as ComponentType, "toolbox")}
onDragEnd={endDrag}
>
<div className={`h-3 w-3 rounded-full ${component.color} mr-2`} />
<span className="text-sm">{component.label}</span>
</div>
))}
</CardContent>
</Card>
{/* 레이아웃 컴포넌트 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{layoutComponents.map((component) => (
<div
key={component.type}
className={`flex cursor-pointer items-center rounded border p-2 hover:bg-gray-50 ${
dragState.isDragging && dragState.draggedComponent?.type === component.type
? "border-blue-500 bg-blue-50"
: "border-gray-200"
}`}
draggable
onDragStart={() => startDrag(component.type as ComponentType, "toolbox")}
onDragEnd={endDrag}
>
<div className={`h-3 w-3 rounded-full ${component.color} mr-2`} />
<span className="text-sm">{component.label}</span>
</div>
))}
</CardContent>
</Card>
</div>
{/* 중앙 메인 영역 */}
<div className="flex-1">
<Tabs defaultValue="design" className="h-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="design" className="flex items-center gap-2">
<Palette className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="flex items-center gap-2">
<Database className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 화면 설계 탭 */}
<TabsContent value="design" className="h-full">
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">{screen.screenName} - </CardTitle>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" onClick={() => console.log("실행 취소")}>
<Undo className="mr-1 h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => console.log("다시 실행")}>
<Redo className="mr-1 h-4 w-4" />
</Button>
<Button onClick={saveLayout}>
<Save className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="h-full">
<div
id="design-canvas"
className="relative h-full w-full rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
onDrop={(e) => {
e.preventDefault();
if (dragState.draggedComponent && dragState.draggedComponent.type === "widget") {
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / 50);
const y = Math.floor((e.clientY - rect.top) / 50);
// 위젯 컴포넌트의 경우 기본 컴포넌트에서 타입 정보를 가져옴
const basicComponent = basicComponents.find(
(c) => c.type === (dragState.draggedComponent as any).widgetType,
);
if (basicComponent) {
const widgetComponent: ComponentData = {
...dragState.draggedComponent,
position: { x, y },
label: basicComponent.label,
widgetType: basicComponent.type as WebType,
} as WidgetComponent;
addComponent(widgetComponent, { x, y });
}
} else if (dragState.draggedComponent) {
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / 50);
const y = Math.floor((e.clientY - rect.top) / 50);
addComponent(dragState.draggedComponent, { x, y });
}
}}
onDragOver={(e) => e.preventDefault()}
>
{/* 그리드 가이드 */}
<div className="pointer-events-none absolute inset-0">
<div className="grid h-full grid-cols-12">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="border-r border-gray-200 last:border-r-0" />
))}
</div>
</div>
{/* 컴포넌트들 렌더링 */}
{layout.components.length > 0 ? (
layout.components
.filter((c) => !c.parentId) // 최상위 컴포넌트만 렌더링
.map(renderComponent)
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<Palette className="mx-auto mb-4 h-16 w-16" />
<p> </p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* 테이블 타입 탭 */}
<TabsContent value="table" className="h-full">
<TableTypeSelector
onTableSelect={(tableName) => console.log("테이블 선택:", tableName)}
onColumnSelect={handleColumnSelect}
className="h-full"
/>
</TabsContent>
{/* 미리보기 탭 */}
<TabsContent value="preview" className="h-full">
<ScreenPreview layout={layout} screenName={screen.screenName} />
</TabsContent>
</Tabs>
</div>
{/* 오른쪽 속성 패널 */}
<div className="w-80">
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
{selectedComponent ? (
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general"></TabsTrigger>
<TabsTrigger value="style">
<Palette className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="advanced"></TabsTrigger>
</TabsList>
{/* 일반 속성 탭 */}
<TabsContent value="general" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="componentId">ID</Label>
<Input id="componentId" value={selectedComponent.id} readOnly className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label htmlFor="componentType"></Label>
<Input id="componentType" value={selectedComponent.type} readOnly className="bg-gray-50" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="positionX">X </Label>
<Input
id="positionX"
type="number"
value={selectedComponent.position.x}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="positionY">Y </Label>
<Input
id="positionY"
type="number"
value={selectedComponent.position.y}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value))
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width"></Label>
<Input
id="width"
type="number"
min="1"
max="12"
value={selectedComponent.size.width}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height"></Label>
<Input
id="height"
type="number"
min="20"
value={selectedComponent.size.height}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value))
}
/>
</div>
</div>
{/* 위젯 전용 속성 */}
{selectedComponent.type === "widget" && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="widgetLabel"></Label>
<Input
id="widgetLabel"
value={selectedComponent.label || ""}
onChange={(e) => updateComponentProperty(selectedComponent.id, "label", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="widgetPlaceholder"></Label>
<Input
id="widgetPlaceholder"
value={selectedComponent.placeholder || ""}
onChange={(e) => updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)}
/>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="required"
checked={selectedComponent.required || false}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "required", e.target.checked)
}
/>
<Label htmlFor="required"></Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="readonly"
checked={selectedComponent.readonly || false}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "readonly", e.target.checked)
}
/>
<Label htmlFor="readonly"> </Label>
</div>
</div>
</>
)}
</TabsContent>
{/* 스타일 속성 탭 */}
<TabsContent value="style" className="space-y-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
/>
</TabsContent>
{/* 고급 속성 탭 */}
<TabsContent value="advanced" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="parentId"> ID</Label>
<Input id="parentId" value={selectedComponent.parentId || ""} readOnly className="bg-gray-50" />
</div>
<Button
variant="destructive"
size="sm"
onClick={() => removeComponent(selectedComponent.id)}
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</TabsContent>
</Tabs>
) : (
<div className="py-8 text-center text-gray-500">
<Palette className="mx-auto mb-2 h-12 w-12 text-gray-300" />
<p> </p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,255 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
interface ScreenListProps {
onScreenSelect: (screen: ScreenDefinition) => void;
selectedScreen: ScreenDefinition | null;
}
export default function ScreenList({ onScreenSelect, selectedScreen }: ScreenListProps) {
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// 샘플 데이터 (실제로는 API에서 가져옴)
useEffect(() => {
const mockScreens: ScreenDefinition[] = [
{
screenId: 1,
screenName: "사용자 관리 화면",
screenCode: "USER_MANAGEMENT",
tableName: "user_info",
companyCode: "COMP001",
description: "사용자 정보를 관리하는 화면",
isActive: "Y",
createdDate: new Date("2024-01-15"),
updatedDate: new Date("2024-01-15"),
createdBy: "admin",
updatedBy: "admin",
},
{
screenId: 2,
screenName: "부서 관리 화면",
screenCode: "DEPT_MANAGEMENT",
tableName: "dept_info",
companyCode: "COMP001",
description: "부서 정보를 관리하는 화면",
isActive: "Y",
createdDate: new Date("2024-01-16"),
updatedDate: new Date("2024-01-16"),
createdBy: "admin",
updatedBy: "admin",
},
{
screenId: 3,
screenName: "제품 관리 화면",
screenCode: "PRODUCT_MANAGEMENT",
tableName: "product_info",
companyCode: "COMP001",
description: "제품 정보를 관리하는 화면",
isActive: "Y",
createdDate: new Date("2024-01-17"),
updatedDate: new Date("2024-01-17"),
createdBy: "admin",
updatedBy: "admin",
},
];
setScreens(mockScreens);
setLoading(false);
}, []);
const filteredScreens = screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
screen.tableName.toLowerCase().includes(searchTerm.toLowerCase()),
);
const handleScreenSelect = (screen: ScreenDefinition) => {
onScreenSelect(screen);
};
const handleEdit = (screen: ScreenDefinition) => {
// 편집 모달 열기
console.log("편집:", screen);
};
const handleDelete = (screen: ScreenDefinition) => {
if (confirm(`"${screen.screenName}" 화면을 삭제하시겠습니까?`)) {
// 삭제 API 호출
console.log("삭제:", screen);
}
};
const handleCopy = (screen: ScreenDefinition) => {
// 복사 모달 열기
console.log("복사:", screen);
};
const handleView = (screen: ScreenDefinition) => {
// 미리보기 모달 열기
console.log("미리보기:", screen);
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="화면명, 코드, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
/>
</div>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 화면 목록 테이블 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({filteredScreens.length})</span>
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredScreens.map((screen) => (
<TableRow
key={screen.screenId}
className={`cursor-pointer hover:bg-gray-50 ${
selectedScreen?.screenId === screen.screenId ? "border-blue-200 bg-blue-50" : ""
}`}
onClick={() => handleScreenSelect(screen)}
>
<TableCell>
<div>
<div className="font-medium text-gray-900">{screen.screenName}</div>
{screen.description && <div className="mt-1 text-sm text-gray-500">{screen.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell>
<span className="font-mono text-sm text-gray-600">{screen.tableName}</span>
</TableCell>
<TableCell>
<Badge
variant={screen.isActive === "Y" ? "default" : "secondary"}
className={screen.isActive === "Y" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}
>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm text-gray-600">{screen.createdDate.toLocaleDateString()}</div>
<div className="text-xs text-gray-400">{screen.createdBy}</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(screen)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(screen)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(screen)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(screen)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filteredScreens.length === 0 && <div className="py-8 text-center text-gray-500"> .</div>}
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-600">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Eye, Smartphone, Monitor, Tablet } from "lucide-react";
import { LayoutData, ComponentData } from "@/types/screen";
import ContainerComponent from "./layout/ContainerComponent";
import RowComponent from "./layout/RowComponent";
import ColumnComponent from "./layout/ColumnComponent";
import WidgetFactory from "./WidgetFactory";
interface ScreenPreviewProps {
layout: LayoutData;
screenName: string;
className?: string;
}
type PreviewMode = "desktop" | "tablet" | "mobile";
export default function ScreenPreview({ layout, screenName, className }: ScreenPreviewProps) {
const [previewMode, setPreviewMode] = useState<PreviewMode>("desktop");
const [formData, setFormData] = useState<Record<string, any>>({});
// 미리보기 모드별 스타일
const getPreviewStyles = (mode: PreviewMode) => {
switch (mode) {
case "desktop":
return "w-full max-w-6xl mx-auto";
case "tablet":
return "w-full max-w-2xl mx-auto border-x-8 border-gray-200";
case "mobile":
return "w-full max-w-sm mx-auto border-x-4 border-gray-200";
default:
return "w-full";
}
};
// 폼 데이터 변경 처리
const handleFormChange = (fieldId: string, value: any) => {
setFormData((prev) => ({
...prev,
[fieldId]: value,
}));
};
// 컴포넌트 렌더링 (미리보기용)
const renderPreviewComponent = (component: ComponentData) => {
const isSelected = false; // 미리보기에서는 선택 불가
switch (component.type) {
case "container":
return (
<ContainerComponent key={component.id} component={component} isSelected={isSelected} onClick={() => {}}>
{layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)}
</ContainerComponent>
);
case "row":
return (
<RowComponent key={component.id} component={component} isSelected={isSelected} onClick={() => {}}>
{layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)}
</RowComponent>
);
case "column":
return (
<ColumnComponent key={component.id} component={component} isSelected={isSelected} onClick={() => {}}>
{layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)}
</ColumnComponent>
);
case "widget":
return (
<div
key={component.id}
className="w-full"
style={{
gridColumn: `span ${component.size.width}`,
}}
>
<WidgetFactory
widget={component}
value={formData[component.id] || ""}
onChange={(value) => handleFormChange(component.id, value)}
/>
</div>
);
default:
return null;
}
};
// 그리드 레이아웃으로 컴포넌트 배치
const renderGridLayout = () => {
const { gridSettings } = layout;
const { columns, gap, padding } = gridSettings;
return (
<div
className="min-h-screen bg-white"
style={{
padding: `${padding}px`,
}}
>
<div
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: `${gap}px`,
}}
>
{layout.components
.filter((c) => !c.parentId) // 최상위 컴포넌트만 렌더링
.map(renderPreviewComponent)}
</div>
</div>
);
};
// 폼 데이터 초기화
const resetFormData = () => {
setFormData({});
};
// 폼 데이터 출력
const logFormData = () => {
console.log("폼 데이터:", formData);
};
return (
<div className={`space-y-4 ${className}`}>
{/* 미리보기 헤더 */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Eye className="h-4 w-4" />
{screenName} -
</CardTitle>
<div className="flex items-center gap-2">
{/* 미리보기 모드 선택 */}
<div className="flex rounded-lg border">
<Button
variant={previewMode === "desktop" ? "default" : "ghost"}
size="sm"
onClick={() => setPreviewMode("desktop")}
className="rounded-r-none"
>
<Monitor className="h-3 w-3" />
</Button>
<Button
variant={previewMode === "tablet" ? "default" : "ghost"}
size="sm"
onClick={() => setPreviewMode("tablet")}
className="rounded-none"
>
<Tablet className="h-3 w-3" />
</Button>
<Button
variant={previewMode === "mobile" ? "default" : "ghost"}
size="sm"
onClick={() => setPreviewMode("mobile")}
className="rounded-l-none"
>
<Smartphone className="h-3 w-3" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={resetFormData}>
</Button>
<Button variant="outline" size="sm" onClick={logFormData}>
</Button>
</div>
</div>
</CardHeader>
</Card>
{/* 미리보기 컨텐츠 */}
<Card>
<CardContent className="p-0">
<div className={`${getPreviewStyles(previewMode)} min-h-screen bg-gray-50`}>
{layout.components.length > 0 ? (
renderGridLayout()
) : (
<div className="flex min-h-screen items-center justify-center text-gray-500">
<div className="text-center">
<Eye className="mx-auto mb-4 h-16 w-16 text-gray-300" />
<p> </p>
<p className="text-sm"> </p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,385 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { Palette, Layout, Type, Square, Box, Eye, RotateCcw } from "lucide-react";
import { ComponentStyle } from "@/types/screen";
interface StyleEditorProps {
style: ComponentStyle;
onStyleChange: (style: ComponentStyle) => void;
className?: string;
}
export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) {
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style);
useEffect(() => {
setLocalStyle(style);
}, [style]);
const handleStyleChange = (property: keyof ComponentStyle, value: any) => {
const newStyle = { ...localStyle, [property]: value };
setLocalStyle(newStyle);
onStyleChange(newStyle);
};
const resetStyle = () => {
const resetStyle: ComponentStyle = {};
setLocalStyle(resetStyle);
onStyleChange(resetStyle);
};
const applyStyle = () => {
onStyleChange(localStyle);
};
return (
<div className={`space-y-4 ${className}`}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Palette className="h-4 w-4" />
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={resetStyle}>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" onClick={applyStyle}>
<Eye className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="layout" className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="layout">
<Layout className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="spacing">
<Box className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="border">
<Square className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="background">
<Palette className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="typography">
<Type className="mr-1 h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 레이아웃 탭 */}
<TabsContent value="layout" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width"></Label>
<Input
id="width"
type="text"
placeholder="100px, 50%, auto"
value={localStyle.width || ""}
onChange={(e) => handleStyleChange("width", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height"></Label>
<Input
id="height"
type="text"
placeholder="100px, 50%, auto"
value={localStyle.height || ""}
onChange={(e) => handleStyleChange("height", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="display"> </Label>
<Select
value={localStyle.display || "block"}
onValueChange={(value) => handleStyleChange("display", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="block">Block</SelectItem>
<SelectItem value="inline">Inline</SelectItem>
<SelectItem value="inline-block">Inline-Block</SelectItem>
<SelectItem value="flex">Flex</SelectItem>
<SelectItem value="grid">Grid</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="position"></Label>
<Select
value={localStyle.position || "static"}
onValueChange={(value) => handleStyleChange("position", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">Static</SelectItem>
<SelectItem value="relative">Relative</SelectItem>
<SelectItem value="absolute">Absolute</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="sticky">Sticky</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{localStyle.display === "flex" && (
<>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="flexDirection"></Label>
<Select
value={localStyle.flexDirection || "row"}
onValueChange={(value) => handleStyleChange("flexDirection", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="row"> (Row)</SelectItem>
<SelectItem value="row-reverse"> </SelectItem>
<SelectItem value="column"> (Column)</SelectItem>
<SelectItem value="column-reverse"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="justifyContent"> </Label>
<Select
value={localStyle.justifyContent || "flex-start"}
onValueChange={(value) => handleStyleChange("justifyContent", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex-start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="flex-end"></SelectItem>
<SelectItem value="space-between"></SelectItem>
<SelectItem value="space-around"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
)}
</TabsContent>
{/* 여백 탭 */}
<TabsContent value="spacing" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="margin"> </Label>
<Input
id="margin"
type="text"
placeholder="10px, 1rem"
value={localStyle.margin || ""}
onChange={(e) => handleStyleChange("margin", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="padding"> </Label>
<Input
id="padding"
type="text"
placeholder="10px, 1rem"
value={localStyle.padding || ""}
onChange={(e) => handleStyleChange("padding", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="gap"></Label>
<Input
id="gap"
type="text"
placeholder="10px, 1rem"
value={localStyle.gap || ""}
onChange={(e) => handleStyleChange("gap", e.target.value)}
/>
</div>
</div>
</TabsContent>
{/* 테두리 탭 */}
<TabsContent value="border" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="borderWidth"> </Label>
<Input
id="borderWidth"
type="text"
placeholder="1px, 2px"
value={localStyle.borderWidth || ""}
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="borderStyle"> </Label>
<Select
value={localStyle.borderStyle || "solid"}
onValueChange={(value) => handleStyleChange("borderStyle", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="dotted"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="borderColor"> </Label>
<Input
id="borderColor"
type="color"
value={localStyle.borderColor || "#000000"}
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="borderRadius"> </Label>
<Input
id="borderRadius"
type="text"
placeholder="5px, 10px"
value={localStyle.borderRadius || ""}
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
/>
</div>
</div>
</TabsContent>
{/* 배경 탭 */}
<TabsContent value="background" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="backgroundColor"> </Label>
<Input
id="backgroundColor"
type="color"
value={localStyle.backgroundColor || "#ffffff"}
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="backgroundImage"> </Label>
<Input
id="backgroundImage"
type="text"
placeholder="url('image.jpg')"
value={localStyle.backgroundImage || ""}
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
/>
</div>
</TabsContent>
{/* 텍스트 탭 */}
<TabsContent value="typography" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="color"> </Label>
<Input
id="color"
type="color"
value={localStyle.color || "#000000"}
onChange={(e) => handleStyleChange("color", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fontSize"> </Label>
<Input
id="fontSize"
type="text"
placeholder="14px, 1rem"
value={localStyle.fontSize || ""}
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fontWeight"> </Label>
<Select
value={localStyle.fontWeight || "normal"}
onValueChange={(value) => handleStyleChange("fontWeight", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal"></SelectItem>
<SelectItem value="bold"></SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="400">400</SelectItem>
<SelectItem value="500">500</SelectItem>
<SelectItem value="600">600</SelectItem>
<SelectItem value="700">700</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="textAlign"> </Label>
<Select
value={localStyle.textAlign || "left"}
onValueChange={(value) => handleStyleChange("textAlign", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
<SelectItem value="justify"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,285 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Database, Columns, Plus } from "lucide-react";
import { ColumnInfo, WebType } from "@/types/screen";
import { tableTypeApi } from "@/lib/api/screen";
import { useAuth } from "@/hooks/useAuth";
interface TableTypeSelectorProps {
onTableSelect?: (tableName: string) => void;
onColumnSelect?: (column: ColumnInfo) => void;
className?: string;
}
export default function TableTypeSelector({ onTableSelect, onColumnSelect, className }: TableTypeSelectorProps) {
const { user } = useAuth();
const [tables, setTables] = useState<
Array<{ tableName: string; displayName: string; description: string; columnCount: string }>
>([]);
const [selectedTable, setSelectedTable] = useState<string>("");
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(false);
// 테이블 목록 조회
useEffect(() => {
const fetchTables = async () => {
try {
setIsLoading(true);
const tableList = await tableTypeApi.getTables();
setTables(tableList);
} catch (error) {
console.error("테이블 목록 조회 실패:", error);
// API 호출 실패 시 기본 테이블 목록 사용
const fallbackTables = [
{ tableName: "user_info", displayName: "사용자 정보", description: "사용자 기본 정보", columnCount: "25" },
{ tableName: "product_info", displayName: "제품 정보", description: "제품 기본 정보", columnCount: "20" },
{ tableName: "order_info", displayName: "주문 정보", description: "주문 기본 정보", columnCount: "15" },
{ tableName: "company_info", displayName: "회사 정보", description: "회사 기본 정보", columnCount: "10" },
{ tableName: "menu_info", displayName: "메뉴 정보", description: "시스템 메뉴 정보", columnCount: "15" },
{ tableName: "auth_group", displayName: "권한 그룹", description: "사용자 권한 그룹", columnCount: "8" },
];
setTables(fallbackTables);
} finally {
setIsLoading(false);
}
};
fetchTables();
}, []);
// 컬럼 정보 조회
useEffect(() => {
if (!selectedTable) {
setColumns([]);
return;
}
const fetchColumns = async () => {
try {
setIsLoading(true);
const columnList = await tableTypeApi.getColumns(selectedTable);
// API 응답을 ColumnInfo 형식으로 변환
const formattedColumns: ColumnInfo[] = columnList.map((col: any) => ({
tableName: selectedTable,
columnName: col.column_name || col.columnName,
columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName,
dataType: col.data_type || col.dataType || "varchar",
webType: col.web_type || col.webType || "text",
isNullable: col.is_nullable || col.isNullable || "YES",
characterMaximumLength: col.character_maximum_length || col.characterMaximumLength,
isVisible: col.is_visible !== false,
displayOrder: col.display_order || col.displayOrder || 1,
}));
setColumns(formattedColumns);
} catch (error) {
console.error("컬럼 정보 조회 실패:", error);
// API 호출 실패 시 기본 컬럼 정보 사용
const fallbackColumns: ColumnInfo[] = [
{
tableName: selectedTable,
columnName: "id",
columnLabel: "ID",
dataType: "integer",
webType: "number",
isNullable: "NO",
isVisible: true,
displayOrder: 1,
},
{
tableName: selectedTable,
columnName: "name",
columnLabel: "이름",
dataType: "varchar",
webType: "text",
isNullable: "NO",
characterMaximumLength: 100,
isVisible: true,
displayOrder: 2,
},
{
tableName: selectedTable,
columnName: "status",
columnLabel: "상태",
dataType: "varchar",
webType: "select",
isNullable: "YES",
characterMaximumLength: 20,
isVisible: true,
displayOrder: 3,
},
{
tableName: selectedTable,
columnName: "created_date",
columnLabel: "생성일",
dataType: "timestamp",
webType: "date",
isNullable: "YES",
isVisible: true,
displayOrder: 4,
},
];
setColumns(fallbackColumns);
} finally {
setIsLoading(false);
}
};
fetchColumns();
}, [selectedTable]);
// 테이블 선택
const handleTableSelect = (tableName: string) => {
setSelectedTable(tableName);
onTableSelect?.(tableName);
};
// 컬럼 선택
const handleColumnSelect = (column: ColumnInfo) => {
onColumnSelect?.(column);
};
// 웹 타입 변경
const handleWebTypeChange = async (columnName: string, webType: WebType) => {
try {
await tableTypeApi.setColumnWebType(selectedTable, columnName, webType);
// 로컬 상태 업데이트
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, webType } : col)));
console.log(`컬럼 ${columnName}의 웹 타입을 ${webType}로 변경했습니다.`);
} catch (error) {
console.error("웹 타입 설정 실패:", error);
alert("웹 타입 설정에 실패했습니다. 다시 시도해주세요.");
}
};
const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase()));
return (
<div className={`space-y-4 ${className}`}>
{/* 테이블 선택 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Database className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<div className="grid max-h-40 grid-cols-2 gap-2 overflow-y-auto">
{filteredTables.map((table) => (
<Button
key={table.tableName}
variant={selectedTable === table.tableName ? "default" : "outline"}
size="sm"
onClick={() => handleTableSelect(table.tableName)}
className="justify-start text-left"
>
<Database className="mr-2 h-3 w-3" />
<div className="flex flex-col items-start">
<span className="font-medium">{table.displayName}</span>
<span className="text-xs text-gray-500">{table.columnCount} </span>
</div>
</Button>
))}
</div>
</CardContent>
</Card>
{/* 컬럼 정보 */}
{selectedTable && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Columns className="h-4 w-4" />
{selectedTable} -
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.columnName}>
<TableCell className="font-mono text-sm">{column.columnName}</TableCell>
<TableCell>{column.columnLabel}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{column.dataType}
</Badge>
</TableCell>
<TableCell>
<Select
value={column.webType || "text"}
onValueChange={(value) => handleWebTypeChange(column.columnName, value as WebType)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
<SelectItem value="textarea"></SelectItem>
<SelectItem value="checkbox"></SelectItem>
<SelectItem value="radio"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Badge variant={column.isNullable === "NO" ? "default" : "secondary"}>
{column.isNullable === "NO" ? "필수" : "선택"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={column.isVisible ? "default" : "secondary"}>
{column.isVisible ? "표시" : "숨김"}
</Badge>
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => handleColumnSelect(column)}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,386 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { Search, Plus, Download, Upload, Trash2, Eye, Edit } from "lucide-react";
import { ScreenTemplate, LayoutData } from "@/types/screen";
import { templateApi } from "@/lib/api/screen";
import { useAuth } from "@/hooks/useAuth";
interface TemplateManagerProps {
onTemplateSelect?: (template: ScreenTemplate) => void;
onTemplateApply?: (template: ScreenTemplate) => void;
className?: string;
}
export default function TemplateManager({ onTemplateSelect, onTemplateApply, className }: TemplateManagerProps) {
const { user } = useAuth();
const [templates, setTemplates] = useState<ScreenTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<ScreenTemplate | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState("my");
// 템플릿 목록 조회
useEffect(() => {
const fetchTemplates = async () => {
try {
setIsLoading(true);
const templateList = await templateApi.getTemplates({
companyCode: user?.company_code || "*",
});
setTemplates(templateList);
} catch (error) {
console.error("템플릿 목록 조회 실패:", error);
// API 호출 실패 시 기본 템플릿 목록 사용
const fallbackTemplates: ScreenTemplate[] = [
{
templateId: 1,
templateName: "기본 CRUD 화면",
templateType: "CRUD",
companyCode: "*",
description: "기본적인 CRUD 기능을 제공하는 화면 템플릿",
layoutData: {
components: [
{
id: "search-container",
type: "container",
position: { x: 0, y: 0 },
size: { width: 12, height: 100 },
title: "검색 영역",
children: [],
},
{
id: "table-container",
type: "container",
position: { x: 0, y: 1 },
size: { width: 12, height: 300 },
title: "데이터 테이블",
children: [],
},
{
id: "form-container",
type: "container",
position: { x: 0, y: 2 },
size: { width: 12, height: 200 },
title: "입력 폼",
children: [],
},
],
gridSettings: { columns: 12, gap: 16, padding: 16 },
},
isPublic: true,
createdBy: "admin",
createdDate: new Date(),
},
{
templateId: 2,
templateName: "목록 화면",
templateType: "LIST",
companyCode: "*",
description: "데이터 목록을 표시하는 화면 템플릿",
layoutData: {
components: [
{
id: "filter-container",
type: "container",
position: { x: 0, y: 0 },
size: { width: 12, height: 80 },
title: "필터 영역",
children: [],
},
{
id: "list-container",
type: "container",
position: { x: 0, y: 1 },
size: { width: 12, height: 400 },
title: "목록 영역",
children: [],
},
],
gridSettings: { columns: 12, gap: 16, padding: 16 },
},
isPublic: true,
createdBy: "admin",
createdDate: new Date(),
},
{
templateId: 3,
templateName: "상세 화면",
templateType: "DETAIL",
companyCode: "*",
description: "데이터 상세 정보를 표시하는 화면 템플릿",
layoutData: {
components: [
{
id: "header-container",
type: "container",
position: { x: 0, y: 0 },
size: { width: 12, height: 60 },
title: "헤더 영역",
children: [],
},
{
id: "detail-container",
type: "container",
position: { x: 0, y: 1 },
size: { width: 12, height: 400 },
title: "상세 정보",
children: [],
},
{
id: "action-container",
type: "container",
position: { x: 0, y: 2 },
size: { width: 12, height: 80 },
title: "액션 버튼",
children: [],
},
],
gridSettings: { columns: 12, gap: 16, padding: 16 },
},
isPublic: true,
createdBy: "admin",
createdDate: new Date(),
},
];
setTemplates(fallbackTemplates);
} finally {
setIsLoading(false);
}
};
fetchTemplates();
}, [user?.company_code]);
// 템플릿 검색
const filteredTemplates = templates.filter(
(template) =>
template.templateName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(template.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
);
// 템플릿 선택
const handleTemplateSelect = (template: ScreenTemplate) => {
setSelectedTemplate(template);
onTemplateSelect?.(template);
};
// 템플릿 적용
const handleTemplateApply = (template: ScreenTemplate) => {
onTemplateApply?.(template);
};
// 템플릿 삭제
const handleTemplateDelete = async (templateId: number) => {
if (!confirm("정말로 이 템플릿을 삭제하시겠습니까?")) return;
try {
await templateApi.deleteTemplate(templateId);
setTemplates((prev) => prev.filter((t) => t.templateId !== templateId));
if (selectedTemplate?.templateId === templateId) {
setSelectedTemplate(null);
}
alert("템플릿이 삭제되었습니다.");
} catch (error) {
console.error("템플릿 삭제 실패:", error);
alert("템플릿 삭제에 실패했습니다. 다시 시도해주세요.");
}
};
// 새 템플릿 생성
const handleCreateTemplate = () => {
// TODO: 새 템플릿 생성 모달 또는 페이지로 이동
console.log("새 템플릿 생성");
};
// 템플릿 내보내기
const handleExportTemplate = (template: ScreenTemplate) => {
const dataStr = JSON.stringify(template, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.href = url;
link.download = `${template.templateName}.json`;
link.click();
URL.revokeObjectURL(url);
};
return (
<div className={`space-y-4 ${className}`}>
{/* 헤더 */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium"> 릿 </CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleCreateTemplate}>
<Plus className="mr-1 h-3 w-3" /> 릿
</Button>
<Button variant="outline" size="sm">
<Upload className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="템플릿명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* 템플릿 목록 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* 템플릿 카드 목록 */}
<div className="space-y-3">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="my"> 릿</TabsTrigger>
<TabsTrigger value="public"> 릿</TabsTrigger>
</TabsList>
</Tabs>
<div className="max-h-96 space-y-3 overflow-y-auto">
{filteredTemplates
.filter((template) => (activeTab === "my" ? template.companyCode !== "*" : template.isPublic))
.map((template) => (
<Card
key={template.templateId}
className={`cursor-pointer transition-all hover:shadow-md ${
selectedTemplate?.templateId === template.templateId ? "ring-2 ring-blue-500" : ""
}`}
onClick={() => handleTemplateSelect(template)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="mb-1 text-sm font-medium">{template.templateName}</h3>
<p className="mb-2 text-xs text-gray-600">{template.description || "설명 없음"}</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{template.templateType}
</Badge>
{template.isPublic && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
<span className="text-xs text-gray-500">
{template.createdBy} {template.createdDate.toLocaleDateString()}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleTemplateApply(template);
}}
>
<Eye className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleExportTemplate(template);
}}
>
<Download className="h-3 w-3" />
</Button>
{template.companyCode !== "*" && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleTemplateDelete(template.templateId);
}}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* 선택된 템플릿 상세 정보 */}
<div>
{selectedTemplate ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">릿 </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">릿</label>
<p className="text-sm text-gray-600">{selectedTemplate.templateName}</p>
</div>
<div>
<label className="text-sm font-medium"></label>
<p className="text-sm text-gray-600">{selectedTemplate.description}</p>
</div>
<div>
<label className="text-sm font-medium"></label>
<Badge variant="outline" className="text-xs">
{selectedTemplate.templateType}
</Badge>
</div>
<div>
<label className="text-sm font-medium"> </label>
<p className="text-sm text-gray-600">{selectedTemplate.layoutData?.components?.length || 0}</p>
</div>
<div>
<label className="text-sm font-medium"> </label>
<p className="text-sm text-gray-600">
{selectedTemplate.layoutData?.gridSettings?.columns || 12} , :{" "}
{selectedTemplate.layoutData?.gridSettings?.gap || 16}px
</p>
</div>
<Separator />
<div className="flex gap-2">
<Button className="flex-1" onClick={() => handleTemplateApply(selectedTemplate)}>
<Plus className="mr-1 h-3 w-3" />
릿
</Button>
<Button variant="outline" className="flex-1">
<Edit className="mr-1 h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-8 text-center text-gray-500">
<Eye className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<p>릿 </p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,209 @@
"use client";
import { WidgetComponent } from "@/types/screen";
import InputWidget from "./widgets/InputWidget";
import SelectWidget from "./widgets/SelectWidget";
import TextareaWidget from "./widgets/TextareaWidget";
import { Calendar, CheckSquare, Radio, FileText, Hash, Database } from "lucide-react";
interface WidgetFactoryProps {
widget: WidgetComponent;
value?: string;
onChange?: (value: string) => void;
className?: string;
}
export default function WidgetFactory({ widget, value, onChange, className }: WidgetFactoryProps) {
// 웹 타입에 따라 적절한 컴포넌트 렌더링
switch (widget.widgetType) {
case "text":
return <InputWidget widget={widget} value={value} onChange={onChange} className={className} />;
case "number":
return <InputWidget widget={widget} value={value} onChange={onChange} className={className} />;
case "date":
return (
<div className={`space-y-2 ${className}`}>
{widget.label && (
<label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="relative">
<Calendar className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
id={widget.id}
type="date"
placeholder={widget.placeholder || "날짜를 선택하세요"}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
required={widget.required}
readOnly={widget.readonly}
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
);
case "code":
return (
<div className={`space-y-2 ${className}`}>
{widget.label && (
<label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="relative">
<Hash className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
id={widget.id}
type="text"
placeholder={widget.placeholder || "코드를 입력하세요"}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
required={widget.required}
readOnly={widget.readonly}
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
);
case "entity":
return (
<div className={`space-y-2 ${className}`}>
{widget.label && (
<label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="relative">
<Database className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
id={widget.id}
type="text"
placeholder={widget.placeholder || "엔티티를 선택하세요"}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
required={widget.required}
readOnly={widget.readonly}
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
);
case "file":
return (
<div className={`space-y-2 ${className}`}>
{widget.label && (
<label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="relative">
<FileText className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
id={widget.id}
type="file"
onChange={(e) => onChange?.(e.target.files?.[0]?.name || "")}
required={widget.required}
disabled={widget.readonly}
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm file:mr-4 file:rounded-md file:border-0 file:bg-blue-50 file:px-4 file:py-1 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
);
case "select":
return <SelectWidget widget={widget} value={value} onChange={onChange} className={className} />;
case "checkbox":
return (
<div className={`space-y-2 ${className}`}>
<div className="flex items-center space-x-2">
<CheckSquare className="h-4 w-4 text-gray-400" />
<input
id={widget.id}
type="checkbox"
checked={value === "true"}
onChange={(e) => onChange?.(e.target.checked ? "true" : "false")}
required={widget.required}
disabled={widget.readonly}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{widget.label && (
<label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
</div>
</div>
);
case "radio":
return (
<div className={`space-y-2 ${className}`}>
{widget.label && (
<label className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Radio className="h-4 w-4 text-gray-400" />
<input
id={`${widget.id}-yes`}
name={widget.id}
type="radio"
value="yes"
checked={value === "yes"}
onChange={(e) => onChange?.(e.target.value)}
required={widget.required}
disabled={widget.readonly}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor={`${widget.id}-yes`} className="text-sm">
</label>
</div>
<div className="flex items-center space-x-2">
<Radio className="h-4 w-4 text-gray-400" />
<input
id={`${widget.id}-no`}
name={widget.id}
type="radio"
value="no"
checked={value === "no"}
onChange={(e) => onChange?.(e.target.value)}
required={widget.required}
disabled={widget.readonly}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor={`${widget.id}-no`} className="text-sm">
</label>
</div>
</div>
</div>
);
case "textarea":
return <TextareaWidget widget={widget} value={value} onChange={onChange} className={className} />;
default:
return (
<div className={`rounded border border-red-300 bg-red-50 p-4 text-red-600 ${className}`}>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-red-500">: {widget.widgetType}</p>
</div>
);
}
}

View File

@ -0,0 +1,78 @@
"use client";
import { cn } from "@/lib/utils";
import { ColumnComponent as ColumnComponentType } from "@/types/screen";
interface ColumnComponentProps {
component: ColumnComponentType;
children?: React.ReactNode;
className?: string;
onClick?: () => void;
isSelected?: boolean;
onMouseDown?: (e: React.MouseEvent) => void;
isMoving?: boolean;
}
export default function ColumnComponent({
component,
children,
className,
onClick,
isSelected,
onMouseDown,
isMoving,
}: ColumnComponentProps) {
// 스타일 객체 생성
const style: React.CSSProperties = {
gridColumn: `span ${component.size.width}`,
minHeight: `${component.size.height}px`,
...(component.style && {
width: component.style.width,
height: component.style.height,
margin: component.style.margin,
padding: component.style.padding,
backgroundColor: component.style.backgroundColor,
border: component.style.border,
borderRadius: component.style.borderRadius,
boxShadow: component.style.boxShadow,
display: component.style.display || "flex",
flexDirection: component.style.flexDirection || "column",
justifyContent: component.style.justifyContent,
alignItems: component.style.alignItems,
gap: component.style.gap,
color: component.style.color,
fontSize: component.style.fontSize,
fontWeight: component.style.fontWeight,
textAlign: component.style.textAlign,
position: component.style.position,
zIndex: component.style.zIndex,
opacity: component.style.opacity,
overflow: component.style.overflow,
cursor: component.style.cursor,
transition: component.style.transition,
transform: component.style.transform,
}),
...(isMoving && {
zIndex: 50,
opacity: 0.8,
transform: "scale(1.02)",
}),
};
return (
<div
className={cn(
"flex-1 rounded border border-gray-200 p-2",
isSelected && "border-blue-500 bg-blue-50",
isMoving && "cursor-move shadow-lg",
className,
)}
style={style}
onClick={onClick}
onMouseDown={onMouseDown}
>
<div className="mb-2 text-xs text-gray-500"></div>
{children}
</div>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { cn } from "@/lib/utils";
import { ContainerComponent as ContainerComponentType } from "@/types/screen";
interface ContainerComponentProps {
component: ContainerComponentType;
children?: React.ReactNode;
className?: string;
onClick?: () => void;
isSelected?: boolean;
onMouseDown?: (e: React.MouseEvent) => void;
isMoving?: boolean;
}
export default function ContainerComponent({
component,
children,
className,
onClick,
isSelected,
onMouseDown,
isMoving,
}: ContainerComponentProps) {
// 스타일 객체 생성
const style: React.CSSProperties = {
gridColumn: `span ${component.size.width}`,
minHeight: `${component.size.height}px`,
...(component.style && {
width: component.style.width,
height: component.style.height,
margin: component.style.margin,
padding: component.style.padding,
backgroundColor: component.style.backgroundColor,
border: component.style.border,
borderRadius: component.style.borderRadius,
boxShadow: component.style.boxShadow,
display: component.style.display,
flexDirection: component.style.flexDirection,
justifyContent: component.style.justifyContent,
alignItems: component.style.alignItems,
gap: component.style.gap,
color: component.style.color,
fontSize: component.style.fontSize,
fontWeight: component.style.fontWeight,
textAlign: component.style.textAlign,
position: component.style.position,
zIndex: component.style.zIndex,
opacity: component.style.opacity,
overflow: component.style.overflow,
cursor: component.style.cursor,
transition: component.style.transition,
transform: component.style.transform,
}),
...(isMoving && {
zIndex: 50,
opacity: 0.8,
transform: "scale(1.02)",
}),
};
return (
<div
className={cn(
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
isSelected && "border-blue-500 bg-blue-50",
isMoving && "cursor-move shadow-lg",
className,
)}
style={style}
onClick={onClick}
onMouseDown={onMouseDown}
>
<div className="mb-2 text-xs text-gray-500">{component.title || "컨테이너"}</div>
{children}
</div>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { cn } from "@/lib/utils";
import { RowComponent as RowComponentType } from "@/types/screen";
interface RowComponentProps {
component: RowComponentType;
children?: React.ReactNode;
className?: string;
onClick?: () => void;
isSelected?: boolean;
onMouseDown?: (e: React.MouseEvent) => void;
isMoving?: boolean;
}
export default function RowComponent({
component,
children,
className,
onClick,
isSelected,
onMouseDown,
isMoving,
}: RowComponentProps) {
// 스타일 객체 생성
const style: React.CSSProperties = {
gridColumn: `span ${component.size.width}`,
minHeight: `${component.size.height}px`,
...(component.style && {
width: component.style.width,
height: component.style.height,
margin: component.style.margin,
padding: component.style.padding,
backgroundColor: component.style.backgroundColor,
border: component.style.border,
borderRadius: component.style.borderRadius,
boxShadow: component.style.boxShadow,
display: component.style.display || "flex",
flexDirection: component.style.flexDirection || "row",
justifyContent: component.style.justifyContent,
alignItems: component.style.alignItems,
gap: component.style.gap,
color: component.style.color,
fontSize: component.style.fontSize,
fontWeight: component.style.fontWeight,
textAlign: component.style.textAlign,
position: component.style.position,
zIndex: component.style.zIndex,
opacity: component.style.opacity,
overflow: component.style.overflow,
cursor: component.style.cursor,
transition: component.style.transition,
transform: component.style.transform,
}),
...(isMoving && {
zIndex: 50,
opacity: 0.8,
transform: "scale(1.02)",
}),
};
return (
<div
className={cn(
"flex gap-4 rounded border border-gray-200 p-2",
isSelected && "border-blue-500 bg-blue-50",
isMoving && "cursor-move shadow-lg",
className,
)}
style={style}
onClick={onClick}
onMouseDown={onMouseDown}
>
<div className="mr-2 text-xs text-gray-500"></div>
{children}
</div>
);
}

View File

@ -0,0 +1,40 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { WidgetComponent } from "@/types/screen";
interface InputWidgetProps {
widget: WidgetComponent;
value?: string;
onChange?: (value: string) => void;
className?: string;
}
export default function InputWidget({ widget, value, onChange, className }: InputWidgetProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value);
};
return (
<div className={cn("space-y-2", className)}>
{widget.label && (
<Label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</Label>
)}
<Input
id={widget.id}
type={widget.widgetType === "number" ? "number" : "text"}
placeholder={widget.placeholder}
value={value || ""}
onChange={handleChange}
required={widget.required}
readOnly={widget.readonly}
className={cn("w-full", widget.readonly && "cursor-not-allowed bg-gray-50")}
/>
</div>
);
}

View File

@ -0,0 +1,69 @@
"use client";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { WidgetComponent } from "@/types/screen";
interface SelectWidgetProps {
widget: WidgetComponent;
value?: string;
onChange?: (value: string) => void;
options?: { value: string; label: string }[];
className?: string;
}
export default function SelectWidget({ widget, value, onChange, options = [], className }: SelectWidgetProps) {
const handleChange = (newValue: string) => {
onChange?.(newValue);
};
// 위젯 타입에 따른 기본 옵션 생성
const getDefaultOptions = () => {
switch (widget.widgetType) {
case "select":
return [
{ value: "option1", label: "옵션 1" },
{ value: "option2", label: "옵션 2" },
{ value: "option3", label: "옵션 3" },
];
case "checkbox":
return [
{ value: "true", label: "체크됨" },
{ value: "false", label: "체크 안됨" },
];
case "radio":
return [
{ value: "yes", label: "예" },
{ value: "no", label: "아니오" },
];
default:
return options.length > 0 ? options : [{ value: "default", label: "기본값" }];
}
};
const displayOptions = options.length > 0 ? options : getDefaultOptions();
return (
<div className={cn("space-y-2", className)}>
{widget.label && (
<Label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</Label>
)}
<Select value={value} onValueChange={handleChange} disabled={widget.readonly}>
<SelectTrigger className="w-full">
<SelectValue placeholder={widget.placeholder || "선택해주세요"} />
</SelectTrigger>
<SelectContent>
{displayOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { WidgetComponent } from "@/types/screen";
interface TextareaWidgetProps {
widget: WidgetComponent;
value?: string;
onChange?: (value: string) => void;
className?: string;
}
export default function TextareaWidget({ widget, value, onChange, className }: TextareaWidgetProps) {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e.target.value);
};
return (
<div className={cn("space-y-2", className)}>
{widget.label && (
<Label htmlFor={widget.id} className="text-sm font-medium">
{widget.label}
{widget.required && <span className="ml-1 text-red-500">*</span>}
</Label>
)}
<Textarea
id={widget.id}
placeholder={widget.placeholder}
value={value || ""}
onChange={handleChange}
required={widget.required}
readOnly={widget.readonly}
className={cn("min-h-[100px] w-full", widget.readonly && "cursor-not-allowed bg-gray-50")}
/>
</div>
);
}

View File

@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("bg-border shrink-0", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -38,6 +38,16 @@ export const TABLE_MANAGEMENT_KEYS = {
WEB_TYPE_CODE_DESC: "table.management.web.type.code.description", WEB_TYPE_CODE_DESC: "table.management.web.type.code.description",
WEB_TYPE_ENTITY: "table.management.web.type.entity", WEB_TYPE_ENTITY: "table.management.web.type.entity",
WEB_TYPE_ENTITY_DESC: "table.management.web.type.entity.description", WEB_TYPE_ENTITY_DESC: "table.management.web.type.entity.description",
WEB_TYPE_TEXTAREA: "table.management.web.type.textarea",
WEB_TYPE_TEXTAREA_DESC: "table.management.web.type.textarea.description",
WEB_TYPE_SELECT: "table.management.web.type.select",
WEB_TYPE_SELECT_DESC: "table.management.web.type.select.description",
WEB_TYPE_CHECKBOX: "table.management.web.type.checkbox",
WEB_TYPE_CHECKBOX_DESC: "table.management.web.type.checkbox.description",
WEB_TYPE_RADIO: "table.management.web.type.radio",
WEB_TYPE_RADIO_DESC: "table.management.web.type.radio.description",
WEB_TYPE_FILE: "table.management.web.type.file",
WEB_TYPE_FILE_DESC: "table.management.web.type.file.description",
// 공통 UI 요소 // 공통 UI 요소
BUTTON_REFRESH: "table.management.button.refresh", BUTTON_REFRESH: "table.management.button.refresh",
@ -100,4 +110,29 @@ export const WEB_TYPE_OPTIONS_WITH_KEYS = [
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_ENTITY, labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_ENTITY,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_ENTITY_DESC, descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_ENTITY_DESC,
}, },
{
value: "textarea",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEXTAREA,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEXTAREA_DESC,
},
{
value: "select",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_SELECT,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_SELECT_DESC,
},
{
value: "checkbox",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_CHECKBOX,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_CHECKBOX_DESC,
},
{
value: "radio",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_RADIO,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_RADIO_DESC,
},
{
value: "file",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE_DESC,
},
] as const; ] as const;

View File

@ -23,6 +23,7 @@ interface UserInfo {
isAdmin: boolean; isAdmin: boolean;
sabun?: string; sabun?: string;
photo?: string | null; photo?: string | null;
company_code?: string; // 회사 코드 추가
} }
// 인증 상태 타입 정의 // 인증 상태 타입 정의

123
frontend/lib/api/screen.ts Normal file
View File

@ -0,0 +1,123 @@
import { apiClient } from "./client";
import {
ScreenDefinition,
CreateScreenRequest,
UpdateScreenRequest,
PaginatedResponse,
ScreenTemplate,
LayoutData,
} from "@/types/screen";
// 화면 정의 관련 API
export const screenApi = {
// 화면 목록 조회
getScreens: async (params: {
page?: number;
size?: number;
companyCode?: string;
searchTerm?: string;
}): Promise<PaginatedResponse<ScreenDefinition>> => {
const response = await apiClient.get("/screen-management/screens", { params });
return response.data;
},
// 화면 상세 조회
getScreen: async (screenId: number): Promise<ScreenDefinition> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}`);
return response.data.data;
},
// 화면 생성
createScreen: async (screenData: CreateScreenRequest): Promise<ScreenDefinition> => {
const response = await apiClient.post("/screen-management/screens", screenData);
return response.data.data;
},
// 화면 수정
updateScreen: async (screenId: number, screenData: UpdateScreenRequest): Promise<ScreenDefinition> => {
const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData);
return response.data.data;
},
// 화면 삭제
deleteScreen: async (screenId: number): Promise<void> => {
await apiClient.delete(`/screen-management/screens/${screenId}`);
},
// 화면 레이아웃 저장
saveLayout: async (screenId: number, layoutData: LayoutData): Promise<void> => {
await apiClient.post(`/screen-management/screens/${screenId}/layout`, layoutData);
},
// 화면 레이아웃 조회
getLayout: async (screenId: number): Promise<LayoutData> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
return response.data.data;
},
};
// 템플릿 관련 API
export const templateApi = {
// 템플릿 목록 조회
getTemplates: async (params: {
companyCode?: string;
templateType?: string;
isPublic?: boolean;
}): Promise<ScreenTemplate[]> => {
const response = await apiClient.get("/screen-management/templates", { params });
return response.data.data;
},
// 템플릿 상세 조회
getTemplate: async (templateId: number): Promise<ScreenTemplate> => {
const response = await apiClient.get(`/screen-management/templates/${templateId}`);
return response.data.data;
},
// 템플릿 생성
createTemplate: async (templateData: Partial<ScreenTemplate>): Promise<ScreenTemplate> => {
const response = await apiClient.post("/screen-management/templates", templateData);
return response.data.data;
},
// 템플릿 수정
updateTemplate: async (templateId: number, templateData: Partial<ScreenTemplate>): Promise<ScreenTemplate> => {
const response = await apiClient.put(`/screen-management/templates/${templateId}`, templateData);
return response.data.data;
},
// 템플릿 삭제
deleteTemplate: async (templateId: number): Promise<void> => {
await apiClient.delete(`/screen-management/templates/${templateId}`);
},
};
// 테이블 타입 관련 API
export const tableTypeApi = {
// 테이블 목록 조회
getTables: async (): Promise<
Array<{ tableName: string; displayName: string; description: string; columnCount: string }>
> => {
const response = await apiClient.get("/table-management/tables");
return response.data.data;
},
// 테이블 컬럼 정보 조회
getColumns: async (tableName: string): Promise<any[]> => {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
return response.data.data;
},
// 컬럼 웹 타입 설정
setColumnWebType: async (
tableName: string,
columnName: string,
webType: string,
detailSettings?: Record<string, any>,
): Promise<void> => {
await apiClient.put(`/table-management/tables/${tableName}/columns/${columnName}/web-type`, {
webType,
detailSettings,
});
},
};

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()}`;
}

View File

@ -15,6 +15,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
@ -1777,6 +1778,29 @@
} }
} }
}, },
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",

View File

@ -20,6 +20,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",

343
frontend/types/screen.ts Normal file
View File

@ -0,0 +1,343 @@
// 화면관리 시스템 타입 정의
// 기본 컴포넌트 타입
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 ComponentStyle {
// 레이아웃
width?: string | number;
height?: string | number;
minWidth?: string | number;
minHeight?: string | number;
maxWidth?: string | number;
maxHeight?: string | number;
// 여백
margin?: string;
marginTop?: string | number;
marginRight?: string | number;
marginBottom?: string | number;
marginLeft?: string | number;
// 패딩
padding?: string;
paddingTop?: string | number;
paddingRight?: string | number;
paddingBottom?: string | number;
paddingLeft?: string | number;
// 테두리
border?: string;
borderWidth?: string | number;
borderStyle?: "solid" | "dashed" | "dotted" | "none";
borderColor?: string;
borderRadius?: string | number;
// 배경
backgroundColor?: string;
backgroundImage?: string;
backgroundSize?: "cover" | "contain" | "auto";
backgroundPosition?: string;
backgroundRepeat?: "repeat" | "no-repeat" | "repeat-x" | "repeat-y";
// 텍스트
color?: string;
fontSize?: string | number;
fontWeight?: "normal" | "bold" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
fontFamily?: string;
textAlign?: "left" | "center" | "right" | "justify";
lineHeight?: string | number;
textDecoration?: "none" | "underline" | "line-through";
// 정렬
display?: "block" | "inline" | "inline-block" | "flex" | "grid" | "none";
flexDirection?: "row" | "row-reverse" | "column" | "column-reverse";
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
alignItems?: "stretch" | "flex-start" | "flex-end" | "center" | "baseline";
gap?: string | number;
// 위치
position?: "static" | "relative" | "absolute" | "fixed" | "sticky";
top?: string | number;
right?: string | number;
bottom?: string | number;
left?: string | number;
zIndex?: number;
// 그림자
boxShadow?: string;
// 기타
opacity?: number;
overflow?: "visible" | "hidden" | "scroll" | "auto";
cursor?: string;
transition?: string;
transform?: string;
}
// BaseComponent에 스타일 속성 추가
export interface BaseComponent {
id: string;
type: ComponentType;
position: { x: number; y: number };
size: { width: number; height: number };
parentId?: string;
style?: ComponentStyle; // 스타일 속성 추가
}
// 컨테이너 컴포넌트
export interface ContainerComponent extends BaseComponent {
type: "container";
title?: string;
backgroundColor?: string;
border?: string;
borderRadius?: number;
shadow?: string;
children?: string[]; // 자식 컴포넌트 ID 목록
}
// 그룹 컴포넌트
export interface GroupComponent extends BaseComponent {
type: "group";
title?: string;
backgroundColor?: string;
border?: string;
borderRadius?: number;
shadow?: string;
collapsible?: boolean;
collapsed?: boolean;
children?: string[]; // 자식 컴포넌트 ID 목록
}
// 행 컴포넌트
export interface RowComponent extends BaseComponent {
type: "row";
gap?: number;
alignItems?: "start" | "center" | "end" | "stretch";
justifyContent?: "start" | "center" | "end" | "space-between" | "space-around";
children?: string[]; // 자식 컴포넌트 ID 목록
}
// 열 컴포넌트
export interface ColumnComponent extends BaseComponent {
type: "column";
gap?: number;
alignItems?: "start" | "center" | "end" | "stretch";
justifyContent?: "start" | "center" | "end" | "space-between" | "space-around";
children?: string[]; // 자식 컴포넌트 ID 목록
}
// 위젯 컴포넌트
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;
updatedDate: Date;
createdBy?: string;
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?: boolean;
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;
draggedComponent?: ComponentData | null; // 컴포넌트 재배치용
dragSource: "toolbox" | "canvas";
dropTarget: string | null;
dropZone?: DropZone;
dragOffset?: { x: number; y: number }; // 드래그 오프셋
}
// 드롭 영역
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[];
total: number;
page: number;
size: number;
totalPages: number;
}