Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into node-nonquery
This commit is contained in:
commit
d9b859d62a
|
|
@ -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();
|
||||||
|
|
@ -3491,7 +3491,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5025,3 +5025,123 @@ model work_mail_list {
|
||||||
|
|
||||||
@@ignore
|
@@ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
|
||||||
|
model zz_230410_user_info {
|
||||||
|
sabun String? @db.VarChar(1024)
|
||||||
|
user_id String? @db.VarChar(1024)
|
||||||
|
user_password String? @db.VarChar(1024)
|
||||||
|
user_name String? @db.VarChar(1024)
|
||||||
|
user_name_eng String? @db.VarChar(1024)
|
||||||
|
user_name_cn String? @db.VarChar(1024)
|
||||||
|
dept_code String? @db.VarChar(1024)
|
||||||
|
dept_name String? @db.VarChar(1024)
|
||||||
|
position_code String? @db.VarChar(1024)
|
||||||
|
position_name String? @db.VarChar(1024)
|
||||||
|
email String? @db.VarChar(1024)
|
||||||
|
tel String? @db.VarChar(1024)
|
||||||
|
cell_phone String? @db.VarChar(1024)
|
||||||
|
user_type String? @db.VarChar(1024)
|
||||||
|
user_type_name String? @db.VarChar(1024)
|
||||||
|
regdate DateTime? @db.Timestamp(6)
|
||||||
|
data_type String? @db.VarChar(64)
|
||||||
|
status String? @db.VarChar(32)
|
||||||
|
end_date DateTime? @db.Timestamp(6)
|
||||||
|
fax_no String? @db.VarChar
|
||||||
|
|
||||||
|
@@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])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { Response } from "express";
|
||||||
|
import { screenManagementService } from "../services/screenManagementService";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
|
// 화면 목록 조회
|
||||||
|
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const screens = await screenManagementService.getScreens(companyCode);
|
||||||
|
res.json({ success: true, data: screens });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 목록 조회 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "화면 목록 조회에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 생성
|
||||||
|
export const createScreen = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const screenData = { ...req.body, companyCode };
|
||||||
|
const newScreen = await screenManagementService.createScreen(
|
||||||
|
screenData,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
res.status(201).json({ success: true, data: newScreen });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 생성 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "화면 생성에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 수정
|
||||||
|
export const updateScreen = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const updateData = { ...req.body, companyCode };
|
||||||
|
const updatedScreen = await screenManagementService.updateScreen(
|
||||||
|
parseInt(id),
|
||||||
|
updateData,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: updatedScreen });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 수정 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "화면 수정에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 삭제
|
||||||
|
export const deleteScreen = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
await screenManagementService.deleteScreen(parseInt(id), companyCode);
|
||||||
|
res.json({ success: true, message: "화면이 삭제되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 삭제 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "화면 삭제에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 목록 조회
|
||||||
|
export const getTables = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const tables = await screenManagementService.getTables(companyCode);
|
||||||
|
res.json({ success: true, data: tables });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 조회 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "테이블 목록 조회에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 컬럼 정보 조회
|
||||||
|
export const getTableColumns = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const columns = await screenManagementService.getTableColumns(
|
||||||
|
tableName,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: columns });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 컬럼 조회 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "테이블 컬럼 조회에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 저장
|
||||||
|
export const saveLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const layoutData = req.body;
|
||||||
|
const savedLayout = await screenManagementService.saveLayout(
|
||||||
|
parseInt(screenId),
|
||||||
|
layoutData,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: savedLayout });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 저장 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "레이아웃 저장에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 조회
|
||||||
|
export const getLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const layout = await screenManagementService.getLayout(
|
||||||
|
parseInt(screenId),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: layout });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 조회 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "레이아웃 조회에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -375,3 +375,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import express from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import {
|
||||||
|
getScreens,
|
||||||
|
createScreen,
|
||||||
|
updateScreen,
|
||||||
|
deleteScreen,
|
||||||
|
getTables,
|
||||||
|
getTableColumns,
|
||||||
|
saveLayout,
|
||||||
|
getLayout,
|
||||||
|
} from "../controllers/screenManagementController";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 화면 관리
|
||||||
|
router.get("/screens", getScreens);
|
||||||
|
router.post("/screens", createScreen);
|
||||||
|
router.put("/screens/:id", updateScreen);
|
||||||
|
router.delete("/screens/:id", deleteScreen);
|
||||||
|
|
||||||
|
// 테이블 관리
|
||||||
|
router.get("/tables", getTables);
|
||||||
|
router.get("/tables/:tableName/columns", getTableColumns);
|
||||||
|
|
||||||
|
// 레이아웃 관리
|
||||||
|
router.post("/screens/:screenId/layout", saveLayout);
|
||||||
|
router.get("/screens/:screenId/layout", getLayout);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,810 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 백엔드에서 사용할 테이블 정보 타입
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel: string;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getScreens(companyCode: string): Promise<ScreenDefinition[]> {
|
||||||
|
const whereClause =
|
||||||
|
companyCode === "*" ? {} : { company_code: companyCode };
|
||||||
|
|
||||||
|
const screens = await prisma.screen_definitions.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { created_date: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return screens.map((screen) => this.mapToScreenDefinition(screen));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 정의 조회
|
||||||
|
*/
|
||||||
|
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 existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingScreen) {
|
||||||
|
throw new Error("화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
userCompanyCode !== "*" &&
|
||||||
|
existingScreen.company_code !== userCompanyCode
|
||||||
|
) {
|
||||||
|
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const screen = await prisma.screen_definitions.update({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
data: {
|
||||||
|
screen_name: updateData.screenName,
|
||||||
|
description: updateData.description,
|
||||||
|
is_active: updateData.isActive ? "Y" : "N",
|
||||||
|
updated_by: updateData.updatedBy,
|
||||||
|
updated_date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapToScreenDefinition(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 정의 삭제
|
||||||
|
*/
|
||||||
|
async deleteScreen(screenId: number, userCompanyCode: string): Promise<void> {
|
||||||
|
// 권한 확인
|
||||||
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingScreen) {
|
||||||
|
throw new Error("화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
userCompanyCode !== "*" &&
|
||||||
|
existingScreen.company_code !== userCompanyCode
|
||||||
|
) {
|
||||||
|
throw new Error("이 화면을 삭제할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.screen_definitions.delete({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 테이블 관리
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
async getTables(companyCode: string): Promise<TableInfo[]> {
|
||||||
|
try {
|
||||||
|
// PostgreSQL에서 사용 가능한 테이블 목록 조회
|
||||||
|
const tables = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 각 테이블의 컬럼 정보도 함께 조회
|
||||||
|
const tableInfos: TableInfo[] = [];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const columns = await this.getTableColumns(
|
||||||
|
table.table_name,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
if (columns.length > 0) {
|
||||||
|
tableInfos.push({
|
||||||
|
tableName: table.table_name,
|
||||||
|
tableLabel: this.getTableLabel(table.table_name),
|
||||||
|
columns: columns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tableInfos;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 조회 실패:", error);
|
||||||
|
throw new Error("테이블 목록을 조회할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
async getTableColumns(
|
||||||
|
tableName: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<ColumnInfo[]> {
|
||||||
|
try {
|
||||||
|
// 테이블 컬럼 정보 조회
|
||||||
|
const columns = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
column_name: string;
|
||||||
|
data_type: string;
|
||||||
|
is_nullable: string;
|
||||||
|
column_default: string | null;
|
||||||
|
character_maximum_length: number | null;
|
||||||
|
numeric_precision: number | null;
|
||||||
|
numeric_scale: number | null;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable,
|
||||||
|
column_default,
|
||||||
|
character_maximum_length,
|
||||||
|
numeric_precision,
|
||||||
|
numeric_scale
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = ${tableName}
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`;
|
||||||
|
|
||||||
|
// column_labels 테이블에서 웹타입 정보 조회 (있는 경우)
|
||||||
|
const webTypeInfo = await prisma.column_labels.findMany({
|
||||||
|
where: { table_name: tableName },
|
||||||
|
select: {
|
||||||
|
column_name: true,
|
||||||
|
web_type: true,
|
||||||
|
column_label: true,
|
||||||
|
detail_settings: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컬럼 정보 매핑
|
||||||
|
return columns.map((column) => {
|
||||||
|
const webTypeData = webTypeInfo.find(
|
||||||
|
(wt) => wt.column_name === column.column_name
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tableName: tableName,
|
||||||
|
columnName: column.column_name,
|
||||||
|
columnLabel:
|
||||||
|
webTypeData?.column_label ||
|
||||||
|
this.getColumnLabel(column.column_name),
|
||||||
|
dataType: column.data_type,
|
||||||
|
webType:
|
||||||
|
(webTypeData?.web_type as WebType) ||
|
||||||
|
this.inferWebType(column.data_type),
|
||||||
|
isNullable: column.is_nullable,
|
||||||
|
columnDefault: column.column_default || undefined,
|
||||||
|
characterMaximumLength: column.character_maximum_length || undefined,
|
||||||
|
numericPrecision: column.numeric_precision || undefined,
|
||||||
|
numericScale: column.numeric_scale || undefined,
|
||||||
|
detailSettings: webTypeData?.detail_settings || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 컬럼 조회 실패:", error);
|
||||||
|
throw new Error("테이블 컬럼 정보를 조회할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 라벨 생성
|
||||||
|
*/
|
||||||
|
private getTableLabel(tableName: string): string {
|
||||||
|
// snake_case를 읽기 쉬운 형태로 변환
|
||||||
|
return tableName
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 라벨 생성
|
||||||
|
*/
|
||||||
|
private getColumnLabel(columnName: string): string {
|
||||||
|
// snake_case를 읽기 쉬운 형태로 변환
|
||||||
|
return columnName
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 타입으로부터 웹타입 추론
|
||||||
|
*/
|
||||||
|
private inferWebType(dataType: string): WebType {
|
||||||
|
const lowerType = dataType.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerType.includes("char") || lowerType.includes("text")) {
|
||||||
|
return "text";
|
||||||
|
} else if (
|
||||||
|
lowerType.includes("int") ||
|
||||||
|
lowerType.includes("numeric") ||
|
||||||
|
lowerType.includes("decimal")
|
||||||
|
) {
|
||||||
|
return "number";
|
||||||
|
} else if (lowerType.includes("date") || lowerType.includes("time")) {
|
||||||
|
return "date";
|
||||||
|
} else if (lowerType.includes("bool")) {
|
||||||
|
return "checkbox";
|
||||||
|
} else {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 레이아웃 관리
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 저장
|
||||||
|
*/
|
||||||
|
async saveLayout(
|
||||||
|
screenId: number,
|
||||||
|
layoutData: LayoutData,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<void> {
|
||||||
|
// 권한 확인
|
||||||
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingScreen) {
|
||||||
|
throw new Error("화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||||
|
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 레이아웃 삭제
|
||||||
|
await prisma.screen_layouts.deleteMany({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 새 레이아웃 저장
|
||||||
|
for (const component of layoutData.components) {
|
||||||
|
const { id, ...componentData } = component;
|
||||||
|
|
||||||
|
// Prisma JSON 필드에 맞는 타입으로 변환
|
||||||
|
const properties: any = {
|
||||||
|
...componentData,
|
||||||
|
position: {
|
||||||
|
x: component.position.x,
|
||||||
|
y: component.position.y,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
width: component.size.width,
|
||||||
|
height: component.size.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.screen_layouts.create({
|
||||||
|
data: {
|
||||||
|
screen_id: screenId,
|
||||||
|
component_type: component.type,
|
||||||
|
component_id: component.id,
|
||||||
|
parent_id: component.parentId || null,
|
||||||
|
position_x: component.position.x,
|
||||||
|
position_y: component.position.y,
|
||||||
|
width: component.size.width,
|
||||||
|
height: component.size.height,
|
||||||
|
properties: properties,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 조회
|
||||||
|
*/
|
||||||
|
async getLayout(
|
||||||
|
screenId: number,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<LayoutData | null> {
|
||||||
|
// 권한 확인
|
||||||
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingScreen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||||
|
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const layouts = await prisma.screen_layouts.findMany({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
orderBy: { display_order: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (layouts.length === 0) {
|
||||||
|
return {
|
||||||
|
components: [],
|
||||||
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const components: ComponentData[] = layouts.map((layout) => {
|
||||||
|
const properties = layout.properties as any;
|
||||||
|
return {
|
||||||
|
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 },
|
||||||
|
parentId: layout.parent_id,
|
||||||
|
...properties,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 인스턴스 export
|
||||||
|
export const screenManagementService = new ScreenManagementService();
|
||||||
|
|
@ -203,14 +203,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)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -339,4 +343,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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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"];
|
||||||
|
|
|
||||||
|
|
@ -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()}`;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,16 @@
|
||||||
|
|
||||||
### 화면관리 시스템이란?
|
### 화면관리 시스템이란?
|
||||||
|
|
||||||
화면관리 시스템은 실제 서비스되는 화면을 드래그앤드롭으로 설계하고 관리할 수 있는 시스템입니다. 테이블 타입관리와 연계하여 각 필드가 웹에서 어떻게 표시될지를 정의하고, 사용자가 직관적으로 화면을 구성할 수 있습니다.
|
화면관리 시스템은 사용자가 속한 회사에 맞춰 화면을 드래그앤드롭으로 설계하고 관리할 수 있는 시스템입니다. 테이블 타입관리와 연계하여 각 필드가 웹에서 어떻게 표시될지를 정의하고, 사용자가 직관적으로 화면을 구성할 수 있습니다.
|
||||||
|
|
||||||
### 주요 특징
|
### 주요 특징
|
||||||
|
|
||||||
|
- **회사별 화면 관리**: 사용자 회사 코드에 따른 화면 접근 제어
|
||||||
- **드래그앤드롭 인터페이스**: 직관적인 화면 설계
|
- **드래그앤드롭 인터페이스**: 직관적인 화면 설계
|
||||||
|
- **컨테이너 그룹화**: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
|
||||||
- **테이블 타입 연계**: 컬럼의 웹 타입에 따른 자동 위젯 생성
|
- **테이블 타입 연계**: 컬럼의 웹 타입에 따른 자동 위젯 생성
|
||||||
- **실시간 미리보기**: 설계한 화면을 즉시 확인 가능
|
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능
|
||||||
- **반응형 디자인**: 다양한 화면 크기에 대응
|
- **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리
|
||||||
- **템플릿 시스템**: 재사용 가능한 화면 템플릿 제공
|
|
||||||
|
|
||||||
### 🎯 **현재 테이블 구조와 100% 호환**
|
### 🎯 **현재 테이블 구조와 100% 호환**
|
||||||
|
|
||||||
|
|
@ -42,6 +43,15 @@
|
||||||
|
|
||||||
**별도의 테이블 구조 변경 없이 바로 개발 가능!** 🚀
|
**별도의 테이블 구조 변경 없이 바로 개발 가능!** 🚀
|
||||||
|
|
||||||
|
### 🏢 **회사별 화면 관리 시스템**
|
||||||
|
|
||||||
|
**사용자 권한에 따른 화면 접근 제어:**
|
||||||
|
|
||||||
|
- ✅ **일반 사용자**: 자신이 속한 회사의 화면만 제작/수정 가능
|
||||||
|
- ✅ **관리자 (회사코드 '\*')**: 모든 회사의 화면을 제어 가능
|
||||||
|
- ✅ **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당 가능
|
||||||
|
- ✅ **권한 격리**: 회사 간 화면 데이터 완전 분리
|
||||||
|
|
||||||
### 지원하는 웹 타입
|
### 지원하는 웹 타입
|
||||||
|
|
||||||
테이블 타입관리에서 각 컬럼별로 설정할 수 있는 웹 타입입니다:
|
테이블 타입관리에서 각 컬럼별로 설정할 수 있는 웹 타입입니다:
|
||||||
|
|
@ -66,6 +76,7 @@
|
||||||
- **자동 상세 설정**: 웹 타입 선택 시 해당 타입에 맞는 기본 상세 설정을 자동으로 제공
|
- **자동 상세 설정**: 웹 타입 선택 시 해당 타입에 맞는 기본 상세 설정을 자동으로 제공
|
||||||
- **실시간 저장**: 웹 타입 변경 시 즉시 백엔드 데이터베이스에 저장
|
- **실시간 저장**: 웹 타입 변경 시 즉시 백엔드 데이터베이스에 저장
|
||||||
- **오류 복구**: 저장 실패 시 원래 상태로 자동 복원
|
- **오류 복구**: 저장 실패 시 원래 상태로 자동 복원
|
||||||
|
- **상세 설정 편집**: 웹 타입별 상세 설정을 모달에서 JSON 형태로 편집 가능
|
||||||
|
|
||||||
#### 2. 웹 타입별 상세 설정
|
#### 2. 웹 타입별 상세 설정
|
||||||
|
|
||||||
|
|
@ -88,7 +99,8 @@
|
||||||
2. **컬럼 확인**: 해당 테이블의 모든 컬럼 정보 표시
|
2. **컬럼 확인**: 해당 테이블의 모든 컬럼 정보 표시
|
||||||
3. **웹 타입 설정**: 각 컬럼의 웹 타입을 드롭다운에서 선택
|
3. **웹 타입 설정**: 각 컬럼의 웹 타입을 드롭다운에서 선택
|
||||||
4. **자동 저장**: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용
|
4. **자동 저장**: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용
|
||||||
5. **추가 설정**: 필요시 상세 설정을 사용자 정의로 수정
|
5. **상세 설정 편집**: "상세 설정 편집" 버튼을 클릭하여 JSON 형태로 추가 설정 수정
|
||||||
|
6. **설정 저장**: 수정된 상세 설정을 저장하여 완료
|
||||||
|
|
||||||
## 🏗️ 아키텍처 구조
|
## 🏗️ 아키텍처 구조
|
||||||
|
|
||||||
|
|
@ -118,6 +130,25 @@
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 회사별 권한 관리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ 사용자 │ │ 권한 검증 │ │ 화면 데이터 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||||
|
│ │ 회사코드 │ │───▶│ │ 권한 검증 │ │───▶│ │ 회사별 │ │
|
||||||
|
│ │ (company_ │ │ │ │ 미들웨어 │ │ │ │ 화면 │ │
|
||||||
|
│ │ code) │ │ │ │ │ │ │ │ 격리 │ │
|
||||||
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||||
|
│ │ 권한 레벨 │ │ │ │ 회사별 │ │ │ │ 메뉴 할당 │ │
|
||||||
|
│ │ (admin: '*')│ │ │ │ 데이터 │ │ │ │ 제한 │ │
|
||||||
|
│ └─────────────┘ │ │ │ 필터링 │ │ │ └─────────────┘ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
### 데이터 흐름
|
### 데이터 흐름
|
||||||
|
|
||||||
1. **테이블 타입 정의**: 테이블 타입관리에서 컬럼의 웹 타입 설정
|
1. **테이블 타입 정의**: 테이블 타입관리에서 컬럼의 웹 타입 설정
|
||||||
|
|
@ -132,28 +163,42 @@
|
||||||
|
|
||||||
- **드래그앤드롭 인터페이스**: 컴포넌트를 캔버스에 배치
|
- **드래그앤드롭 인터페이스**: 컴포넌트를 캔버스에 배치
|
||||||
- **그리드 시스템**: 12컬럼 그리드 기반 레이아웃
|
- **그리드 시스템**: 12컬럼 그리드 기반 레이아웃
|
||||||
- **반응형 설정**: 화면 크기별 레이아웃 조정
|
- **컨테이너 그룹화**: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
|
||||||
- **실시간 미리보기**: 설계한 화면을 즉시 확인
|
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인
|
||||||
|
|
||||||
### 2. 컴포넌트 라이브러리
|
### 2. 컴포넌트 라이브러리
|
||||||
|
|
||||||
- **입력 컴포넌트**: text, number, date, textarea 등
|
- **입력 컴포넌트**: text, number, date, textarea 등
|
||||||
- **선택 컴포넌트**: select, checkbox, radio 등
|
- **선택 컴포넌트**: select, checkbox, radio 등
|
||||||
- **표시 컴포넌트**: label, display, image 등
|
- **표시 컴포넌트**: label, display, image 등
|
||||||
- **레이아웃 컴포넌트**: container, row, column 등
|
- **레이아웃 컴포넌트**: container, row, column, group 등
|
||||||
|
- **컨테이너 컴포넌트**: 컴포넌트들을 그룹으로 묶는 기능
|
||||||
|
|
||||||
### 3. 테이블 연계 시스템
|
### 3. 회사별 권한 관리
|
||||||
|
|
||||||
|
- **회사 코드 기반 접근 제어**: 사용자 회사 코드에 따른 화면 접근
|
||||||
|
- **관리자 권한**: 회사 코드 '\*'인 사용자는 모든 회사 화면 제어
|
||||||
|
- **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당 가능
|
||||||
|
- **데이터 격리**: 회사 간 화면 데이터 완전 분리
|
||||||
|
|
||||||
|
### 4. 테이블 연계 시스템
|
||||||
|
|
||||||
- **자동 위젯 생성**: 컬럼의 웹 타입에 따른 위젯 자동 생성
|
- **자동 위젯 생성**: 컬럼의 웹 타입에 따른 위젯 자동 생성
|
||||||
- **데이터 바인딩**: 컬럼과 위젯의 자동 연결
|
- **데이터 바인딩**: 컬럼과 위젯의 자동 연결
|
||||||
- **유효성 검증**: 컬럼 설정에 따른 자동 검증 규칙 적용
|
- **유효성 검증**: 컬럼 설정에 따른 자동 검증 규칙 적용
|
||||||
|
|
||||||
### 4. 템플릿 시스템
|
### 5. 템플릿 시스템
|
||||||
|
|
||||||
- **기본 템플릿**: CRUD, 목록, 상세 등 기본 패턴
|
- **기본 템플릿**: CRUD, 목록, 상세 등 기본 패턴
|
||||||
- **사용자 정의 템플릿**: 자주 사용하는 레이아웃 저장
|
- **사용자 정의 템플릿**: 자주 사용하는 레이아웃 저장
|
||||||
- **템플릿 공유**: 팀원 간 템플릿 공유 및 재사용
|
- **템플릿 공유**: 팀원 간 템플릿 공유 및 재사용
|
||||||
|
|
||||||
|
### 6. 메뉴 연동 시스템
|
||||||
|
|
||||||
|
- **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당
|
||||||
|
- **메뉴-화면 연결**: 메뉴와 화면의 1:1 또는 1:N 연결
|
||||||
|
- **권한 기반 메뉴 표시**: 사용자 권한에 따른 메뉴 표시 제어
|
||||||
|
|
||||||
## 🗄️ 데이터베이스 설계
|
## 🗄️ 데이터베이스 설계
|
||||||
|
|
||||||
### 1. 기존 테이블 구조 (테이블 타입관리)
|
### 1. 기존 테이블 구조 (테이블 타입관리)
|
||||||
|
|
@ -221,6 +266,7 @@ CREATE TABLE screen_definitions (
|
||||||
screen_name VARCHAR(100) NOT NULL,
|
screen_name VARCHAR(100) NOT NULL,
|
||||||
screen_code VARCHAR(50) UNIQUE NOT NULL,
|
screen_code VARCHAR(50) UNIQUE NOT NULL,
|
||||||
table_name VARCHAR(100) NOT NULL, -- 🎯 table_labels와 연계
|
table_name VARCHAR(100) NOT NULL, -- 🎯 table_labels와 연계
|
||||||
|
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
|
||||||
description TEXT,
|
description TEXT,
|
||||||
is_active CHAR(1) DEFAULT 'Y',
|
is_active CHAR(1) DEFAULT 'Y',
|
||||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
@ -232,6 +278,9 @@ CREATE TABLE screen_definitions (
|
||||||
CONSTRAINT fk_screen_definitions_table_name
|
CONSTRAINT fk_screen_definitions_table_name
|
||||||
FOREIGN KEY (table_name) REFERENCES table_labels(table_name)
|
FOREIGN KEY (table_name) REFERENCES table_labels(table_name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 회사 코드 인덱스 추가
|
||||||
|
CREATE INDEX idx_screen_definitions_company_code ON screen_definitions(company_code);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### screen_layouts (화면 레이아웃)
|
#### screen_layouts (화면 레이아웃)
|
||||||
|
|
@ -283,12 +332,43 @@ CREATE TABLE screen_templates (
|
||||||
template_id SERIAL PRIMARY KEY,
|
template_id SERIAL PRIMARY KEY,
|
||||||
template_name VARCHAR(100) NOT NULL,
|
template_name VARCHAR(100) NOT NULL,
|
||||||
template_type VARCHAR(50) NOT NULL, -- CRUD, LIST, DETAIL 등
|
template_type VARCHAR(50) NOT NULL, -- CRUD, LIST, DETAIL 등
|
||||||
|
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
|
||||||
description TEXT,
|
description TEXT,
|
||||||
layout_data JSONB, -- 레이아웃 데이터
|
layout_data JSONB, -- 레이아웃 데이터
|
||||||
is_public BOOLEAN DEFAULT FALSE, -- 공개 여부
|
is_public BOOLEAN DEFAULT FALSE, -- 공개 여부
|
||||||
created_by VARCHAR(50),
|
created_by VARCHAR(50),
|
||||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 회사 코드 인덱스 추가
|
||||||
|
CREATE INDEX idx_screen_templates_company_code ON screen_templates(company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### screen_menu_assignments (화면-메뉴 할당)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE screen_menu_assignments (
|
||||||
|
assignment_id SERIAL PRIMARY KEY,
|
||||||
|
screen_id INTEGER NOT NULL,
|
||||||
|
menu_id INTEGER NOT NULL,
|
||||||
|
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
is_active CHAR(1) DEFAULT 'Y',
|
||||||
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
|
||||||
|
-- 외래키 제약조건
|
||||||
|
CONSTRAINT fk_screen_menu_assignments_screen_id
|
||||||
|
FOREIGN KEY (screen_id) REFERENCES screen_definitions(screen_id),
|
||||||
|
CONSTRAINT fk_screen_menu_assignments_menu_id
|
||||||
|
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id),
|
||||||
|
|
||||||
|
-- 유니크 제약조건 (한 메뉴에 같은 화면 중복 할당 방지)
|
||||||
|
CONSTRAINT uk_screen_menu_company UNIQUE (screen_id, menu_id, company_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 회사 코드 인덱스 추가
|
||||||
|
CREATE INDEX idx_screen_menu_assignments_company_code ON screen_menu_assignments(company_code);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 테이블 간 연계 관계
|
### 3. 테이블 간 연계 관계
|
||||||
|
|
@ -303,6 +383,8 @@ screen_definitions (화면 정의)
|
||||||
screen_layouts (화면 레이아웃)
|
screen_layouts (화면 레이아웃)
|
||||||
↓ (1:N)
|
↓ (1:N)
|
||||||
screen_widgets (화면 위젯)
|
screen_widgets (화면 위젯)
|
||||||
|
↓ (1:N)
|
||||||
|
screen_menu_assignments (화면-메뉴 할당)
|
||||||
```
|
```
|
||||||
|
|
||||||
**핵심 연계 포인트:**
|
**핵심 연계 포인트:**
|
||||||
|
|
@ -310,6 +392,8 @@ screen_widgets (화면 위젯)
|
||||||
- ✅ `screen_definitions.table_name` ↔ `table_labels.table_name`
|
- ✅ `screen_definitions.table_name` ↔ `table_labels.table_name`
|
||||||
- ✅ `screen_widgets.table_name, column_name` ↔ `column_labels.table_name, column_name`
|
- ✅ `screen_widgets.table_name, column_name` ↔ `column_labels.table_name, column_name`
|
||||||
- ✅ `screen_widgets.widget_type` ↔ `column_labels.web_type` (자동 동기화)
|
- ✅ `screen_widgets.widget_type` ↔ `column_labels.web_type` (자동 동기화)
|
||||||
|
- ✅ `screen_definitions.company_code` ↔ 사용자 회사 코드 (권한 관리)
|
||||||
|
- ✅ `screen_menu_assignments.company_code` ↔ 메뉴 회사 코드 (메뉴 할당 제한)
|
||||||
|
|
||||||
## 🎨 화면 구성 요소
|
## 🎨 화면 구성 요소
|
||||||
|
|
||||||
|
|
@ -327,6 +411,29 @@ interface ContainerProps {
|
||||||
margin: number;
|
margin: number;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
border?: string;
|
border?: string;
|
||||||
|
borderRadius?: number;
|
||||||
|
shadow?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Group (그룹)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GroupProps {
|
||||||
|
id: string;
|
||||||
|
type: "group";
|
||||||
|
title?: string;
|
||||||
|
width: number; // 1-12
|
||||||
|
height: number;
|
||||||
|
padding: number;
|
||||||
|
margin: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
border?: string;
|
||||||
|
borderRadius?: number;
|
||||||
|
shadow?: string;
|
||||||
|
collapsible?: boolean; // 접을 수 있는 그룹
|
||||||
|
collapsed?: boolean; // 접힌 상태
|
||||||
|
children: string[]; // 포함된 컴포넌트 ID 목록
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -440,16 +547,27 @@ interface DragState {
|
||||||
draggedItem: ComponentData | null;
|
draggedItem: ComponentData | null;
|
||||||
dragSource: "toolbox" | "canvas";
|
dragSource: "toolbox" | "canvas";
|
||||||
dropTarget: string | null;
|
dropTarget: string | null;
|
||||||
|
dropZone?: DropZone; // 드롭 가능한 영역 정보
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 그룹화 상태 관리
|
||||||
|
interface GroupState {
|
||||||
|
isGrouping: boolean;
|
||||||
|
selectedComponents: string[];
|
||||||
|
groupTarget: string | null;
|
||||||
|
groupMode: "create" | "add" | "remove";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
// 드롭 영역 정의
|
// 드롭 영역 정의
|
||||||
interface DropZone {
|
interface DropZone {
|
||||||
id: string;
|
id: string;
|
||||||
accepts: string[]; // 허용되는 컴포넌트 타입
|
accepts: string[]; // 허용되는 컴포넌트 타입
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
size: { width: number; height: number };
|
size: { width: number; height: number };
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
````
|
||||||
|
|
||||||
### 2. 컴포넌트 배치 로직
|
### 2. 컴포넌트 배치 로직
|
||||||
|
|
||||||
|
|
@ -478,7 +596,7 @@ function resizeComponent(
|
||||||
height: Math.max(50, newHeight),
|
height: Math.max(50, newHeight),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
````
|
||||||
|
|
||||||
### 3. 실시간 미리보기
|
### 3. 실시간 미리보기
|
||||||
|
|
||||||
|
|
@ -492,21 +610,40 @@ function generatePreview(layout: LayoutData): React.ReactElement {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 렌더링
|
// 그룹 컴포넌트 렌더링
|
||||||
function renderComponent(component: ComponentData): React.ReactElement {
|
function renderGroupComponent(
|
||||||
switch (component.type) {
|
group: GroupProps,
|
||||||
case "text":
|
components: ComponentData[]
|
||||||
return <TextInput {...component.props} />;
|
): React.ReactElement {
|
||||||
case "select":
|
const groupChildren = components.filter((c) => group.children.includes(c.id));
|
||||||
return <Select {...component.props} />;
|
|
||||||
case "date":
|
return (
|
||||||
return <DatePicker {...component.props} />;
|
<div className="component-group" style={getGroupStyles(group)}>
|
||||||
default:
|
{group.title && <div className="group-title">{group.title}</div>}
|
||||||
return <div>Unknown component</div>;
|
<div className="group-content">
|
||||||
}
|
{groupChildren.map((component) => renderComponent(component))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
// 컴포넌트 렌더링
|
||||||
|
function renderComponent(component: ComponentData): React.ReactElement {
|
||||||
|
switch (component.type) {
|
||||||
|
case "text":
|
||||||
|
return <TextInput {...component.props} />;
|
||||||
|
case "select":
|
||||||
|
return <Select {...component.props} />;
|
||||||
|
case "date":
|
||||||
|
return <DatePicker {...component.props} />;
|
||||||
|
default:
|
||||||
|
return <div>Unknown component</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
## 🔗 테이블 타입 연계
|
## 🔗 테이블 타입 연계
|
||||||
|
|
||||||
### 1. 웹 타입 설정 방법
|
### 1. 웹 타입 설정 방법
|
||||||
|
|
@ -552,7 +689,7 @@ async function getTableColumnWebTypes(
|
||||||
): Promise<ColumnWebTypeSetting[]> {
|
): Promise<ColumnWebTypeSetting[]> {
|
||||||
return api.get(`/table-management/tables/${tableName}/columns/web-types`);
|
return api.get(`/table-management/tables/${tableName}/columns/web-types`);
|
||||||
}
|
}
|
||||||
```
|
````
|
||||||
|
|
||||||
#### 웹 타입별 추가 설정 (현재 테이블 구조 기반)
|
#### 웹 타입별 추가 설정 (현재 테이블 구조 기반)
|
||||||
|
|
||||||
|
|
@ -798,10 +935,15 @@ function generateValidationRules(column: ColumnInfo): ValidationRule[] {
|
||||||
|
|
||||||
### 1. 화면 정의 API
|
### 1. 화면 정의 API
|
||||||
|
|
||||||
#### 화면 목록 조회
|
#### 화면 목록 조회 (회사별)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
GET /api/screen-management/screens
|
GET /api/screen-management/screens
|
||||||
|
Query: {
|
||||||
|
companyCode?: string; // 회사 코드 (관리자는 생략 가능)
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
Response: {
|
Response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: ScreenDefinition[];
|
data: ScreenDefinition[];
|
||||||
|
|
@ -809,6 +951,19 @@ Response: {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 화면 생성 (회사별)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
POST /api/screen-management/screens
|
||||||
|
Body: {
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
tableName: string;
|
||||||
|
companyCode: string; // 사용자 회사 코드 자동 설정
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### 화면 생성
|
#### 화면 생성
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -896,17 +1051,44 @@ Body: {
|
||||||
|
|
||||||
### 4. 템플릿 API
|
### 4. 템플릿 API
|
||||||
|
|
||||||
#### 템플릿 목록 조회
|
#### 템플릿 목록 조회 (회사별)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
GET /api/screen-management/templates
|
GET /api/screen-management/templates
|
||||||
Query: {
|
Query: {
|
||||||
|
companyCode?: string; // 회사 코드 (관리자는 생략 가능)
|
||||||
type?: string;
|
type?: string;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5. 메뉴 할당 API
|
||||||
|
|
||||||
|
#### 화면-메뉴 할당
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
POST /api/screen-management/screens/:screenId/menu-assignments
|
||||||
|
Body: {
|
||||||
|
menuId: number;
|
||||||
|
companyCode: string; // 사용자 회사 코드 자동 설정
|
||||||
|
displayOrder?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 메뉴별 화면 목록 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
GET /api/screen-management/menus/:menuId/screens
|
||||||
|
Query: {
|
||||||
|
companyCode: string; // 회사 코드 필수
|
||||||
|
}
|
||||||
|
Response: {
|
||||||
|
success: boolean;
|
||||||
|
data: ScreenDefinition[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### 템플릿 적용
|
#### 템플릿 적용
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -934,37 +1116,89 @@ export default function ScreenDesigner() {
|
||||||
dragSource: "toolbox",
|
dragSource: "toolbox",
|
||||||
dropTarget: null,
|
dropTarget: null,
|
||||||
});
|
});
|
||||||
|
const [groupState, setGroupState] = useState<GroupState>({
|
||||||
|
isGrouping: false,
|
||||||
|
selectedComponents: [],
|
||||||
|
groupTarget: null,
|
||||||
|
groupMode: "create",
|
||||||
|
});
|
||||||
|
const [userCompanyCode, setUserCompanyCode] = useState<string>("");
|
||||||
|
```
|
||||||
|
|
||||||
|
// 컴포넌트 추가
|
||||||
|
const addComponent = (component: ComponentData) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
components: [...prev.components, component],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 삭제
|
||||||
|
const removeComponent = (componentId: string) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
components: prev.components.filter((c) => c.id !== componentId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 이동
|
||||||
|
const moveComponent = (componentId: string, newPosition: Position) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
components: prev.components.map((c) =>
|
||||||
|
c.id === componentId ? { ...c, position: newPosition } : c
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 그룹화
|
||||||
|
const groupComponents = (componentIds: string[], groupTitle?: string) => {
|
||||||
|
const groupId = generateId();
|
||||||
|
const groupComponent: GroupProps = {
|
||||||
|
id: groupId,
|
||||||
|
type: "group",
|
||||||
|
title: groupTitle || "그룹",
|
||||||
|
width: 12,
|
||||||
|
height: 200,
|
||||||
|
padding: 16,
|
||||||
|
margin: 8,
|
||||||
|
backgroundColor: "#f8f9fa",
|
||||||
|
border: "1px solid #dee2e6",
|
||||||
|
borderRadius: 8,
|
||||||
|
shadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: false,
|
||||||
|
children: componentIds,
|
||||||
|
};
|
||||||
|
|
||||||
// 컴포넌트 추가
|
|
||||||
const addComponent = (component: ComponentData) => {
|
|
||||||
setLayout((prev) => ({
|
setLayout((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
components: [...prev.components, component],
|
components: [...prev.components, groupComponent],
|
||||||
}));
|
}));
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 삭제
|
};
|
||||||
const removeComponent = (componentId: string) => {
|
|
||||||
setLayout((prev) => ({
|
|
||||||
...prev,
|
|
||||||
components: prev.components.filter((c) => c.id !== componentId),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 이동
|
// 그룹에서 컴포넌트 제거
|
||||||
const moveComponent = (componentId: string, newPosition: Position) => {
|
const ungroupComponent = (componentId: string, groupId: string) => {
|
||||||
setLayout((prev) => ({
|
setLayout((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
components: prev.components.map((c) =>
|
components: prev.components.map((c) => {
|
||||||
c.id === componentId ? { ...c, position: newPosition } : c
|
if (c.id === groupId && c.type === "group") {
|
||||||
),
|
return {
|
||||||
}));
|
...c,
|
||||||
};
|
children: c.children.filter((id) => id !== componentId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen-designer">
|
|
||||||
<Toolbox onComponentSelect={addComponent} />
|
<div className="screen-designer">
|
||||||
<Canvas
|
<Toolbox onComponentSelect={addComponent} />
|
||||||
|
<Canvas
|
||||||
layout={layout}
|
layout={layout}
|
||||||
selectedComponent={selectedComponent}
|
selectedComponent={selectedComponent}
|
||||||
onComponentSelect={setSelectedComponent}
|
onComponentSelect={setSelectedComponent}
|
||||||
|
|
@ -973,15 +1207,24 @@ export default function ScreenDesigner() {
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
onDragStateChange={setDragState}
|
onDragStateChange={setDragState}
|
||||||
/>
|
/>
|
||||||
<PropertiesPanel
|
<PropertiesPanel
|
||||||
component={layout.components.find((c) => c.id === selectedComponent)}
|
component={layout.components.find((c) => c.id === selectedComponent)}
|
||||||
onPropertyChange={updateComponentProperty}
|
onPropertyChange={updateComponentProperty}
|
||||||
|
onGroupCreate={groupComponents}
|
||||||
|
onGroupRemove={ungroupComponent}
|
||||||
|
/>
|
||||||
|
<PreviewPanel layout={layout} />
|
||||||
|
<GroupingToolbar
|
||||||
|
groupState={groupState}
|
||||||
|
onGroupStateChange={setGroupState}
|
||||||
|
onGroupCreate={groupComponents}
|
||||||
|
onGroupRemove={ungroupComponent}
|
||||||
/>
|
/>
|
||||||
<PreviewPanel layout={layout} />
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
````
|
||||||
|
|
||||||
### 2. 드래그앤드롭 구현
|
### 2. 드래그앤드롭 구현
|
||||||
|
|
||||||
|
|
@ -1024,7 +1267,7 @@ export function useDragAndDrop() {
|
||||||
updateDropTarget,
|
updateDropTarget,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
````
|
||||||
|
|
||||||
### 3. 그리드 시스템
|
### 3. 그리드 시스템
|
||||||
|
|
||||||
|
|
@ -1045,6 +1288,56 @@ export default function GridSystem({ children, columns = 12 }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 그룹화 도구 모음
|
||||||
|
export function GroupingToolbar({
|
||||||
|
groupState,
|
||||||
|
onGroupStateChange,
|
||||||
|
onGroupCreate,
|
||||||
|
onGroupRemove,
|
||||||
|
}) {
|
||||||
|
const handleGroupCreate = () => {
|
||||||
|
if (groupState.selectedComponents.length > 1) {
|
||||||
|
const groupTitle = prompt("그룹 제목을 입력하세요:");
|
||||||
|
onGroupCreate(groupState.selectedComponents, groupTitle);
|
||||||
|
onGroupStateChange({
|
||||||
|
...groupState,
|
||||||
|
isGrouping: false,
|
||||||
|
selectedComponents: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupRemove = () => {
|
||||||
|
if (groupState.groupTarget) {
|
||||||
|
onGroupRemove(groupState.selectedComponents[0], groupState.groupTarget);
|
||||||
|
onGroupStateChange({
|
||||||
|
...groupState,
|
||||||
|
isGrouping: false,
|
||||||
|
selectedComponents: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grouping-toolbar">
|
||||||
|
<button
|
||||||
|
onClick={handleGroupCreate}
|
||||||
|
disabled={groupState.selectedComponents.length < 2}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
그룹 생성
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGroupRemove}
|
||||||
|
disabled={!groupState.groupTarget}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
그룹 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// GridItem.tsx
|
// GridItem.tsx
|
||||||
interface GridItemProps {
|
interface GridItemProps {
|
||||||
width: number; // 1-12
|
width: number; // 1-12
|
||||||
|
|
@ -1080,15 +1373,22 @@ export default function GridItem({
|
||||||
```typescript
|
```typescript
|
||||||
// screenManagementService.ts
|
// screenManagementService.ts
|
||||||
export class ScreenManagementService {
|
export class ScreenManagementService {
|
||||||
// 화면 정의 생성
|
// 화면 정의 생성 (회사별)
|
||||||
async createScreen(
|
async createScreen(
|
||||||
screenData: CreateScreenRequest
|
screenData: CreateScreenRequest,
|
||||||
|
userCompanyCode: string
|
||||||
): Promise<ScreenDefinition> {
|
): Promise<ScreenDefinition> {
|
||||||
|
// 권한 검증: 사용자 회사 코드 확인
|
||||||
|
if (userCompanyCode !== "*" && userCompanyCode !== screenData.companyCode) {
|
||||||
|
throw new Error("해당 회사의 화면을 생성할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
const screen = await prisma.screen_definitions.create({
|
const screen = await prisma.screen_definitions.create({
|
||||||
data: {
|
data: {
|
||||||
screen_name: screenData.screenName,
|
screen_name: screenData.screenName,
|
||||||
screen_code: screenData.screenCode,
|
screen_code: screenData.screenCode,
|
||||||
table_name: screenData.tableName,
|
table_name: screenData.tableName,
|
||||||
|
company_code: screenData.companyCode,
|
||||||
description: screenData.description,
|
description: screenData.description,
|
||||||
created_by: screenData.createdBy,
|
created_by: screenData.createdBy,
|
||||||
},
|
},
|
||||||
|
|
@ -1097,6 +1397,30 @@ export class ScreenManagementService {
|
||||||
return this.mapToScreenDefinition(screen);
|
return this.mapToScreenDefinition(screen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 회사별 화면 목록 조회
|
||||||
|
async getScreensByCompany(
|
||||||
|
companyCode: string,
|
||||||
|
page: number = 1,
|
||||||
|
size: number = 20
|
||||||
|
): Promise<{ screens: ScreenDefinition[]; total: number }> {
|
||||||
|
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 {
|
||||||
|
screens: screens.map(this.mapToScreenDefinition),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// 레이아웃 저장
|
// 레이아웃 저장
|
||||||
async saveLayout(screenId: number, layoutData: LayoutData): Promise<void> {
|
async saveLayout(screenId: number, layoutData: LayoutData): Promise<void> {
|
||||||
// 기존 레이아웃 삭제
|
// 기존 레이아웃 삭제
|
||||||
|
|
@ -1449,7 +1773,23 @@ export class TableTypeIntegrationService {
|
||||||
|
|
||||||
## 🎬 사용 시나리오
|
## 🎬 사용 시나리오
|
||||||
|
|
||||||
### 1. 웹 타입 설정 (테이블 타입관리)
|
### 1. 회사별 화면 관리
|
||||||
|
|
||||||
|
#### 일반 사용자 (회사 코드: 'COMP001')
|
||||||
|
|
||||||
|
1. **로그인**: 자신의 회사 코드로 시스템 로그인
|
||||||
|
2. **화면 목록 조회**: 자신이 속한 회사의 화면만 표시
|
||||||
|
3. **화면 생성**: 회사 코드가 자동으로 설정되어 생성
|
||||||
|
4. **메뉴 할당**: 자신의 회사 메뉴에만 화면 할당 가능
|
||||||
|
|
||||||
|
#### 관리자 (회사 코드: '\*')
|
||||||
|
|
||||||
|
1. **로그인**: 관리자 권한으로 시스템 로그인
|
||||||
|
2. **전체 화면 조회**: 모든 회사의 화면을 조회/수정 가능
|
||||||
|
3. **회사별 화면 관리**: 각 회사별로 화면 생성/수정/삭제
|
||||||
|
4. **크로스 회사 메뉴 할당**: 모든 회사의 메뉴에 화면 할당 가능
|
||||||
|
|
||||||
|
### 2. 웹 타입 설정 (테이블 타입관리)
|
||||||
|
|
||||||
1. **테이블 선택**: 테이블 타입관리에서 웹 타입을 설정할 테이블 선택
|
1. **테이블 선택**: 테이블 타입관리에서 웹 타입을 설정할 테이블 선택
|
||||||
2. **컬럼 관리**: 해당 테이블의 컬럼 목록에서 웹 타입을 설정할 컬럼 선택
|
2. **컬럼 관리**: 해당 테이블의 컬럼 목록에서 웹 타입을 설정할 컬럼 선택
|
||||||
|
|
@ -1462,38 +1802,48 @@ export class TableTypeIntegrationService {
|
||||||
5. **저장**: 웹 타입 설정을 데이터베이스에 저장
|
5. **저장**: 웹 타입 설정을 데이터베이스에 저장
|
||||||
6. **연계 확인**: 화면관리 시스템에서 자동 위젯 생성 확인
|
6. **연계 확인**: 화면관리 시스템에서 자동 위젯 생성 확인
|
||||||
|
|
||||||
### 2. 새로운 화면 설계
|
### 3. 새로운 화면 설계
|
||||||
|
|
||||||
1. **테이블 선택**: 테이블 타입관리에서 설계할 테이블 선택
|
1. **테이블 선택**: 테이블 타입관리에서 설계할 테이블 선택
|
||||||
2. **웹 타입 확인**: 각 컬럼의 웹 타입 설정 상태 확인
|
2. **웹 타입 확인**: 각 컬럼의 웹 타입 설정 상태 확인
|
||||||
3. **화면 생성**: 화면명과 코드를 입력하여 새 화면 생성
|
3. **화면 생성**: 화면명과 코드를 입력하여 새 화면 생성 (회사 코드 자동 설정)
|
||||||
4. **자동 위젯 생성**: 컬럼의 웹 타입에 따라 자동으로 위젯 생성
|
4. **자동 위젯 생성**: 컬럼의 웹 타입에 따라 자동으로 위젯 생성
|
||||||
5. **컴포넌트 배치**: 드래그앤드롭으로 컴포넌트를 캔버스에 배치
|
5. **컴포넌트 배치**: 드래그앤드롭으로 컴포넌트를 캔버스에 배치
|
||||||
6. **속성 설정**: 각 컴포넌트의 속성을 Properties 패널에서 설정
|
6. **컨테이너 그룹화**: 관련 컴포넌트들을 그룹으로 묶어 깔끔하게 정렬
|
||||||
7. **미리보기**: 실시간으로 설계한 화면 확인
|
7. **속성 설정**: 각 컴포넌트의 속성을 Properties 패널에서 설정
|
||||||
8. **저장**: 완성된 화면 레이아웃을 데이터베이스에 저장
|
8. **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인
|
||||||
|
9. **저장**: 완성된 화면 레이아웃을 데이터베이스에 저장
|
||||||
|
|
||||||
### 2. 기존 화면 수정
|
### 4. 기존 화면 수정
|
||||||
|
|
||||||
1. **화면 선택**: 수정할 화면을 목록에서 선택
|
1. **화면 선택**: 수정할 화면을 목록에서 선택 (권한 확인)
|
||||||
2. **레이아웃 로드**: 기존 레이아웃을 캔버스에 로드
|
2. **레이아웃 로드**: 기존 레이아웃을 캔버스에 로드
|
||||||
3. **컴포넌트 수정**: 컴포넌트 추가/삭제/이동/수정
|
3. **컴포넌트 수정**: 컴포넌트 추가/삭제/이동/수정
|
||||||
4. **속성 변경**: 컴포넌트 속성 변경
|
4. **그룹 구조 조정**: 컴포넌트 그룹화/그룹 해제/그룹 속성 변경
|
||||||
5. **변경사항 확인**: 미리보기로 변경사항 확인
|
5. **속성 변경**: 컴포넌트 속성 변경
|
||||||
6. **저장**: 수정된 레이아웃 저장
|
6. **변경사항 확인**: 실시간 미리보기로 변경사항 확인
|
||||||
|
7. **저장**: 수정된 레이아웃 저장
|
||||||
|
|
||||||
### 3. 템플릿 활용
|
### 5. 템플릿 활용
|
||||||
|
|
||||||
1. **템플릿 선택**: 적합한 템플릿을 목록에서 선택
|
1. **템플릿 선택**: 적합한 템플릿을 목록에서 선택 (회사별 템플릿)
|
||||||
2. **템플릿 적용**: 선택한 템플릿을 현재 화면에 적용
|
2. **템플릿 적용**: 선택한 템플릿을 현재 화면에 적용
|
||||||
3. **커스터마이징**: 템플릿을 기반으로 필요한 부분 수정
|
3. **커스터마이징**: 템플릿을 기반으로 필요한 부분 수정
|
||||||
4. **저장**: 커스터마이징된 화면 저장
|
4. **저장**: 커스터마이징된 화면 저장
|
||||||
|
|
||||||
### 4. 화면 배포
|
### 6. 메뉴 할당 및 관리
|
||||||
|
|
||||||
|
1. **메뉴 선택**: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시)
|
||||||
|
2. **화면 할당**: 선택한 화면을 메뉴에 할당
|
||||||
|
3. **할당 순서 조정**: 메뉴 내 화면 표시 순서 조정
|
||||||
|
4. **할당 해제**: 메뉴에서 화면 할당 해제
|
||||||
|
5. **권한 확인**: 메뉴 할당 시 회사 코드 일치 여부 확인
|
||||||
|
|
||||||
|
### 7. 화면 배포
|
||||||
|
|
||||||
1. **화면 활성화**: 설계 완료된 화면을 활성 상태로 변경
|
1. **화면 활성화**: 설계 완료된 화면을 활성 상태로 변경
|
||||||
2. **권한 설정**: 화면 접근 권한 설정
|
2. **권한 설정**: 화면 접근 권한 설정 (회사별 권한)
|
||||||
3. **메뉴 연결**: 메뉴 시스템에 화면 연결
|
3. **메뉴 연결**: 메뉴 시스템에 화면 연결 (회사별 메뉴)
|
||||||
4. **테스트**: 실제 환경에서 화면 동작 테스트
|
4. **테스트**: 실제 환경에서 화면 동작 테스트
|
||||||
5. **배포**: 운영 환경에 화면 배포
|
5. **배포**: 운영 환경에 화면 배포
|
||||||
|
|
||||||
|
|
@ -1545,6 +1895,25 @@ export class TableTypeIntegrationService {
|
||||||
|
|
||||||
## 🎯 결론
|
## 🎯 결론
|
||||||
|
|
||||||
화면관리 시스템은 테이블 타입관리와 연계하여 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다. 드래그앤드롭 인터페이스와 자동 위젯 생성 기능을 통해 개발자가 아닌 사용자도 전문적인 웹 화면을 쉽게 구성할 수 있습니다.
|
화면관리 시스템은 **회사별 권한 관리**와 **테이블 타입관리 연계**를 통해 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다.
|
||||||
|
|
||||||
이 시스템을 통해 ERP 시스템의 화면 개발 생산성을 크게 향상시키고, 사용자 요구사항에 따른 빠른 화면 구성이 가능해질 것입니다.
|
### 🏢 **회사별 화면 관리의 핵심 가치**
|
||||||
|
|
||||||
|
- **권한 격리**: 사용자는 자신이 속한 회사의 화면만 제작/수정 가능
|
||||||
|
- **관리자 통제**: 회사 코드 '\*'인 관리자는 모든 회사의 화면을 제어
|
||||||
|
- **메뉴 연동**: 각 회사의 메뉴에만 화면 할당하여 완벽한 데이터 분리
|
||||||
|
|
||||||
|
### 🎨 **향상된 사용자 경험**
|
||||||
|
|
||||||
|
- **드래그앤드롭 인터페이스**: 직관적인 화면 설계
|
||||||
|
- **컨테이너 그룹화**: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
|
||||||
|
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인
|
||||||
|
- **자동 위젯 생성**: 컬럼의 웹 타입에 따른 스마트한 위젯 생성
|
||||||
|
|
||||||
|
### 🚀 **기술적 혜택**
|
||||||
|
|
||||||
|
- **기존 테이블 구조 100% 호환**: 별도 스키마 변경 없이 바로 개발 가능
|
||||||
|
- **권한 기반 보안**: 회사 간 데이터 완전 격리
|
||||||
|
- **확장 가능한 아키텍처**: 새로운 웹 타입과 컴포넌트 쉽게 추가
|
||||||
|
|
||||||
|
이 시스템을 통해 ERP 시스템의 화면 개발 생산성을 크게 향상시키고, **회사별 맞춤형 화면 구성**과 **사용자 요구사항에 따른 빠른 화면 구성**이 가능해질 것입니다.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
{/* 최근 활동 */}
|
{/* 최근 활동 */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } 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";
|
||||||
|
|
||||||
|
// 단계별 진행을 위한 타입 정의
|
||||||
|
type Step = "list" | "design" | "template";
|
||||||
|
|
||||||
|
export default function ScreenManagementPage() {
|
||||||
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
|
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||||
|
|
||||||
|
// 단계별 제목과 설명
|
||||||
|
const stepConfig = {
|
||||||
|
list: {
|
||||||
|
title: "화면 목록 관리",
|
||||||
|
description: "생성된 화면들을 확인하고 관리하세요",
|
||||||
|
icon: "📋",
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
title: "화면 설계",
|
||||||
|
description: "드래그앤드롭으로 화면을 설계하세요",
|
||||||
|
icon: "🎨",
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: "템플릿 관리",
|
||||||
|
description: "화면 템플릿을 관리하고 재사용하세요",
|
||||||
|
icon: "📝",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다음 단계로 이동
|
||||||
|
const goToNextStep = (nextStep: Step) => {
|
||||||
|
setStepHistory((prev) => [...prev, nextStep]);
|
||||||
|
setCurrentStep(nextStep);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이전 단계로 이동
|
||||||
|
const goToPreviousStep = () => {
|
||||||
|
if (stepHistory.length > 1) {
|
||||||
|
const newHistory = stepHistory.slice(0, -1);
|
||||||
|
const previousStep = newHistory[newHistory.length - 1];
|
||||||
|
setStepHistory(newHistory);
|
||||||
|
setCurrentStep(previousStep);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 특정 단계로 이동
|
||||||
|
const goToStep = (step: Step) => {
|
||||||
|
setCurrentStep(step);
|
||||||
|
// 해당 단계까지의 히스토리만 유지
|
||||||
|
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||||
|
if (stepIndex !== -1) {
|
||||||
|
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단계별 진행 상태 확인
|
||||||
|
const isStepCompleted = (step: Step) => {
|
||||||
|
return stepHistory.includes(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 현재 단계가 마지막 단계인지 확인
|
||||||
|
const isLastStep = currentStep === "template";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<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>
|
||||||
|
<div className="text-sm text-gray-500">{stepConfig[currentStep].description}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단계별 진행 표시 */}
|
||||||
|
<div className="border-b bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{Object.entries(stepConfig).map(([step, config], index) => (
|
||||||
|
<div key={step} className="flex items-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => goToStep(step as Step)}
|
||||||
|
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all ${
|
||||||
|
currentStep === step
|
||||||
|
? "border-blue-600 bg-blue-600 text-white"
|
||||||
|
: isStepCompleted(step as Step)
|
||||||
|
? "border-green-500 bg-green-500 text-white"
|
||||||
|
: "border-gray-300 bg-white text-gray-400"
|
||||||
|
} ${isStepCompleted(step as Step) ? "cursor-pointer hover:bg-green-600" : ""}`}
|
||||||
|
>
|
||||||
|
{isStepCompleted(step as Step) && currentStep !== step ? (
|
||||||
|
<CheckCircle className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<span className="text-lg">{config.icon}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="mt-2 text-center">
|
||||||
|
<div
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
currentStep === step
|
||||||
|
? "text-blue-600"
|
||||||
|
: isStepCompleted(step as Step)
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{index < Object.keys(stepConfig).length - 1 && (
|
||||||
|
<div className={`mx-4 h-0.5 w-16 ${isStepCompleted(step as Step) ? "bg-green-500" : "bg-gray-300"}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단계별 내용 */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{/* 화면 목록 단계 */}
|
||||||
|
{currentStep === "list" && (
|
||||||
|
<div className="h-full p-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">{stepConfig.list.title}</h2>
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => goToNextStep("design")}>
|
||||||
|
화면 설계하기 <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScreenList
|
||||||
|
onScreenSelect={setSelectedScreen}
|
||||||
|
selectedScreen={selectedScreen}
|
||||||
|
onDesignScreen={(screen) => {
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
goToNextStep("design");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화면 설계 단계 */}
|
||||||
|
{currentStep === "design" && (
|
||||||
|
<div className="h-full">
|
||||||
|
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 템플릿 관리 단계 */}
|
||||||
|
{currentStep === "template" && (
|
||||||
|
<div className="h-full p-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">{stepConfig.template.title}</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goToPreviousStep}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
이전 단계
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => goToStep("list")}>
|
||||||
|
목록으로 돌아가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,260 @@
|
||||||
|
"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, Palette } from "lucide-react";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
|
interface ScreenListProps {
|
||||||
|
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||||
|
selectedScreen: ScreenDefinition | null;
|
||||||
|
onDesignScreen: (screen: ScreenDefinition) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: 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={() => onDesignScreen(screen)}>
|
||||||
|
<Palette className="mr-2 h-4 w-4" />
|
||||||
|
화면 설계
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
"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 {
|
||||||
|
selectedTable?: string;
|
||||||
|
onTableChange?: (tableName: string) => void;
|
||||||
|
onColumnWebTypeChange?: (columnInfo: ColumnInfo) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TableTypeSelector({
|
||||||
|
selectedTable: propSelectedTable,
|
||||||
|
onTableChange,
|
||||||
|
onColumnWebTypeChange,
|
||||||
|
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);
|
||||||
|
if (onTableChange) {
|
||||||
|
onTableChange(tableName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 선택
|
||||||
|
const handleColumnSelect = (column: ColumnInfo) => {
|
||||||
|
if (onColumnWebTypeChange) {
|
||||||
|
onColumnWebTypeChange(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
"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, FileText } from "lucide-react";
|
||||||
|
import { ScreenTemplate, LayoutData, ScreenDefinition } from "@/types/screen";
|
||||||
|
import { templateApi } from "@/lib/api/screen";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
|
interface TemplateManagerProps {
|
||||||
|
selectedScreen: ScreenDefinition | null;
|
||||||
|
onBackToList: () => void;
|
||||||
|
onTemplateSelect?: (template: ScreenTemplate) => void;
|
||||||
|
onTemplateApply?: (template: ScreenTemplate) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TemplateManager({
|
||||||
|
selectedScreen,
|
||||||
|
onBackToList,
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면이 선택되지 않았을 때 처리
|
||||||
|
if (!selectedScreen) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-gray-500">
|
||||||
|
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
||||||
|
<p className="mb-4 text-lg">템플릿을 적용할 화면을 선택해주세요</p>
|
||||||
|
<p className="mb-6 text-sm">화면 목록에서 화면을 선택한 후 템플릿을 관리하세요</p>
|
||||||
|
<Button onClick={onBackToList} variant="outline">
|
||||||
|
화면 목록으로 돌아가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ interface UserInfo {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
sabun?: string;
|
sabun?: string;
|
||||||
photo?: string | null;
|
photo?: string | null;
|
||||||
|
company_code?: string; // 회사 코드 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 상태 타입 정의
|
// 인증 상태 타입 정의
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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()}`;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
// 화면관리 시스템 타입 정의
|
||||||
|
|
||||||
|
// 기본 컴포넌트 타입
|
||||||
|
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
|
||||||
|
|
||||||
|
// 웹 타입 정의
|
||||||
|
export type WebType =
|
||||||
|
| "text"
|
||||||
|
| "number"
|
||||||
|
| "date"
|
||||||
|
| "code"
|
||||||
|
| "entity"
|
||||||
|
| "textarea"
|
||||||
|
| "select"
|
||||||
|
| "checkbox"
|
||||||
|
| "radio"
|
||||||
|
| "file"
|
||||||
|
| "email"
|
||||||
|
| "tel"
|
||||||
|
| "datetime"
|
||||||
|
| "dropdown"
|
||||||
|
| "text_area"
|
||||||
|
| "boolean"
|
||||||
|
| "decimal";
|
||||||
|
|
||||||
|
// 위치 정보
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 크기 정보
|
||||||
|
export interface Size {
|
||||||
|
width: number; // 1-12 그리드
|
||||||
|
height: number; // 픽셀
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 정보
|
||||||
|
export interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel: string;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스타일 관련 타입
|
||||||
|
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; // 스타일 속성 추가
|
||||||
|
tableName?: string; // 테이블명 추가
|
||||||
|
label?: string; // 라벨 추가
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컨테이너 컴포넌트
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue