feat: 화면 그룹 관리 기능 추가

- 화면 그룹 CRUD API 및 라우트 구현
- 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가
- 화면-그룹 연결 및 데이터 흐름 관리 기능 포함
- 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
This commit is contained in:
DDD1542 2026-01-05 10:05:31 +09:00
parent ad76bfe3b0
commit 7caf2dea94
12 changed files with 3853 additions and 78 deletions

View File

@ -72,6 +72,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
@ -196,6 +197,7 @@ app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);

View File

@ -0,0 +1,983 @@
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// pool 인스턴스 가져오기
const pool = getPool();
// ============================================================
// 화면 그룹 (screen_groups) CRUD
// ============================================================
// 화면 그룹 목록 조회
export const getScreenGroups = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const { page = 1, size = 20, searchTerm } = req.query;
const offset = (parseInt(page as string) - 1) * parseInt(size as string);
let whereClause = "WHERE 1=1";
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링 (멀티테넌시)
if (companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
// 검색어 필터링
if (searchTerm) {
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
params.push(`%${searchTerm}%`);
paramIndex++;
}
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as total FROM screen_groups ${whereClause}`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0].total);
// 데이터 조회
const dataQuery = `
SELECT
sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count
FROM screen_groups sg
${whereClause}
ORDER BY sg.display_order ASC, sg.created_date DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(size as string), offset);
const result = await pool.query(dataQuery, params);
logger.info("화면 그룹 목록 조회", { companyCode, total, count: result.rows.length });
res.json({
success: true,
data: result.rows,
total,
page: parseInt(page as string),
size: parseInt(size as string),
totalPages: Math.ceil(total / parseInt(size as string)),
});
} catch (error: any) {
logger.error("화면 그룹 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "화면 그룹 목록 조회에 실패했습니다.", error: error.message });
}
};
// 화면 그룹 상세 조회
export const getScreenGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
let query = `
SELECT sg.*,
(SELECT json_agg(
json_build_object(
'id', sgs.id,
'screen_id', sgs.screen_id,
'screen_name', sd.screen_name,
'screen_role', sgs.screen_role,
'display_order', sgs.display_order,
'is_default', sgs.is_default
) ORDER BY sgs.display_order
) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id
) as screens
FROM screen_groups sg
WHERE sg.id = $1
`;
const params: any[] = [id];
// 멀티테넌시 필터링
if (companyCode !== "*") {
query += ` AND sg.company_code = $2`;
params.push(companyCode);
}
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." });
}
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("화면 그룹 상세 조회 실패:", error);
res.status(500).json({ success: false, message: "화면 그룹 조회에 실패했습니다.", error: error.message });
}
};
// 화면 그룹 생성
export const createScreenGroup = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const { group_name, group_code, main_table_name, description, icon, display_order, is_active } = req.body;
if (!group_name || !group_code) {
return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." });
}
const query = `
INSERT INTO screen_groups (group_name, group_code, main_table_name, description, icon, display_order, is_active, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const params = [
group_name,
group_code,
main_table_name || null,
description || null,
icon || null,
display_order || 0,
is_active || 'Y',
companyCode === "*" ? "*" : companyCode,
userId
];
const result = await pool.query(query, params);
logger.info("화면 그룹 생성", { companyCode, groupId: result.rows[0].id, groupName: group_name });
res.json({ success: true, data: result.rows[0], message: "화면 그룹이 생성되었습니다." });
} catch (error: any) {
logger.error("화면 그룹 생성 실패:", error);
if (error.code === '23505') {
return res.status(400).json({ success: false, message: "이미 존재하는 그룹 코드입니다." });
}
res.status(500).json({ success: false, message: "화면 그룹 생성에 실패했습니다.", error: error.message });
}
};
// 화면 그룹 수정
export const updateScreenGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
const { group_name, group_code, main_table_name, description, icon, display_order, is_active } = req.body;
let query = `
UPDATE screen_groups
SET group_name = $1, group_code = $2, main_table_name = $3, description = $4,
icon = $5, display_order = $6, is_active = $7, updated_date = NOW()
WHERE id = $8
`;
const params: any[] = [group_name, group_code, main_table_name, description, icon, display_order, is_active, id];
// 멀티테넌시 필터링
if (companyCode !== "*") {
query += ` AND company_code = $9`;
params.push(companyCode);
}
query += " RETURNING *";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." });
}
logger.info("화면 그룹 수정", { companyCode, groupId: id });
res.json({ success: true, data: result.rows[0], message: "화면 그룹이 수정되었습니다." });
} catch (error: any) {
logger.error("화면 그룹 수정 실패:", error);
res.status(500).json({ success: false, message: "화면 그룹 수정에 실패했습니다.", error: error.message });
}
};
// 화면 그룹 삭제
export const deleteScreenGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_groups WHERE id = $1`;
const params: any[] = [id];
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += " RETURNING id";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." });
}
logger.info("화면 그룹 삭제", { companyCode, groupId: id });
res.json({ success: true, message: "화면 그룹이 삭제되었습니다." });
} catch (error: any) {
logger.error("화면 그룹 삭제 실패:", error);
res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message });
}
};
// ============================================================
// 화면-그룹 연결 (screen_group_screens) CRUD
// ============================================================
// 그룹에 화면 추가
export const addScreenToGroup = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const { group_id, screen_id, screen_role, display_order, is_default } = req.body;
if (!group_id || !screen_id) {
return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." });
}
const query = `
INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
const params = [
group_id,
screen_id,
screen_role || 'main',
display_order || 0,
is_default || 'N',
companyCode === "*" ? "*" : companyCode,
userId
];
const result = await pool.query(query, params);
logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id });
res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." });
} catch (error: any) {
logger.error("화면-그룹 연결 추가 실패:", error);
if (error.code === '23505') {
return res.status(400).json({ success: false, message: "이미 그룹에 추가된 화면입니다." });
}
res.status(500).json({ success: false, message: "화면 추가에 실패했습니다.", error: error.message });
}
};
// 그룹에서 화면 제거
export const removeScreenFromGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_group_screens WHERE id = $1`;
const params: any[] = [id];
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += " RETURNING id";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." });
}
logger.info("화면-그룹 연결 제거", { companyCode, id });
res.json({ success: true, message: "화면이 그룹에서 제거되었습니다." });
} catch (error: any) {
logger.error("화면-그룹 연결 제거 실패:", error);
res.status(500).json({ success: false, message: "화면 제거에 실패했습니다.", error: error.message });
}
};
// 그룹 내 화면 순서/역할 수정
export const updateScreenInGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
const { screen_role, display_order, is_default } = req.body;
let query = `
UPDATE screen_group_screens
SET screen_role = $1, display_order = $2, is_default = $3, updated_date = NOW()
WHERE id = $4
`;
const params: any[] = [screen_role, display_order, is_default, id];
if (companyCode !== "*") {
query += ` AND company_code = $5`;
params.push(companyCode);
}
query += " RETURNING *";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." });
}
res.json({ success: true, data: result.rows[0], message: "화면 정보가 수정되었습니다." });
} catch (error: any) {
logger.error("화면-그룹 연결 수정 실패:", error);
res.status(500).json({ success: false, message: "화면 정보 수정에 실패했습니다.", error: error.message });
}
};
// ============================================================
// 화면 필드 조인 설정 (screen_field_joins) CRUD
// ============================================================
// 화면 필드 조인 목록 조회
export const getFieldJoins = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const { screen_id } = req.query;
let query = `
SELECT sfj.*,
tl1.table_label as save_table_label,
tl2.table_label as join_table_label
FROM screen_field_joins sfj
LEFT JOIN table_labels tl1 ON sfj.save_table = tl1.table_name
LEFT JOIN table_labels tl2 ON sfj.join_table = tl2.table_name
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*") {
query += ` AND sfj.company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
if (screen_id) {
query += ` AND sfj.screen_id = $${paramIndex}`;
params.push(screen_id);
paramIndex++;
}
query += " ORDER BY sfj.id ASC";
const result = await pool.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("필드 조인 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "필드 조인 목록 조회에 실패했습니다.", error: error.message });
}
};
// 화면 필드 조인 생성
export const createFieldJoin = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const {
screen_id, layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
join_type, filter_condition, sort_column, sort_direction, is_active
} = req.body;
if (!screen_id || !save_table || !save_column || !join_table || !join_column || !display_column) {
return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다." });
}
const query = `
INSERT INTO screen_field_joins (
screen_id, layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
join_type, filter_condition, sort_column, sort_direction, is_active, company_code, writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *
`;
const params = [
screen_id, layout_id || null, component_id || null, field_name || null,
save_table, save_column, join_table, join_column, display_column,
join_type || 'LEFT', filter_condition || null, sort_column || null, sort_direction || 'ASC',
is_active || 'Y', companyCode === "*" ? "*" : companyCode, userId
];
const result = await pool.query(query, params);
logger.info("필드 조인 생성", { companyCode, screenId: screen_id, id: result.rows[0].id });
res.json({ success: true, data: result.rows[0], message: "필드 조인이 생성되었습니다." });
} catch (error: any) {
logger.error("필드 조인 생성 실패:", error);
res.status(500).json({ success: false, message: "필드 조인 생성에 실패했습니다.", error: error.message });
}
};
// 화면 필드 조인 수정
export const updateFieldJoin = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
const {
layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
join_type, filter_condition, sort_column, sort_direction, is_active
} = req.body;
let query = `
UPDATE screen_field_joins SET
layout_id = $1, component_id = $2, field_name = $3,
save_table = $4, save_column = $5, join_table = $6, join_column = $7, display_column = $8,
join_type = $9, filter_condition = $10, sort_column = $11, sort_direction = $12,
is_active = $13, updated_date = NOW()
WHERE id = $14
`;
const params: any[] = [
layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
join_type, filter_condition, sort_column, sort_direction, is_active, id
];
if (companyCode !== "*") {
query += ` AND company_code = $15`;
params.push(companyCode);
}
query += " RETURNING *";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." });
}
res.json({ success: true, data: result.rows[0], message: "필드 조인이 수정되었습니다." });
} catch (error: any) {
logger.error("필드 조인 수정 실패:", error);
res.status(500).json({ success: false, message: "필드 조인 수정에 실패했습니다.", error: error.message });
}
};
// 화면 필드 조인 삭제
export const deleteFieldJoin = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_field_joins WHERE id = $1`;
const params: any[] = [id];
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += " RETURNING id";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." });
}
res.json({ success: true, message: "필드 조인이 삭제되었습니다." });
} catch (error: any) {
logger.error("필드 조인 삭제 실패:", error);
res.status(500).json({ success: false, message: "필드 조인 삭제에 실패했습니다.", error: error.message });
}
};
// ============================================================
// 데이터 흐름 (screen_data_flows) CRUD
// ============================================================
// 데이터 흐름 목록 조회
export const getDataFlows = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const { group_id } = req.query;
let query = `
SELECT sdf.*,
sd1.screen_name as source_screen_name,
sd2.screen_name as target_screen_name,
sg.group_name
FROM screen_data_flows sdf
LEFT JOIN screen_definitions sd1 ON sdf.source_screen_id = sd1.screen_id
LEFT JOIN screen_definitions sd2 ON sdf.target_screen_id = sd2.screen_id
LEFT JOIN screen_groups sg ON sdf.group_id = sg.id
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*") {
query += ` AND sdf.company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
if (group_id) {
query += ` AND sdf.group_id = $${paramIndex}`;
params.push(group_id);
paramIndex++;
}
query += " ORDER BY sdf.id ASC";
const result = await pool.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("데이터 흐름 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "데이터 흐름 목록 조회에 실패했습니다.", error: error.message });
}
};
// 데이터 흐름 생성
export const createDataFlow = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const {
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping, flow_type, flow_label, condition_expression, is_active
} = req.body;
if (!source_screen_id || !target_screen_id) {
return res.status(400).json({ success: false, message: "소스 화면과 타겟 화면은 필수입니다." });
}
const query = `
INSERT INTO screen_data_flows (
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping, flow_type, flow_label, condition_expression, is_active, company_code, writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *
`;
const params = [
group_id || null, source_screen_id, source_action || null, target_screen_id, target_action || null,
data_mapping ? JSON.stringify(data_mapping) : null, flow_type || 'unidirectional',
flow_label || null, condition_expression || null, is_active || 'Y',
companyCode === "*" ? "*" : companyCode, userId
];
const result = await pool.query(query, params);
logger.info("데이터 흐름 생성", { companyCode, id: result.rows[0].id });
res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 생성되었습니다." });
} catch (error: any) {
logger.error("데이터 흐름 생성 실패:", error);
res.status(500).json({ success: false, message: "데이터 흐름 생성에 실패했습니다.", error: error.message });
}
};
// 데이터 흐름 수정
export const updateDataFlow = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
const {
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping, flow_type, flow_label, condition_expression, is_active
} = req.body;
let query = `
UPDATE screen_data_flows SET
group_id = $1, source_screen_id = $2, source_action = $3,
target_screen_id = $4, target_action = $5, data_mapping = $6,
flow_type = $7, flow_label = $8, condition_expression = $9,
is_active = $10, updated_date = NOW()
WHERE id = $11
`;
const params: any[] = [
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping ? JSON.stringify(data_mapping) : null, flow_type, flow_label, condition_expression, is_active, id
];
if (companyCode !== "*") {
query += ` AND company_code = $12`;
params.push(companyCode);
}
query += " RETURNING *";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." });
}
res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 수정되었습니다." });
} catch (error: any) {
logger.error("데이터 흐름 수정 실패:", error);
res.status(500).json({ success: false, message: "데이터 흐름 수정에 실패했습니다.", error: error.message });
}
};
// 데이터 흐름 삭제
export const deleteDataFlow = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_data_flows WHERE id = $1`;
const params: any[] = [id];
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += " RETURNING id";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." });
}
res.json({ success: true, message: "데이터 흐름이 삭제되었습니다." });
} catch (error: any) {
logger.error("데이터 흐름 삭제 실패:", error);
res.status(500).json({ success: false, message: "데이터 흐름 삭제에 실패했습니다.", error: error.message });
}
};
// ============================================================
// 화면-테이블 관계 (screen_table_relations) CRUD
// ============================================================
// 화면-테이블 관계 목록 조회
export const getTableRelations = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const { screen_id, group_id } = req.query;
let query = `
SELECT str.*,
sd.screen_name,
sg.group_name,
tl.table_label
FROM screen_table_relations str
LEFT JOIN screen_definitions sd ON str.screen_id = sd.screen_id
LEFT JOIN screen_groups sg ON str.group_id = sg.id
LEFT JOIN table_labels tl ON str.table_name = tl.table_name
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*") {
query += ` AND str.company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
if (screen_id) {
query += ` AND str.screen_id = $${paramIndex}`;
params.push(screen_id);
paramIndex++;
}
if (group_id) {
query += ` AND str.group_id = $${paramIndex}`;
params.push(group_id);
paramIndex++;
}
query += " ORDER BY str.id ASC";
const result = await pool.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("화면-테이블 관계 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "화면-테이블 관계 목록 조회에 실패했습니다.", error: error.message });
}
};
// 화면-테이블 관계 생성
export const createTableRelation = async (req: Request, res: Response) => {
try {
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
if (!screen_id || !table_name) {
return res.status(400).json({ success: false, message: "화면 ID와 테이블명은 필수입니다." });
}
const query = `
INSERT INTO screen_table_relations (group_id, screen_id, table_name, relation_type, crud_operations, description, is_active, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const params = [
group_id || null, screen_id, table_name, relation_type || 'main',
crud_operations || 'CRUD', description || null, is_active || 'Y',
companyCode === "*" ? "*" : companyCode, userId
];
const result = await pool.query(query, params);
logger.info("화면-테이블 관계 생성", { companyCode, screenId: screen_id, tableName: table_name });
res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 생성되었습니다." });
} catch (error: any) {
logger.error("화면-테이블 관계 생성 실패:", error);
res.status(500).json({ success: false, message: "화면-테이블 관계 생성에 실패했습니다.", error: error.message });
}
};
// 화면-테이블 관계 수정
export const updateTableRelation = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
let query = `
UPDATE screen_table_relations SET
group_id = $1, table_name = $2, relation_type = $3, crud_operations = $4,
description = $5, is_active = $6, updated_date = NOW()
WHERE id = $7
`;
const params: any[] = [group_id, table_name, relation_type, crud_operations, description, is_active, id];
if (companyCode !== "*") {
query += ` AND company_code = $8`;
params.push(companyCode);
}
query += " RETURNING *";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." });
}
res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 수정되었습니다." });
} catch (error: any) {
logger.error("화면-테이블 관계 수정 실패:", error);
res.status(500).json({ success: false, message: "화면-테이블 관계 수정에 실패했습니다.", error: error.message });
}
};
// 화면-테이블 관계 삭제
export const deleteTableRelation = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_table_relations WHERE id = $1`;
const params: any[] = [id];
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += " RETURNING id";
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." });
}
res.json({ success: true, message: "화면-테이블 관계가 삭제되었습니다." });
} catch (error: any) {
logger.error("화면-테이블 관계 삭제 실패:", error);
res.status(500).json({ success: false, message: "화면-테이블 관계 삭제에 실패했습니다.", error: error.message });
}
};
// ============================================================
// 화면 레이아웃 요약 정보 (미리보기용)
// ============================================================
// 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록)
export const getScreenLayoutSummary = async (req: Request, res: Response) => {
try {
const { screenId } = req.params;
// 화면의 컴포넌트 정보 조회
const query = `
SELECT
properties->>'widgetType' as widget_type,
properties->>'label' as label,
properties->>'fieldName' as field_name,
properties->>'tableName' as table_name
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
ORDER BY display_order ASC
`;
const result = await pool.query(query, [screenId]);
// 위젯 타입별 집계
const widgetCounts: Record<string, number> = {};
const labels: string[] = [];
const fields: Array<{ label: string; widgetType: string; fieldName?: string }> = [];
result.rows.forEach((row: any) => {
const widgetType = row.widget_type || 'text';
widgetCounts[widgetType] = (widgetCounts[widgetType] || 0) + 1;
if (row.label && row.label !== '기본 버튼') {
labels.push(row.label);
fields.push({
label: row.label,
widgetType: widgetType,
fieldName: row.field_name,
});
}
});
// 화면 타입 추론 (가장 많은 컴포넌트 기준)
let screenType = 'form'; // 기본값
if (widgetCounts['table'] > 0) {
screenType = 'grid';
} else if (widgetCounts['custom'] > 2) {
screenType = 'dashboard';
} else if (Object.keys(widgetCounts).length <= 2 && widgetCounts['button'] > 0) {
screenType = 'action';
}
logger.info("화면 레이아웃 요약 조회", { screenId, widgetCounts, fieldCount: fields.length });
res.json({
success: true,
data: {
screenId: parseInt(screenId),
screenType,
widgetCounts,
totalComponents: result.rows.length,
fields: fields.slice(0, 10), // 최대 10개
labels: labels.slice(0, 8), // 최대 8개
},
});
} catch (error: any) {
logger.error("화면 레이아웃 요약 조회 실패:", error);
res.status(500).json({ success: false, message: "화면 레이아웃 요약 조회에 실패했습니다.", error: error.message });
}
};
// 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함)
export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => {
try {
const { screenIds } = req.body;
if (!screenIds || !Array.isArray(screenIds) || screenIds.length === 0) {
return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." });
}
// 여러 화면의 컴포넌트 정보 (좌표 포함) 한번에 조회
// componentType이 더 정확한 위젯 종류 (table-list, button-primary 등)
const query = `
SELECT
screen_id,
component_type,
position_x,
position_y,
width,
height,
properties->>'componentType' as component_kind,
properties->>'widgetType' as widget_type,
properties->>'label' as label
FROM screen_layouts
WHERE screen_id = ANY($1)
AND component_type = 'component'
ORDER BY screen_id, display_order ASC
`;
const result = await pool.query(query, [screenIds]);
// 화면별로 그룹핑
const summaryMap: Record<number, any> = {};
screenIds.forEach((id: number) => {
summaryMap[id] = {
screenId: id,
screenType: 'form',
widgetCounts: {},
totalComponents: 0,
// 미니어처 렌더링용 레이아웃 데이터
layoutItems: [],
canvasWidth: 0,
canvasHeight: 0,
};
});
result.rows.forEach((row: any) => {
const screenId = row.screen_id;
// componentKind가 더 정확한 타입 (table-list, button-primary, table-search-widget 등)
const componentKind = row.component_kind || row.widget_type || 'text';
const widgetType = row.widget_type || 'text';
if (summaryMap[screenId]) {
summaryMap[screenId].widgetCounts[componentKind] =
(summaryMap[screenId].widgetCounts[componentKind] || 0) + 1;
summaryMap[screenId].totalComponents++;
// 레이아웃 아이템 추가 (미니어처 렌더링용)
summaryMap[screenId].layoutItems.push({
x: row.position_x || 0,
y: row.position_y || 0,
width: row.width || 100,
height: row.height || 30,
componentKind: componentKind, // 정확한 컴포넌트 종류
widgetType: widgetType,
label: row.label,
});
// 캔버스 크기 계산 (최대 좌표 기준)
const rightEdge = (row.position_x || 0) + (row.width || 100);
const bottomEdge = (row.position_y || 0) + (row.height || 30);
if (rightEdge > summaryMap[screenId].canvasWidth) {
summaryMap[screenId].canvasWidth = rightEdge;
}
if (bottomEdge > summaryMap[screenId].canvasHeight) {
summaryMap[screenId].canvasHeight = bottomEdge;
}
}
});
// 화면 타입 추론 (componentKind 기준)
Object.values(summaryMap).forEach((summary: any) => {
if (summary.widgetCounts['table-list'] > 0) {
summary.screenType = 'grid';
} else if (summary.widgetCounts['table-search-widget'] > 1) {
summary.screenType = 'dashboard';
} else if (summary.totalComponents <= 5 && summary.widgetCounts['button-primary'] > 0) {
summary.screenType = 'action';
}
});
logger.info("여러 화면 레이아웃 요약 조회", { screenIds, count: Object.keys(summaryMap).length });
res.json({
success: true,
data: summaryMap,
});
} catch (error: any) {
logger.error("여러 화면 레이아웃 요약 조회 실패:", error);
res.status(500).json({ success: false, message: "여러 화면 레이아웃 요약 조회에 실패했습니다.", error: error.message });
}
};

View File

@ -0,0 +1,87 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
// 화면 그룹
getScreenGroups,
getScreenGroup,
createScreenGroup,
updateScreenGroup,
deleteScreenGroup,
// 화면-그룹 연결
addScreenToGroup,
removeScreenFromGroup,
updateScreenInGroup,
// 필드 조인
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
// 데이터 흐름
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
// 화면-테이블 관계
getTableRelations,
createTableRelation,
updateTableRelation,
deleteTableRelation,
// 화면 레이아웃 요약
getScreenLayoutSummary,
getMultipleScreenLayoutSummary,
} from "../controllers/screenGroupController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ============================================================
// 화면 그룹 (screen_groups)
// ============================================================
router.get("/groups", getScreenGroups);
router.get("/groups/:id", getScreenGroup);
router.post("/groups", createScreenGroup);
router.put("/groups/:id", updateScreenGroup);
router.delete("/groups/:id", deleteScreenGroup);
// ============================================================
// 화면-그룹 연결 (screen_group_screens)
// ============================================================
router.post("/group-screens", addScreenToGroup);
router.put("/group-screens/:id", updateScreenInGroup);
router.delete("/group-screens/:id", removeScreenFromGroup);
// ============================================================
// 필드 조인 설정 (screen_field_joins)
// ============================================================
router.get("/field-joins", getFieldJoins);
router.post("/field-joins", createFieldJoin);
router.put("/field-joins/:id", updateFieldJoin);
router.delete("/field-joins/:id", deleteFieldJoin);
// ============================================================
// 데이터 흐름 (screen_data_flows)
// ============================================================
router.get("/data-flows", getDataFlows);
router.post("/data-flows", createDataFlow);
router.put("/data-flows/:id", updateDataFlow);
router.delete("/data-flows/:id", deleteDataFlow);
// ============================================================
// 화면-테이블 관계 (screen_table_relations)
// ============================================================
router.get("/table-relations", getTableRelations);
router.post("/table-relations", createTableRelation);
router.put("/table-relations/:id", updateTableRelation);
router.delete("/table-relations/:id", deleteTableRelation);
// ============================================================
// 화면 레이아웃 요약 (미리보기용)
// ============================================================
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
export default router;

View File

@ -1,68 +1,90 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
// 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template";
type ViewMode = "tree" | "table";
export default function ScreenManagementPage() {
const [currentStep, setCurrentStep] = useState<Step>("list");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
// 화면 목록 로드
const loadScreens = useCallback(async () => {
try {
setLoading(true);
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" });
// screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환
if (result.data && result.data.length > 0) {
setScreens(result.data);
}
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadScreens();
}, [loadScreens]);
// 화면 설계 모드일 때는 전체 화면 사용
const isDesignMode = currentStep === "design";
// 단계별 제목과 설명
const stepConfig = {
list: {
title: "화면 목록 관리",
description: "생성된 화면들을 확인하고 관리하세요",
},
design: {
title: "화면 설계",
description: "드래그앤드롭으로 화면을 설계하세요",
},
template: {
title: "템플릿 관리",
description: "화면 템플릿을 관리하고 재사용하세요",
},
};
// 다음 단계로 이동
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 handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
};
// 화면 디자인 핸들러
const handleDesignScreen = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
goToNextStep("design");
};
// 검색어로 필터링된 화면
const filteredScreens = screens.filter((screen) =>
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
);
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
if (isDesignMode) {
return (
<div className="fixed inset-0 z-50 bg-background">
@ -72,56 +94,93 @@ export default function ScreenManagementPage() {
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> 릿 </p>
</div>
{/* 단계별 내용 */}
<div className="flex-1">
{/* 화면 목록 단계 */}
{currentStep === "list" && (
<ScreenList
onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen}
onDesignScreen={(screen) => {
setSelectedScreen(screen);
goToNextStep("design");
}}
/>
)}
{/* 템플릿 관리 단계 */}
{currentStep === "template" && (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
<div className="flex gap-2">
<Button
variant="outline"
onClick={goToPreviousStep}
className="h-10 gap-2 text-sm font-medium"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
onClick={() => goToStep("list")}
className="h-10 gap-2 text-sm font-medium"
>
</Button>
</div>
</div>
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
)}
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
<div className="flex items-center gap-2">
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">
<TabsTrigger value="tree" className="gap-1.5 px-3">
<LayoutGrid className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="gap-1.5 px-3">
<LayoutList className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
{viewMode === "tree" ? (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: 트리 구조 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
/>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
/>
</div>
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<ScreenRelationFlow screen={selectedScreen} />
</div>
</div>
) : (
// 테이블 뷰 (기존 ScreenList 사용)
<div className="flex-1 overflow-auto p-6">
<ScreenList
onScreenSelect={handleScreenSelect}
selectedScreen={selectedScreen}
onDesignScreen={handleDesignScreen}
/>
</div>
)}
{/* 화면 생성 모달 */}
<CreateScreenModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
setIsCreateOpen(false);
loadScreens();
}}
/>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>

View File

@ -0,0 +1,232 @@
"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { ChevronRight, ChevronDown, Monitor, FolderOpen, Folder } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { ScreenGroup, getScreenGroups } from "@/lib/api/screenGroup";
import { Badge } from "@/components/ui/badge";
interface ScreenGroupTreeViewProps {
screens: ScreenDefinition[];
selectedScreen: ScreenDefinition | null;
onScreenSelect: (screen: ScreenDefinition) => void;
onScreenDesign: (screen: ScreenDefinition) => void;
companyCode?: string;
}
interface TreeNode {
type: "group" | "screen";
id: string;
name: string;
data?: ScreenDefinition | ScreenGroup;
children?: TreeNode[];
expanded?: boolean;
}
export function ScreenGroupTreeView({
screens,
selectedScreen,
onScreenSelect,
onScreenDesign,
companyCode,
}: ScreenGroupTreeViewProps) {
const [groups, setGroups] = useState<ScreenGroup[]>([]);
const [loading, setLoading] = useState(true);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupScreensMap, setGroupScreensMap] = useState<Map<number, number[]>>(new Map());
// 그룹 목록 로드
useEffect(() => {
const loadGroups = async () => {
try {
setLoading(true);
const response = await getScreenGroups({});
if (response.success && response.data) {
setGroups(response.data);
}
} catch (error) {
console.error("그룹 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadGroups();
}, [companyCode]);
// 그룹에 속한 화면 ID들을 가져오기
const getGroupedScreenIds = (): Set<number> => {
const ids = new Set<number>();
groupScreensMap.forEach((screenIds) => {
screenIds.forEach((id) => ids.add(id));
});
return ids;
};
// 미분류 화면들 (어떤 그룹에도 속하지 않은 화면)
const getUngroupedScreens = (): ScreenDefinition[] => {
const groupedIds = getGroupedScreenIds();
return screens.filter((screen) => !groupedIds.has(screen.screenId));
};
// 그룹에 속한 화면들
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
const screenIds = groupScreensMap.get(groupId) || [];
return screens.filter((screen) => screenIds.includes(screen.screenId));
};
const toggleGroup = (groupId: string) => {
const newExpanded = new Set(expandedGroups);
if (newExpanded.has(groupId)) {
newExpanded.delete(groupId);
} else {
newExpanded.add(groupId);
}
setExpandedGroups(newExpanded);
};
const handleScreenClick = (screen: ScreenDefinition) => {
onScreenSelect(screen);
};
const handleScreenDoubleClick = (screen: ScreenDefinition) => {
onScreenDesign(screen);
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
);
}
const ungroupedScreens = getUngroupedScreens();
return (
<div className="h-full overflow-auto">
<div className="p-2">
{/* 그룹화된 화면들 */}
{groups.map((group) => {
const groupId = String(group.id);
const isExpanded = expandedGroups.has(groupId);
const groupScreens = getScreensInGroup(group.id);
return (
<div key={groupId} className="mb-1">
{/* 그룹 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-sm font-medium"
)}
onClick={() => toggleGroup(groupId)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
)}
<span className="truncate flex-1">{group.groupName}</span>
<Badge variant="secondary" className="text-xs">
{groupScreens.length}
</Badge>
</div>
{/* 그룹 내 화면들 */}
{isExpanded && (
<div className="ml-4 mt-1 space-y-0.5">
{groupScreens.length === 0 ? (
<div className="pl-6 py-2 text-xs text-muted-foreground">
</div>
) : (
groupScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClick(screen)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
>
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{screen.screenCode}
</span>
</div>
))
)}
</div>
)}
</div>
);
})}
{/* 미분류 화면들 */}
{ungroupedScreens.length > 0 && (
<div className="mb-1">
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-sm font-medium text-muted-foreground"
)}
onClick={() => toggleGroup("ungrouped")}
>
{expandedGroups.has("ungrouped") ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<Folder className="h-4 w-4 shrink-0" />
<span className="truncate flex-1"></span>
<Badge variant="outline" className="text-xs">
{ungroupedScreens.length}
</Badge>
</div>
{expandedGroups.has("ungrouped") && (
<div className="ml-4 mt-1 space-y-0.5">
{ungroupedScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClick(screen)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
>
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{screen.screenCode}
</span>
</div>
))}
</div>
)}
</div>
)}
{groups.length === 0 && ungroupedScreens.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Monitor className="h-12 w-12 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground"> </p>
</div>
)}
</div>
</div>
);
}

View File

@ -42,6 +42,8 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateC
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
import { Layers } from "lucide-react";
import CreateScreenModal from "./CreateScreenModal";
import CopyScreenModal from "./CopyScreenModal";
import dynamic from "next/dynamic";
@ -93,6 +95,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 그룹 필터 관련 상태
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
const [groups, setGroups] = useState<ScreenGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
// 검색어 디바운스를 위한 타이머 ref
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
@ -183,6 +190,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
}
};
// 화면 그룹 목록 로드
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
try {
setLoadingGroups(true);
const response = await getScreenGroups();
if (response.success && response.data) {
setGroups(response.data);
}
} catch (error) {
console.error("그룹 목록 조회 실패:", error);
} finally {
setLoadingGroups(false);
}
};
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
useEffect(() => {
// 이전 타이머 취소
@ -224,6 +250,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
params.companyCode = selectedCompanyCode;
}
// 그룹 필터
if (selectedGroupId !== "all") {
params.groupId = selectedGroupId;
}
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
const resp = await screenApi.getScreens(params);
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
@ -256,7 +287,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
return () => {
abort = true;
};
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]);
const filteredScreens = screens; // 서버 필터 기준 사용
@ -671,6 +702,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div>
)}
{/* 그룹 필터 */}
<div className="w-full sm:w-[180px]">
<Select value={selectedGroupId} onValueChange={setSelectedGroupId} disabled={activeTab === "trash"}>
<SelectTrigger className="h-10 text-sm">
<Layers className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="전체 그룹" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="ungrouped"></SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.groupName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 검색 입력 */}
<div className="w-full sm:w-[400px]">
<div className="relative">

View File

@ -0,0 +1,446 @@
"use client";
import React from "react";
import { Handle, Position } from "@xyflow/react";
import {
Monitor,
Database,
FormInput,
Table2,
LayoutDashboard,
MousePointer2,
Key,
Link2,
Columns3,
} from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
// ========== 타입 정의 ==========
// 화면 노드 데이터 인터페이스
export interface ScreenNodeData {
label: string;
subLabel?: string;
type: "screen" | "table" | "action";
tableName?: string;
isMain?: boolean;
// 레이아웃 요약 정보 (미리보기용)
layoutSummary?: ScreenLayoutSummary;
}
// 테이블 노드 데이터 인터페이스
export interface TableNodeData {
label: string;
subLabel?: string;
isMain?: boolean;
columns?: Array<{
name: string;
type: string;
isPrimaryKey?: boolean;
isForeignKey?: boolean;
}>;
}
// ========== 유틸리티 함수 ==========
// 화면 타입별 아이콘
const getScreenTypeIcon = (screenType?: string) => {
switch (screenType) {
case "grid":
return <Table2 className="h-4 w-4" />;
case "dashboard":
return <LayoutDashboard className="h-4 w-4" />;
case "action":
return <MousePointer2 className="h-4 w-4" />;
default:
return <FormInput className="h-4 w-4" />;
}
};
// 화면 타입별 색상 (헤더)
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
if (!isMain) return "bg-slate-400";
switch (screenType) {
case "grid":
return "bg-violet-500";
case "dashboard":
return "bg-amber-500";
case "action":
return "bg-rose-500";
default:
return "bg-blue-500";
}
};
// 화면 타입별 라벨
const getScreenTypeLabel = (screenType?: string) => {
switch (screenType) {
case "grid":
return "그리드";
case "dashboard":
return "대시보드";
case "action":
return "액션";
default:
return "폼";
}
};
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { label, subLabel, isMain, tableName, layoutSummary } = data;
const screenType = layoutSummary?.screenType || "form";
const headerColor = getScreenTypeColor(screenType, isMain);
return (
<div className="group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md transition-all hover:shadow-lg hover:ring-2 hover:ring-primary/20">
{/* Handles */}
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (컬러) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor}`}>
<Monitor className="h-4 w-4" />
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
{isMain && <span className="flex h-2 w-2 rounded-full bg-white/80" />}
</div>
{/* 화면 미리보기 영역 (컴팩트) */}
<div className="h-[140px] overflow-hidden bg-slate-50 p-2">
{layoutSummary ? (
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
{getScreenTypeIcon(screenType)}
<span className="mt-1 text-[10px]">: {label}</span>
</div>
)}
</div>
{/* 필드 매핑 영역 */}
<div className="flex-1 overflow-hidden border-t border-slate-200 bg-white px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-slate-500">
<Columns3 className="h-3 w-3" />
<span> </span>
<span className="ml-auto text-[8px] text-slate-400">
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
{layoutSummary?.layoutItems
?.filter(item => item.label && !item.componentKind?.includes('button'))
?.slice(0, 6)
?.map((item, idx) => (
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
<div className={`h-1.5 w-1.5 rounded-full ${
item.componentKind === 'table-list' ? 'bg-violet-400' :
item.componentKind?.includes('select') ? 'bg-amber-400' :
'bg-slate-400'
}`} />
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
</div>
)) || (
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
)}
</div>
</div>
{/* 푸터 (테이블 정보) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Database className="h-3 w-3" />
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
{getScreenTypeLabel(screenType)}
</span>
</div>
</div>
);
};
// ========== 컴포넌트 종류별 미니어처 색상 ==========
// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등)
const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련
if (componentKind === "table-list" || componentKind === "data-grid") {
return "bg-violet-200 border-violet-400";
}
// 검색 필터
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
return "bg-pink-200 border-pink-400";
}
// 버튼 관련
if (componentKind?.includes("button")) {
return "bg-blue-300 border-blue-500";
}
// 입력 필드
if (componentKind?.includes("input") || componentKind?.includes("text")) {
return "bg-slate-200 border-slate-400";
}
// 셀렉트/드롭다운
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
return "bg-amber-200 border-amber-400";
}
// 차트
if (componentKind?.includes("chart")) {
return "bg-emerald-200 border-emerald-400";
}
// 커스텀 위젯
if (componentKind === "custom") {
return "bg-pink-200 border-pink-400";
}
return "bg-slate-100 border-slate-300";
};
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: string }> = ({
layoutSummary,
screenType,
}) => {
const { totalComponents, widgetCounts } = layoutSummary;
// 그리드 화면 일러스트
if (screenType === "grid") {
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
<div className="flex-1" />
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
</div>
{/* 테이블 헤더 */}
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
))}
</div>
{/* 테이블 행들 */}
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
{[...Array(7)].map((_, i) => (
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-slate-100" : "bg-white"}`}>
{[...Array(5)].map((_, j) => (
<div key={j} className="h-2 flex-1 rounded bg-slate-300/70" />
))}
</div>
))}
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-2 pt-1">
<div className="h-2.5 w-4 rounded bg-slate-300" />
<div className="h-2.5 w-4 rounded bg-blue-500" />
<div className="h-2.5 w-4 rounded bg-slate-300" />
<div className="h-2.5 w-4 rounded bg-slate-300" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 폼 화면 일러스트
if (screenType === "form") {
return (
<div className="flex h-full flex-col gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 폼 필드들 */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-2.5 w-14 rounded bg-slate-400" />
<div className="h-5 flex-1 rounded-md border border-slate-300 bg-white shadow-sm" />
</div>
))}
{/* 버튼 영역 */}
<div className="mt-auto flex justify-end gap-2 border-t border-slate-100 pt-3">
<div className="h-5 w-14 rounded-md bg-slate-300 shadow-sm" />
<div className="h-5 w-14 rounded-md bg-blue-500 shadow-sm" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 대시보드 화면 일러스트
if (screenType === "dashboard") {
return (
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 카드/차트들 */}
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
<div className="h-10 rounded-md bg-emerald-300/80" />
</div>
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
</div>
<div className="col-span-2 rounded-lg bg-blue-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-blue-400" />
<div className="flex h-14 items-end gap-1">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-blue-400/80"
style={{ height: `${25 + Math.random() * 75}%` }}
/>
))}
</div>
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 액션 화면 일러스트 (버튼 중심)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
<div className="rounded-full bg-slate-100 p-4 text-slate-400">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-blue-500 shadow-sm" />
<div className="h-7 w-16 rounded-md bg-slate-300 shadow-sm" />
</div>
<div className="text-xs font-medium text-slate-400"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 기본 (알 수 없는 타입)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white text-slate-400">
<div className="rounded-full bg-slate-100 p-4">
{getScreenTypeIcon(screenType)}
</div>
<span className="text-sm font-medium">{totalComponents} </span>
</div>
);
};
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 ==========
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { label, subLabel, isMain, columns } = data;
return (
<div className="group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md transition-all hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20">
{/* Handles */}
<Handle
type="target"
position={Position.Top}
id="top"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (초록색) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${isMain ? "bg-emerald-600" : "bg-slate-500"}`}>
<Database className="h-4 w-4" />
<div className="flex-1 min-w-0">
<div className="truncate text-xs font-semibold">{label}</div>
{subLabel && <div className="truncate text-[10px] opacity-80">{subLabel}</div>}
</div>
</div>
{/* 컬럼 목록 */}
<div className="flex-1 overflow-hidden p-2">
{columns && columns.length > 0 ? (
<div className="flex h-full flex-col gap-0.5">
{columns.map((col, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 rounded bg-slate-50 px-2 py-1 hover:bg-slate-100"
>
{/* PK/FK 아이콘 */}
{col.isPrimaryKey && <Key className="h-3 w-3 text-amber-500" />}
{col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-3 w-3 text-blue-500" />}
{!col.isPrimaryKey && !col.isForeignKey && <div className="w-3" />}
{/* 컬럼명 */}
<span className="flex-1 truncate font-mono text-[10px] font-medium text-slate-700">
{col.name}
</span>
{/* 타입 */}
<span className="text-[9px] text-slate-400">{col.type}</span>
</div>
))}
{columns.length > 8 && (
<div className="text-center text-[9px] text-slate-400">... {columns.length - 8} </div>
)}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Database className="h-6 w-6 text-slate-300" />
<span className="mt-1 text-[9px] text-slate-400"> </span>
</div>
)}
</div>
{/* 푸터 */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<span className="text-[10px] text-muted-foreground">PostgreSQL</span>
{columns && (
<span className="text-[10px] text-muted-foreground">{columns.length} </span>
)}
</div>
</div>
);
};
// ========== 기존 호환성 유지용 ==========
export const LegacyScreenNode = ScreenNode;
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
return (
<div className="rounded-lg border-2 border-purple-300 bg-white p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
<div className="flex items-center gap-2 text-purple-600">
<Table2 className="h-4 w-4" />
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
</div>
</div>
);
};

View File

@ -0,0 +1,367 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import {
ReactFlow,
Controls,
Background,
BackgroundVariant,
Node,
Edge,
useNodesState,
useEdgesState,
MarkerType,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ScreenDefinition } from "@/types/screen";
import { ScreenNode, TableNode, ScreenNodeData, TableNodeData } from "./ScreenNode";
import {
getFieldJoins,
getDataFlows,
getTableRelations,
getMultipleScreenLayoutSummary,
ScreenLayoutSummary,
} from "@/lib/api/screenGroup";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
// 노드 타입 등록
const nodeTypes = {
screenNode: ScreenNode,
tableNode: TableNode,
};
// 레이아웃 상수
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
const TABLE_Y = 400; // 테이블 노드 Y 위치 (하단)
const NODE_WIDTH = 260; // 노드 너비 (조금 넓게)
const NODE_GAP = 40; // 노드 간격
interface ScreenRelationFlowProps {
screen: ScreenDefinition | null;
}
// 노드 타입 (Record<string, unknown> 확장)
type ScreenNodeType = Node<ScreenNodeData & Record<string, unknown>>;
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
type AllNodeType = ScreenNodeType | TableNodeType;
export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [loading, setLoading] = useState(false);
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
// 테이블 컬럼 정보 로드
const loadTableColumns = useCallback(
async (tableName: string): Promise<ColumnTypeInfo[]> => {
if (!tableName) return [];
if (tableColumns[tableName]) return tableColumns[tableName];
try {
const response = await getTableColumns(tableName);
if (response.success && response.data && response.data.columns) {
const columns = response.data.columns;
setTableColumns((prev) => ({ ...prev, [tableName]: columns }));
return columns;
}
} catch (error) {
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
}
return [];
},
[tableColumns]
);
// 데이터 로드 및 노드/엣지 생성
useEffect(() => {
if (!screen) {
setNodes([]);
setEdges([]);
return;
}
const loadRelations = async () => {
setLoading(true);
try {
// 관계 데이터 로드
const [joinsRes, flowsRes, relationsRes] = await Promise.all([
getFieldJoins(screen.screenId).catch(() => ({ success: false, data: [] })),
getDataFlows().catch(() => ({ success: false, data: [] })),
getTableRelations({ screen_id: screen.screenId }).catch(() => ({ success: false, data: [] })),
]);
const joins = joinsRes.success ? joinsRes.data || [] : [];
const flows = flowsRes.success ? flowsRes.data || [] : [];
const relations = relationsRes.success ? relationsRes.data || [] : [];
// ========== 화면 목록 수집 ==========
const screenList: ScreenDefinition[] = [screen];
// 데이터 흐름에서 연결된 화면들 추가
flows.forEach((flow: any) => {
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
if (!exists) {
screenList.push({
screenId: flow.target_screen_id,
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
screenCode: "",
tableName: "",
companyCode: screen.companyCode,
isActive: "Y",
createdDate: new Date(),
updatedDate: new Date(),
} as ScreenDefinition);
}
}
});
// 화면 레이아웃 요약 정보 로드
const screenIds = screenList.map((s) => s.screenId);
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
try {
const layoutRes = await getMultipleScreenLayoutSummary(screenIds);
if (layoutRes.success && layoutRes.data) {
// API 응답이 Record 형태 (screenId -> summary)
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
}
} catch (e) {
console.error("레이아웃 요약 로드 실패:", e);
}
// ========== 상단: 화면 노드들 ==========
const screenNodes: ScreenNodeType[] = [];
const screenStartX = 50;
screenList.forEach((scr, idx) => {
const isMain = scr.screenId === screen.screenId;
const summary = layoutSummaries[scr.screenId];
screenNodes.push({
id: `screen-${scr.screenId}`,
type: "screenNode",
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
data: {
label: scr.screenName,
subLabel: isMain ? "메인 화면" : "연결 화면",
type: "screen",
isMain,
tableName: scr.tableName,
layoutSummary: summary,
},
});
});
// ========== 하단: 테이블 노드들 ==========
const tableNodes: TableNodeType[] = [];
const tableSet = new Set<string>();
// 메인 화면의 테이블 추가
if (screen.tableName) {
tableSet.add(screen.tableName);
}
// 조인된 테이블들 추가
joins.forEach((join: any) => {
if (join.save_table) tableSet.add(join.save_table);
if (join.join_table) tableSet.add(join.join_table);
});
// 테이블 관계에서 추가
relations.forEach((rel: any) => {
if (rel.table_name) tableSet.add(rel.table_name);
});
// 테이블 노드 배치 (하단, 가로 배치)
const tableList = Array.from(tableSet);
const tableStartX = 50;
for (let idx = 0; idx < tableList.length; idx++) {
const tableName = tableList[idx];
const isMainTable = tableName === screen.tableName;
// 컬럼 정보 로드
let columns: ColumnTypeInfo[] = [];
try {
columns = await loadTableColumns(tableName);
} catch (e) {
// ignore
}
// 컬럼 정보를 PK/FK 표시와 함께 변환
const formattedColumns = columns.slice(0, 8).map((col) => ({
name: col.displayName || col.columnName || "",
type: col.dataType || "",
isPrimaryKey: col.isPrimaryKey || col.columnName === "id",
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
}));
tableNodes.push({
id: `table-${tableName}`,
type: "tableNode",
position: { x: tableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
data: {
label: tableName,
subLabel: isMainTable ? "메인 테이블" : "조인 테이블",
isMain: isMainTable,
columns: formattedColumns,
},
});
}
// ========== 엣지: 연결선 생성 ==========
const newEdges: Edge[] = [];
// 메인 화면 → 메인 테이블 연결 (양방향 CRUD)
if (screen.tableName) {
newEdges.push({
id: `edge-main`,
source: `screen-${screen.screenId}`,
target: `table-${screen.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
animated: true,
style: { stroke: "#3b82f6", strokeWidth: 2 },
});
}
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
joins.forEach((join: any, idx: number) => {
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
newEdges.push({
id: `edge-join-${idx}`,
source: `table-${join.save_table}`,
target: `table-${join.join_table}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
label: "1:N 관계",
labelStyle: { fontSize: 10, fill: "#6366f1", fontWeight: 500 },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#6366f1" },
style: { stroke: "#6366f1", strokeWidth: 1.5, strokeDasharray: "5,5" },
});
}
});
// 테이블 관계 엣지 (추가 관계)
relations.forEach((rel: any, idx: number) => {
if (rel.table_name && rel.table_name !== screen.tableName) {
// 화면 → 연결 테이블
const edgeExists = newEdges.some(
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
);
if (!edgeExists) {
newEdges.push({
id: `edge-rel-${idx}`,
source: `screen-${screen.screenId}`,
target: `table-${rel.table_name}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
labelStyle: { fontSize: 9, fill: "#10b981" },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [3, 2] as [number, number],
style: { stroke: "#10b981", strokeWidth: 1.5 },
});
}
}
});
// 데이터 흐름 엣지 (화면 간)
flows
.filter((flow: any) => flow.source_screen_id === screen.screenId)
.forEach((flow: any, idx: number) => {
if (flow.target_screen_id) {
newEdges.push({
id: `edge-flow-${idx}`,
source: `screen-${screen.screenId}`,
target: `screen-${flow.target_screen_id}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
animated: true,
label: flow.flow_label || flow.flow_type || "이동",
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
style: { stroke: "#8b5cf6", strokeWidth: 2 },
});
}
});
// 최종 노드 배열 합치기
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
// 테이블이 없으면 안내 노드 추가
if (tableNodes.length === 0) {
allNodes.push({
id: "hint-table",
type: "tableNode",
position: { x: 50, y: TABLE_Y },
data: {
label: "연결된 테이블 없음",
subLabel: "화면에 테이블을 설정하세요",
isMain: false,
columns: [],
},
});
}
setNodes(allNodes);
setEdges(newEdges);
} catch (error) {
console.error("관계 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadRelations();
}, [screen, setNodes, setEdges, loadTableColumns]);
if (!screen) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<p className="text-sm"> </p>
<p className="text-sm"> </p>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
);
}
return (
<div className="h-full w-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.3}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
<Controls position="bottom-right" />
</ReactFlow>
</div>
);
}

View File

@ -0,0 +1,296 @@
"use client";
import { useState, useEffect } from "react";
import { ScreenDefinition } from "@/types/screen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Database,
Monitor,
ArrowRight,
Link2,
Table,
Columns,
ExternalLink,
Layers,
GitBranch
} from "lucide-react";
import { getFieldJoins, getDataFlows, getTableRelations, FieldJoin, DataFlow, TableRelation } from "@/lib/api/screenGroup";
import { screenApi } from "@/lib/api/screen";
interface ScreenRelationViewProps {
screen: ScreenDefinition | null;
}
export function ScreenRelationView({ screen }: ScreenRelationViewProps) {
const [loading, setLoading] = useState(false);
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [tableRelations, setTableRelations] = useState<TableRelation[]>([]);
const [layoutInfo, setLayoutInfo] = useState<any>(null);
useEffect(() => {
const loadRelations = async () => {
if (!screen) {
setFieldJoins([]);
setDataFlows([]);
setTableRelations([]);
setLayoutInfo(null);
return;
}
try {
setLoading(true);
// 병렬로 데이터 로드
const [joinsRes, flowsRes, relationsRes, layoutRes] = await Promise.all([
getFieldJoins(screen.screenId),
getDataFlows(screen.screenId),
getTableRelations(screen.screenId),
screenApi.getLayout(screen.screenId).catch(() => null),
]);
if (joinsRes.success && joinsRes.data) {
setFieldJoins(joinsRes.data);
}
if (flowsRes.success && flowsRes.data) {
setDataFlows(flowsRes.data);
}
if (relationsRes.success && relationsRes.data) {
setTableRelations(relationsRes.data);
}
if (layoutRes) {
setLayoutInfo(layoutRes);
}
} catch (error) {
console.error("관계 정보 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadRelations();
}, [screen?.screenId]);
if (!screen) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<Layers className="h-16 w-16 text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground/70">
</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
);
}
// 컴포넌트에서 사용하는 테이블 분석
const getUsedTables = () => {
const tables = new Set<string>();
if (screen.tableName) {
tables.add(screen.tableName);
}
if (layoutInfo?.components) {
layoutInfo.components.forEach((comp: any) => {
if (comp.properties?.tableName) {
tables.add(comp.properties.tableName);
}
if (comp.properties?.dataSource?.tableName) {
tables.add(comp.properties.dataSource.tableName);
}
});
}
return Array.from(tables);
};
const usedTables = getUsedTables();
return (
<div className="p-4 space-y-4 overflow-auto h-full">
{/* 화면 기본 정보 */}
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10">
<Monitor className="h-6 w-6 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{screen.screenName}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline">{screen.screenCode}</Badge>
<Badge variant="secondary">{screen.screenType}</Badge>
</div>
{screen.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{screen.description}
</p>
)}
</div>
</div>
<Separator />
{/* 연결된 테이블 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-green-500" />
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{usedTables.length > 0 ? (
<div className="space-y-2">
{usedTables.map((tableName, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Table className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-mono">{tableName}</span>
{tableName === screen.tableName && (
<Badge variant="default" className="text-xs ml-auto">
</Badge>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 필드 조인 관계 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Link2 className="h-4 w-4 text-purple-500" />
{fieldJoins.length > 0 && (
<Badge variant="secondary" className="ml-auto">{fieldJoins.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{fieldJoins.length > 0 ? (
<div className="space-y-2">
{fieldJoins.map((join) => (
<div
key={join.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<div className="flex items-center gap-1">
<span className="font-mono text-xs">{join.sourceTable}</span>
<span className="text-muted-foreground">.</span>
<span className="font-mono text-xs text-blue-600">{join.sourceColumn}</span>
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="flex items-center gap-1">
<span className="font-mono text-xs">{join.targetTable}</span>
<span className="text-muted-foreground">.</span>
<span className="font-mono text-xs text-green-600">{join.targetColumn}</span>
</div>
<Badge variant="outline" className="ml-auto text-xs">
{join.joinType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 데이터 흐름 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<GitBranch className="h-4 w-4 text-orange-500" />
{dataFlows.length > 0 && (
<Badge variant="secondary" className="ml-auto">{dataFlows.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{dataFlows.length > 0 ? (
<div className="space-y-2">
{dataFlows.map((flow) => (
<div
key={flow.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<Monitor className="h-4 w-4 text-blue-500" />
<span className="truncate">{flow.flowName || "이름 없음"}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Monitor className="h-4 w-4 text-green-500" />
<span className="text-muted-foreground truncate">
#{flow.targetScreenId}
</span>
<Badge variant="outline" className="ml-auto text-xs">
{flow.flowType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 테이블 관계 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Columns className="h-4 w-4 text-cyan-500" />
{tableRelations.length > 0 && (
<Badge variant="secondary" className="ml-auto">{tableRelations.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{tableRelations.length > 0 ? (
<div className="space-y-2">
{tableRelations.map((relation) => (
<div
key={relation.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<Table className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-xs">{relation.parentTable}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs">{relation.childTable}</span>
<Badge variant="outline" className="ml-auto text-xs">
{relation.relationType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 빠른 작업 */}
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground mb-2">
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,457 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Plus, ArrowRight, Trash2, Pencil, GitBranch, RefreshCw } from "lucide-react";
import {
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
DataFlow,
} from "@/lib/api/screenGroup";
interface DataFlowPanelProps {
groupId?: number;
screenId?: number;
screens?: Array<{ screen_id: number; screen_name: string }>;
}
export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataFlowPanelProps) {
// 상태 관리
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<DataFlow | null>(null);
const [formData, setFormData] = useState({
source_screen_id: 0,
source_action: "",
target_screen_id: 0,
target_action: "",
data_mapping: "",
flow_type: "unidirectional",
flow_label: "",
condition_expression: "",
is_active: "Y",
});
// 데이터 로드
const loadDataFlows = useCallback(async () => {
setLoading(true);
try {
const response = await getDataFlows(groupId);
if (response.success && response.data) {
setDataFlows(response.data);
}
} catch (error) {
console.error("데이터 흐름 로드 실패:", error);
} finally {
setLoading(false);
}
}, [groupId]);
useEffect(() => {
loadDataFlows();
}, [loadDataFlows]);
// 모달 열기
const openModal = (flow?: DataFlow) => {
if (flow) {
setSelectedFlow(flow);
setFormData({
source_screen_id: flow.source_screen_id,
source_action: flow.source_action || "",
target_screen_id: flow.target_screen_id,
target_action: flow.target_action || "",
data_mapping: flow.data_mapping ? JSON.stringify(flow.data_mapping, null, 2) : "",
flow_type: flow.flow_type,
flow_label: flow.flow_label || "",
condition_expression: flow.condition_expression || "",
is_active: flow.is_active,
});
} else {
setSelectedFlow(null);
setFormData({
source_screen_id: screenId || 0,
source_action: "",
target_screen_id: 0,
target_action: "",
data_mapping: "",
flow_type: "unidirectional",
flow_label: "",
condition_expression: "",
is_active: "Y",
});
}
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.source_screen_id || !formData.target_screen_id) {
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
return;
}
try {
let dataMappingJson = null;
if (formData.data_mapping) {
try {
dataMappingJson = JSON.parse(formData.data_mapping);
} catch {
toast.error("데이터 매핑 JSON 형식이 올바르지 않습니다.");
return;
}
}
const payload = {
group_id: groupId,
source_screen_id: formData.source_screen_id,
source_action: formData.source_action || null,
target_screen_id: formData.target_screen_id,
target_action: formData.target_action || null,
data_mapping: dataMappingJson,
flow_type: formData.flow_type,
flow_label: formData.flow_label || null,
condition_expression: formData.condition_expression || null,
is_active: formData.is_active,
};
let response;
if (selectedFlow) {
response = await updateDataFlow(selectedFlow.id, payload);
} else {
response = await createDataFlow(payload);
}
if (response.success) {
toast.success(selectedFlow ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
setIsModalOpen(false);
loadDataFlows();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("이 데이터 흐름을 삭제하시겠습니까?")) return;
try {
const response = await deleteDataFlow(id);
if (response.success) {
toast.success("데이터 흐름이 삭제되었습니다.");
loadDataFlows();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
}
};
// 액션 옵션
const sourceActions = [
{ value: "click", label: "클릭" },
{ value: "submit", label: "제출" },
{ value: "select", label: "선택" },
{ value: "change", label: "변경" },
{ value: "doubleClick", label: "더블클릭" },
];
const targetActions = [
{ value: "open", label: "열기" },
{ value: "load", label: "로드" },
{ value: "refresh", label: "새로고침" },
{ value: "save", label: "저장" },
{ value: "filter", label: "필터" },
];
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={loadDataFlows} className="h-8 w-8 p-0">
<RefreshCw className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 설명 */}
<p className="text-xs text-muted-foreground">
. (: 목록 )
</p>
{/* 흐름 목록 */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : dataFlows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
<GitBranch className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{dataFlows.map((flow) => (
<div
key={flow.id}
className="flex items-center justify-between rounded-lg border bg-card p-3 text-xs"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* 소스 화면 */}
<div className="flex flex-col">
<span className="font-medium truncate max-w-[100px]">
{flow.source_screen_name || `화면 ${flow.source_screen_id}`}
</span>
{flow.source_action && (
<span className="text-muted-foreground">{flow.source_action}</span>
)}
</div>
{/* 화살표 */}
<div className="flex items-center gap-1 text-primary">
<ArrowRight className="h-4 w-4" />
{flow.flow_type === "bidirectional" && (
<ArrowRight className="h-4 w-4 rotate-180" />
)}
</div>
{/* 타겟 화면 */}
<div className="flex flex-col">
<span className="font-medium truncate max-w-[100px]">
{flow.target_screen_name || `화면 ${flow.target_screen_id}`}
</span>
{flow.target_action && (
<span className="text-muted-foreground">{flow.target_action}</span>
)}
</div>
{/* 라벨 */}
{flow.flow_label && (
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">
{flow.flow_label}
</span>
)}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1 ml-2">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(flow)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(flow.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 추가/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{selectedFlow ? "데이터 흐름 수정" : "데이터 흐름 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 소스 화면 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Select
value={formData.source_screen_id.toString()}
onValueChange={(value) => setFormData({ ...formData, source_screen_id: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{screens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.source_action}
onValueChange={(value) => setFormData({ ...formData, source_action: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="액션 선택" />
</SelectTrigger>
<SelectContent>
{sourceActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 타겟 화면 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Select
value={formData.target_screen_id.toString()}
onValueChange={(value) => setFormData({ ...formData, target_screen_id: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{screens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.target_action}
onValueChange={(value) => setFormData({ ...formData, target_action: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="액션 선택" />
</SelectTrigger>
<SelectContent>
{targetActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 흐름 설정 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.flow_type}
onValueChange={(value) => setFormData({ ...formData, flow_type: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unidirectional"></SelectItem>
<SelectItem value="bidirectional"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.flow_label}
onChange={(e) => setFormData({ ...formData, flow_label: e.target.value })}
placeholder="예: 상세 보기"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 데이터 매핑 */}
<div>
<Label className="text-xs sm:text-sm"> (JSON)</Label>
<Textarea
value={formData.data_mapping}
onChange={(e) => setFormData({ ...formData, data_mapping: e.target.value })}
placeholder='{"source_field": "target_field"}'
className="min-h-[80px] font-mono text-xs sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
{/* 조건식 */}
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Input
value={formData.condition_expression}
onChange={(e) => setFormData({ ...formData, condition_expression: e.target.value })}
placeholder="예: data.status === 'active'"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{selectedFlow ? "수정" : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,409 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react";
import {
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
FieldJoin,
} from "@/lib/api/screenGroup";
interface FieldJoinPanelProps {
screenId: number;
componentId?: string;
layoutId?: number;
}
export default function FieldJoinPanel({ screenId, componentId, layoutId }: FieldJoinPanelProps) {
// 상태 관리
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedJoin, setSelectedJoin] = useState<FieldJoin | null>(null);
const [formData, setFormData] = useState({
field_name: "",
save_table: "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
sort_column: "",
sort_direction: "ASC",
is_active: "Y",
});
// 데이터 로드
const loadFieldJoins = useCallback(async () => {
if (!screenId) return;
setLoading(true);
try {
const response = await getFieldJoins(screenId);
if (response.success && response.data) {
// 현재 컴포넌트에 해당하는 조인만 필터링
const filtered = componentId
? response.data.filter(join => join.component_id === componentId)
: response.data;
setFieldJoins(filtered);
}
} catch (error) {
console.error("필드 조인 로드 실패:", error);
} finally {
setLoading(false);
}
}, [screenId, componentId]);
useEffect(() => {
loadFieldJoins();
}, [loadFieldJoins]);
// 모달 열기
const openModal = (join?: FieldJoin) => {
if (join) {
setSelectedJoin(join);
setFormData({
field_name: join.field_name || "",
save_table: join.save_table,
save_column: join.save_column,
join_table: join.join_table,
join_column: join.join_column,
display_column: join.display_column,
join_type: join.join_type,
filter_condition: join.filter_condition || "",
sort_column: join.sort_column || "",
sort_direction: join.sort_direction || "ASC",
is_active: join.is_active,
});
} else {
setSelectedJoin(null);
setFormData({
field_name: "",
save_table: "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
sort_column: "",
sort_direction: "ASC",
is_active: "Y",
});
}
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column || !formData.display_column) {
toast.error("필수 필드를 모두 입력해주세요.");
return;
}
try {
const payload = {
screen_id: screenId,
layout_id: layoutId,
component_id: componentId,
...formData,
};
let response;
if (selectedJoin) {
response = await updateFieldJoin(selectedJoin.id, payload);
} else {
response = await createFieldJoin(payload);
}
if (response.success) {
toast.success(selectedJoin ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
setIsModalOpen(false);
loadFieldJoins();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("이 조인 설정을 삭제하시겠습니까?")) return;
try {
const response = await deleteFieldJoin(id);
if (response.success) {
toast.success("조인 설정이 삭제되었습니다.");
loadFieldJoins();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
}
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
{/* 설명 */}
<p className="text-xs text-muted-foreground">
.
</p>
{/* 조인 목록 */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : fieldJoins.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
<Database className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="h-8 text-xs"> .</TableHead>
<TableHead className="h-8 text-xs"> .</TableHead>
<TableHead className="h-8 text-xs"> </TableHead>
<TableHead className="h-8 w-[60px] text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fieldJoins.map((join) => (
<TableRow key={join.id} className="text-xs">
<TableCell className="py-2">
<span className="font-mono">{join.save_table}.{join.save_column}</span>
</TableCell>
<TableCell className="py-2">
<span className="font-mono">{join.join_table}.{join.join_column}</span>
</TableCell>
<TableCell className="py-2">
<span className="font-mono">{join.display_column}</span>
</TableCell>
<TableCell className="py-2">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(join)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(join.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 추가/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{selectedJoin ? "조인 설정 수정" : "조인 설정 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 필드명 */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.field_name}
onChange={(e) => setFormData({ ...formData, field_name: e.target.value })}
placeholder="화면에 표시될 필드명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 저장 테이블/컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.save_table}
onChange={(e) => setFormData({ ...formData, save_table: e.target.value })}
placeholder="예: work_orders"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.save_column}
onChange={(e) => setFormData({ ...formData, save_column: e.target.value })}
placeholder="예: item_code"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 조인 테이블/컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.join_table}
onChange={(e) => setFormData({ ...formData, join_table: e.target.value })}
placeholder="예: item_mng"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.join_column}
onChange={(e) => setFormData({ ...formData, join_column: e.target.value })}
placeholder="예: id"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 표시 컬럼 */}
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.display_column}
onChange={(e) => setFormData({ ...formData, display_column: e.target.value })}
placeholder="예: item_name (화면에 표시될 컬럼)"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 조인 타입/정렬 */}
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.join_type}
onValueChange={(value) => setFormData({ ...formData, join_type: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEFT">LEFT JOIN</SelectItem>
<SelectItem value="INNER">INNER JOIN</SelectItem>
<SelectItem value="RIGHT">RIGHT JOIN</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.sort_column}
onChange={(e) => setFormData({ ...formData, sort_column: e.target.value })}
placeholder="예: name"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.sort_direction}
onValueChange={(value) => setFormData({ ...formData, sort_direction: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ASC"></SelectItem>
<SelectItem value="DESC"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 필터 조건 */}
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Textarea
value={formData.filter_condition}
onChange={(e) => setFormData({ ...formData, filter_condition: e.target.value })}
placeholder="예: is_active = 'Y'"
className="min-h-[60px] font-mono text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{selectedJoin ? "수정" : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,387 @@
/**
* API
* - (screen_groups)
* - - (screen_group_screens)
* - (screen_field_joins)
* - (screen_data_flows)
* - - (screen_table_relations)
*/
import { apiClient } from "./client";
// ============================================================
// 타입 정의
// ============================================================
export interface ScreenGroup {
id: number;
group_name: string;
group_code: string;
main_table_name?: string;
description?: string;
icon?: string;
display_order: number;
is_active: string;
company_code: string;
created_date?: string;
updated_date?: string;
writer?: string;
screen_count?: number;
screens?: ScreenGroupScreen[];
}
export interface ScreenGroupScreen {
id: number;
group_id: number;
screen_id: number;
screen_name?: string;
screen_role: string;
display_order: number;
is_default: string;
company_code: string;
}
export interface FieldJoin {
id: number;
screen_id: number;
layout_id?: number;
component_id?: string;
field_name?: string;
save_table: string;
save_column: string;
join_table: string;
join_column: string;
display_column: string;
join_type: string;
filter_condition?: string;
sort_column?: string;
sort_direction?: string;
is_active: string;
save_table_label?: string;
join_table_label?: string;
}
export interface DataFlow {
id: number;
group_id?: number;
source_screen_id: number;
source_action?: string;
target_screen_id: number;
target_action?: string;
data_mapping?: Record<string, any>;
flow_type: string;
flow_label?: string;
condition_expression?: string;
is_active: string;
source_screen_name?: string;
target_screen_name?: string;
group_name?: string;
}
export interface TableRelation {
id: number;
group_id?: number;
screen_id: number;
table_name: string;
relation_type: string;
crud_operations: string;
description?: string;
is_active: string;
screen_name?: string;
group_name?: string;
table_label?: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
total?: number;
page?: number;
size?: number;
totalPages?: number;
}
// ============================================================
// 화면 그룹 (screen_groups) API
// ============================================================
export async function getScreenGroups(params?: {
page?: number;
size?: number;
searchTerm?: string;
}): Promise<ApiResponse<ScreenGroup[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.size) queryParams.append("size", params.size.toString());
if (params?.searchTerm) queryParams.append("searchTerm", params.searchTerm);
const response = await apiClient.get(`/screen-groups/groups?${queryParams.toString()}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getScreenGroup(id: number): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.get(`/screen-groups/groups/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createScreenGroup(data: Partial<ScreenGroup>): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.post("/screen-groups/groups", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateScreenGroup(id: number, data: Partial<ScreenGroup>): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.put(`/screen-groups/groups/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteScreenGroup(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/groups/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면-그룹 연결 (screen_group_screens) API
// ============================================================
export async function addScreenToGroup(data: {
group_id: number;
screen_id: number;
screen_role?: string;
display_order?: number;
is_default?: string;
}): Promise<ApiResponse<ScreenGroupScreen>> {
try {
const response = await apiClient.post("/screen-groups/group-screens", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateScreenInGroup(id: number, data: {
screen_role?: string;
display_order?: number;
is_default?: string;
}): Promise<ApiResponse<ScreenGroupScreen>> {
try {
const response = await apiClient.put(`/screen-groups/group-screens/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function removeScreenFromGroup(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/group-screens/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 필드 조인 (screen_field_joins) API
// ============================================================
export async function getFieldJoins(screenId?: number): Promise<ApiResponse<FieldJoin[]>> {
try {
const queryParams = screenId ? `?screen_id=${screenId}` : "";
const response = await apiClient.get(`/screen-groups/field-joins${queryParams}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createFieldJoin(data: Partial<FieldJoin>): Promise<ApiResponse<FieldJoin>> {
try {
const response = await apiClient.post("/screen-groups/field-joins", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateFieldJoin(id: number, data: Partial<FieldJoin>): Promise<ApiResponse<FieldJoin>> {
try {
const response = await apiClient.put(`/screen-groups/field-joins/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteFieldJoin(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/field-joins/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 데이터 흐름 (screen_data_flows) API
// ============================================================
export async function getDataFlows(groupId?: number): Promise<ApiResponse<DataFlow[]>> {
try {
const queryParams = groupId ? `?group_id=${groupId}` : "";
const response = await apiClient.get(`/screen-groups/data-flows${queryParams}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createDataFlow(data: Partial<DataFlow>): Promise<ApiResponse<DataFlow>> {
try {
const response = await apiClient.post("/screen-groups/data-flows", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateDataFlow(id: number, data: Partial<DataFlow>): Promise<ApiResponse<DataFlow>> {
try {
const response = await apiClient.put(`/screen-groups/data-flows/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteDataFlow(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/data-flows/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면-테이블 관계 (screen_table_relations) API
// ============================================================
export async function getTableRelations(params?: {
screen_id?: number;
group_id?: number;
}): Promise<ApiResponse<TableRelation[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.screen_id) queryParams.append("screen_id", params.screen_id.toString());
if (params?.group_id) queryParams.append("group_id", params.group_id.toString());
const response = await apiClient.get(`/screen-groups/table-relations?${queryParams.toString()}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createTableRelation(data: Partial<TableRelation>): Promise<ApiResponse<TableRelation>> {
try {
const response = await apiClient.post("/screen-groups/table-relations", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateTableRelation(id: number, data: Partial<TableRelation>): Promise<ApiResponse<TableRelation>> {
try {
const response = await apiClient.put(`/screen-groups/table-relations/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteTableRelation(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/table-relations/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면 레이아웃 요약 (미리보기용) API
// ============================================================
// 레이아웃 아이템 (미니어처 렌더링용)
export interface LayoutItem {
x: number;
y: number;
width: number;
height: number;
componentKind: string; // 정확한 컴포넌트 종류 (table-list, button-primary 등)
widgetType: string; // 일반적인 위젯 타입 (button, text 등)
label?: string;
}
export interface ScreenLayoutSummary {
screenId: number;
screenType: 'form' | 'grid' | 'dashboard' | 'action';
widgetCounts: Record<string, number>;
totalComponents: number;
// 미니어처 렌더링용 레이아웃 데이터
layoutItems: LayoutItem[];
canvasWidth: number;
canvasHeight: number;
}
// 단일 화면 레이아웃 요약 조회
export async function getScreenLayoutSummary(screenId: number): Promise<ApiResponse<ScreenLayoutSummary>> {
try {
const response = await apiClient.get(`/screen-groups/layout-summary/${screenId}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 여러 화면 레이아웃 요약 일괄 조회
export async function getMultipleScreenLayoutSummary(
screenIds: number[]
): Promise<ApiResponse<Record<number, ScreenLayoutSummary>>> {
try {
const response = await apiClient.post("/screen-groups/layout-summary/batch", { screenIds });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}