feat: 화면 그룹 관리 기능 추가
- 화면 그룹 CRUD API 및 라우트 구현 - 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가 - 화면-그룹 연결 및 데이터 흐름 관리 기능 포함 - 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
This commit is contained in:
parent
ad76bfe3b0
commit
7caf2dea94
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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,55 +94,92 @@ export default function ScreenManagementPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
||||
{/* 페이지 헤더 */}
|
||||
<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 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>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="flex-1">
|
||||
{/* 화면 목록 단계 */}
|
||||
{currentStep === "list" && (
|
||||
<ScreenList
|
||||
onScreenSelect={setSelectedScreen}
|
||||
{/* 메인 콘텐츠 */}
|
||||
{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}
|
||||
onDesignScreen={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
goToNextStep("design");
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 템플릿 관리 단계 */}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue