feature/v2-unified-renewal #379
|
|
@ -169,14 +169,22 @@ router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res:
|
|||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||
|
||||
try {
|
||||
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
|
||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
|
@ -257,4 +265,31 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques
|
|||
}
|
||||
});
|
||||
|
||||
// 회사별 채번규칙 복제 (화면 복제 후 메뉴 동기화 완료 상태에서 호출)
|
||||
router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
|
||||
// 최고 관리자만 회사간 복제 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({ success: false, error: "최고 관리자만 회사간 채번규칙 복제가 가능합니다." });
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({ success: false, error: "원본 회사 코드와 대상 회사 코드가 필요합니다." });
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("회사별 채번규칙 복제 시작", { sourceCompanyCode, targetCompanyCode });
|
||||
|
||||
const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode);
|
||||
|
||||
logger.info("회사별 채번규칙 복제 완료", { sourceCompanyCode, targetCompanyCode, result });
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("회사별 채번규칙 복제 실패", { error: error.message, sourceCompanyCode, targetCompanyCode });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { MultiLangService } from "../services/multilangService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
syncScreenGroupsToMenu,
|
||||
syncMenuToScreenGroups,
|
||||
getSyncStatus,
|
||||
syncAllCompanies,
|
||||
} from "../services/menuScreenSyncService";
|
||||
|
||||
// pool 인스턴스 가져오기
|
||||
const pool = getPool();
|
||||
|
||||
// 다국어 서비스 인스턴스
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// ============================================================
|
||||
// 화면 그룹 (screen_groups) CRUD
|
||||
// ============================================================
|
||||
|
|
@ -17,7 +19,7 @@ const multiLangService = new MultiLangService();
|
|||
// 화면 그룹 목록 조회
|
||||
export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { page = 1, size = 20, searchTerm } = req.query;
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(size as string);
|
||||
|
||||
|
|
@ -92,7 +94,7 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
|
|||
export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `
|
||||
SELECT sg.*,
|
||||
|
|
@ -137,8 +139,8 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) =
|
|||
// 화면 그룹 생성
|
||||
export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body;
|
||||
|
||||
if (!group_name || !group_code) {
|
||||
|
|
@ -196,47 +198,6 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
// 업데이트된 데이터 반환
|
||||
const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]);
|
||||
|
||||
// 다국어 카테고리 자동 생성 (그룹 경로 기반)
|
||||
try {
|
||||
// 그룹 경로 조회 (상위 그룹 → 현재 그룹)
|
||||
const groupPathResult = await pool.query(
|
||||
`WITH RECURSIVE group_path AS (
|
||||
SELECT id, parent_group_id, group_name, group_level, 1 as depth
|
||||
FROM screen_groups
|
||||
WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1
|
||||
FROM screen_groups g
|
||||
INNER JOIN group_path gp ON g.id = gp.parent_group_id
|
||||
WHERE g.parent_group_id IS NOT NULL
|
||||
)
|
||||
SELECT group_name FROM group_path
|
||||
ORDER BY depth DESC`,
|
||||
[newGroupId]
|
||||
);
|
||||
|
||||
const groupPath = groupPathResult.rows.map((r: any) => r.group_name);
|
||||
|
||||
// 회사 이름 조회
|
||||
let companyName = "공통";
|
||||
if (finalCompanyCode !== "*") {
|
||||
const companyResult = await pool.query(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[finalCompanyCode]
|
||||
);
|
||||
if (companyResult.rows.length > 0) {
|
||||
companyName = companyResult.rows[0].company_name;
|
||||
}
|
||||
}
|
||||
|
||||
// 다국어 카테고리 생성
|
||||
await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath);
|
||||
logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode });
|
||||
} catch (multilangError: any) {
|
||||
// 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리
|
||||
logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message);
|
||||
}
|
||||
|
||||
logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id });
|
||||
|
||||
res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." });
|
||||
|
|
@ -253,7 +214,7 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body;
|
||||
|
||||
// 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지
|
||||
|
|
@ -340,10 +301,35 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
|
||||
// 화면 그룹 삭제
|
||||
export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상)
|
||||
const childGroupsResult = await client.query(`
|
||||
WITH RECURSIVE child_groups AS (
|
||||
SELECT id FROM screen_groups WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT sg.id FROM screen_groups sg
|
||||
JOIN child_groups cg ON sg.parent_group_id = cg.id
|
||||
)
|
||||
SELECT id FROM child_groups
|
||||
`, [id]);
|
||||
const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id);
|
||||
|
||||
// 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리
|
||||
if (groupIdsToDelete.length > 0) {
|
||||
await client.query(`
|
||||
UPDATE menu_info
|
||||
SET screen_group_id = NULL
|
||||
WHERE screen_group_id = ANY($1::int[])
|
||||
`, [groupIdsToDelete]);
|
||||
}
|
||||
|
||||
// 3. screen_groups 삭제
|
||||
let query = `DELETE FROM screen_groups WHERE id = $1`;
|
||||
const params: any[] = [id];
|
||||
|
||||
|
|
@ -354,18 +340,24 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
|
||||
query += " RETURNING id";
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
const result = await client.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("화면 그룹 삭제", { companyCode, groupId: id });
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
|
||||
|
||||
res.json({ success: true, message: "화면 그룹이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error("화면 그룹 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -377,14 +369,19 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
// 그룹에 화면 추가
|
||||
export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { group_id, screen_id, screen_role, display_order, is_default } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { group_id, screen_id, screen_role, display_order, is_default, target_company_code } = req.body;
|
||||
|
||||
if (!group_id || !screen_id) {
|
||||
return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." });
|
||||
}
|
||||
|
||||
// 최고 관리자가 다른 회사로 복제할 때 target_company_code 사용
|
||||
const effectiveCompanyCode = (userCompanyCode === "*" && target_company_code)
|
||||
? target_company_code
|
||||
: userCompanyCode;
|
||||
|
||||
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)
|
||||
|
|
@ -396,13 +393,13 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response)
|
|||
screen_role || 'main',
|
||||
display_order || 0,
|
||||
is_default || 'N',
|
||||
companyCode === "*" ? "*" : companyCode,
|
||||
effectiveCompanyCode,
|
||||
userId
|
||||
];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id });
|
||||
logger.info("화면-그룹 연결 추가", { companyCode: effectiveCompanyCode, groupId: group_id, screenId: screen_id });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." });
|
||||
} catch (error: any) {
|
||||
|
|
@ -418,7 +415,7 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response)
|
|||
export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `DELETE FROM screen_group_screens WHERE id = $1`;
|
||||
const params: any[] = [id];
|
||||
|
|
@ -449,7 +446,7 @@ export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Resp
|
|||
export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { screen_role, display_order, is_default } = req.body;
|
||||
|
||||
let query = `
|
||||
|
|
@ -487,7 +484,7 @@ export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Respon
|
|||
// 화면 필드 조인 목록 조회
|
||||
export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { screen_id } = req.query;
|
||||
|
||||
let query = `
|
||||
|
|
@ -528,8 +525,8 @@ export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) =>
|
|||
// 화면 필드 조인 생성
|
||||
export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const {
|
||||
screen_id, layout_id, component_id, field_name,
|
||||
save_table, save_column, join_table, join_column, display_column,
|
||||
|
|
@ -570,7 +567,7 @@ export const createFieldJoin = async (req: AuthenticatedRequest, res: Response)
|
|||
export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
layout_id, component_id, field_name,
|
||||
save_table, save_column, join_table, join_column, display_column,
|
||||
|
|
@ -615,7 +612,7 @@ export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response)
|
|||
export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `DELETE FROM screen_field_joins WHERE id = $1`;
|
||||
const params: any[] = [id];
|
||||
|
|
@ -648,7 +645,7 @@ export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response)
|
|||
// 데이터 흐름 목록 조회
|
||||
export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { group_id, source_screen_id } = req.query;
|
||||
|
||||
let query = `
|
||||
|
|
@ -698,8 +695,8 @@ export const getDataFlows = async (req: AuthenticatedRequest, res: Response) =>
|
|||
// 데이터 흐름 생성
|
||||
export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const {
|
||||
group_id, source_screen_id, source_action, target_screen_id, target_action,
|
||||
data_mapping, flow_type, flow_label, condition_expression, is_active
|
||||
|
|
@ -738,7 +735,7 @@ export const createDataFlow = async (req: AuthenticatedRequest, res: Response) =
|
|||
export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
group_id, source_screen_id, source_action, target_screen_id, target_action,
|
||||
data_mapping, flow_type, flow_label, condition_expression, is_active
|
||||
|
|
@ -781,7 +778,7 @@ export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) =
|
|||
export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `DELETE FROM screen_data_flows WHERE id = $1`;
|
||||
const params: any[] = [id];
|
||||
|
|
@ -814,7 +811,7 @@ export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) =
|
|||
// 화면-테이블 관계 목록 조회
|
||||
export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { screen_id, group_id } = req.query;
|
||||
|
||||
let query = `
|
||||
|
|
@ -863,8 +860,8 @@ export const getTableRelations = async (req: AuthenticatedRequest, res: Response
|
|||
// 화면-테이블 관계 생성
|
||||
export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
|
||||
|
||||
if (!screen_id || !table_name) {
|
||||
|
|
@ -897,7 +894,7 @@ export const createTableRelation = async (req: AuthenticatedRequest, res: Respon
|
|||
export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
|
||||
|
||||
let query = `
|
||||
|
|
@ -932,7 +929,7 @@ export const updateTableRelation = async (req: AuthenticatedRequest, res: Respon
|
|||
export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `DELETE FROM screen_table_relations WHERE id = $1`;
|
||||
const params: any[] = [id];
|
||||
|
|
@ -962,7 +959,7 @@ export const deleteTableRelation = async (req: AuthenticatedRequest, res: Respon
|
|||
// ============================================================
|
||||
|
||||
// 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록)
|
||||
export const getScreenLayoutSummary = async (req: Request, res: Response) => {
|
||||
export const getScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
|
||||
|
|
@ -1030,7 +1027,7 @@ export const getScreenLayoutSummary = async (req: Request, res: Response) => {
|
|||
};
|
||||
|
||||
// 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함)
|
||||
export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => {
|
||||
export const getMultipleScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenIds } = req.body;
|
||||
|
||||
|
|
@ -1230,7 +1227,7 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response
|
|||
// ============================================================
|
||||
|
||||
// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)
|
||||
export const getScreenSubTables = async (req: Request, res: Response) => {
|
||||
export const getScreenSubTables = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenIds } = req.body;
|
||||
|
||||
|
|
@ -2060,3 +2057,368 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 메뉴-화면그룹 동기화 API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 화면관리 → 메뉴 동기화
|
||||
* screen_groups를 menu_info로 동기화
|
||||
*/
|
||||
export const syncScreenGroupsToMenuController = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { targetCompanyCode } = req.body;
|
||||
|
||||
// 최고 관리자가 특정 회사를 지정한 경우 해당 회사로
|
||||
let companyCode = userCompanyCode;
|
||||
if (userCompanyCode === "*" && targetCompanyCode) {
|
||||
companyCode = targetCompanyCode;
|
||||
}
|
||||
|
||||
// 최고 관리자(*)는 회사를 지정해야 함
|
||||
if (companyCode === "*") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "동기화할 회사를 선택해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("화면관리 → 메뉴 동기화 요청", { companyCode, userId });
|
||||
|
||||
const result = await syncScreenGroupsToMenu(companyCode, userId);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "동기화 중 오류가 발생했습니다.",
|
||||
errors: result.errors,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면관리 → 메뉴 동기화 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "동기화에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 메뉴 → 화면관리 동기화
|
||||
* menu_info를 screen_groups로 동기화
|
||||
*/
|
||||
export const syncMenuToScreenGroupsController = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { targetCompanyCode } = req.body;
|
||||
|
||||
// 최고 관리자가 특정 회사를 지정한 경우 해당 회사로
|
||||
let companyCode = userCompanyCode;
|
||||
if (userCompanyCode === "*" && targetCompanyCode) {
|
||||
companyCode = targetCompanyCode;
|
||||
}
|
||||
|
||||
// 최고 관리자(*)는 회사를 지정해야 함
|
||||
if (companyCode === "*") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "동기화할 회사를 선택해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("메뉴 → 화면관리 동기화 요청", { companyCode, userId });
|
||||
|
||||
const result = await syncMenuToScreenGroups(companyCode, userId);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "동기화 중 오류가 발생했습니다.",
|
||||
errors: result.errors,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("메뉴 → 화면관리 동기화 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "동기화에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 동기화 상태 조회
|
||||
*/
|
||||
export const getSyncStatusController = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const { targetCompanyCode } = req.query;
|
||||
|
||||
// 최고 관리자가 특정 회사를 지정한 경우 해당 회사로
|
||||
let companyCode = userCompanyCode;
|
||||
if (userCompanyCode === "*" && targetCompanyCode) {
|
||||
companyCode = targetCompanyCode as string;
|
||||
}
|
||||
|
||||
// 최고 관리자(*)는 회사를 지정해야 함
|
||||
if (companyCode === "*") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "조회할 회사를 선택해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getSyncStatus(companyCode);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("동기화 상태 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "동기화 상태 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 회사 동기화
|
||||
* 모든 회사에 대해 양방향 동기화 수행 (최고 관리자만)
|
||||
*/
|
||||
export const syncAllCompaniesController = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
|
||||
// 최고 관리자만 전체 동기화 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "전체 동기화는 최고 관리자만 수행할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("전체 회사 동기화 요청", { userId });
|
||||
|
||||
const result = await syncAllCompanies(userId);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "전체 동기화 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 결과 요약
|
||||
const totalCreated = result.results.reduce((sum, r) => sum + r.created, 0);
|
||||
const totalLinked = result.results.reduce((sum, r) => sum + r.linked, 0);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `전체 동기화 완료: ${result.totalCompanies}개 회사 중 ${result.successCount}개 성공`,
|
||||
data: {
|
||||
totalCompanies: result.totalCompanies,
|
||||
successCount: result.successCount,
|
||||
failedCount: result.failedCount,
|
||||
totalCreated,
|
||||
totalLinked,
|
||||
details: result.results,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("전체 회사 동기화 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "전체 동기화에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* [PoC] screen_groups 기반 메뉴 트리 조회
|
||||
*
|
||||
* 기존 menu_info 대신 screen_groups를 사이드바 메뉴로 사용하기 위한 테스트 API
|
||||
* - screen_groups를 트리 구조로 반환
|
||||
* - 각 그룹에 연결된 기본 화면의 URL 포함
|
||||
* - menu_objid를 통해 권한 체크 가능
|
||||
*
|
||||
* DB 변경 없이 로직만 추가
|
||||
*/
|
||||
export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const { targetCompanyCode } = req.query;
|
||||
|
||||
// 조회할 회사 코드 결정
|
||||
const companyCode = userCompanyCode === "*" && targetCompanyCode
|
||||
? String(targetCompanyCode)
|
||||
: userCompanyCode;
|
||||
|
||||
logger.info("[PoC] screen_groups 기반 메뉴 트리 조회", {
|
||||
userCompanyCode,
|
||||
targetCompanyCode: companyCode
|
||||
});
|
||||
|
||||
// 1. screen_groups 조회 (계층 구조 포함)
|
||||
const groupsQuery = `
|
||||
SELECT
|
||||
sg.id,
|
||||
sg.group_name,
|
||||
sg.group_code,
|
||||
sg.parent_group_id,
|
||||
sg.group_level,
|
||||
sg.display_order,
|
||||
sg.icon,
|
||||
sg.is_active,
|
||||
sg.menu_objid,
|
||||
sg.company_code,
|
||||
-- 기본 화면 정보 (URL 생성용)
|
||||
(
|
||||
SELECT json_build_object(
|
||||
'screen_id', sd.screen_id,
|
||||
'screen_name', sd.screen_name,
|
||||
'screen_code', sd.screen_code
|
||||
)
|
||||
FROM screen_group_screens sgs
|
||||
JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
||||
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
|
||||
ORDER BY
|
||||
CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
|
||||
sgs.display_order ASC
|
||||
LIMIT 1
|
||||
) as default_screen,
|
||||
-- 하위 화면 개수
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM screen_group_screens sgs
|
||||
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
|
||||
) as screen_count
|
||||
FROM screen_groups sg
|
||||
WHERE sg.company_code = $1
|
||||
AND (sg.is_active = 'Y' OR sg.is_active IS NULL)
|
||||
ORDER BY sg.group_level ASC, sg.display_order ASC, sg.group_name ASC
|
||||
`;
|
||||
|
||||
const groupsResult = await pool.query(groupsQuery, [companyCode]);
|
||||
|
||||
// 2. 트리 구조로 변환
|
||||
const groups = groupsResult.rows;
|
||||
const groupMap = new Map<number, any>();
|
||||
const rootGroups: any[] = [];
|
||||
|
||||
// 먼저 모든 그룹을 Map에 저장
|
||||
for (const group of groups) {
|
||||
const menuItem = {
|
||||
id: group.id,
|
||||
objid: group.menu_objid || group.id, // 권한 체크용 (menu_objid 우선)
|
||||
name: group.group_name,
|
||||
name_kor: group.group_name,
|
||||
icon: group.icon,
|
||||
url: group.default_screen
|
||||
? `/screens/${group.default_screen.screen_id}`
|
||||
: null,
|
||||
screen_id: group.default_screen?.screen_id || null,
|
||||
screen_code: group.default_screen?.screen_code || null,
|
||||
screen_count: parseInt(group.screen_count) || 0,
|
||||
parent_id: group.parent_group_id,
|
||||
level: group.group_level || 0,
|
||||
display_order: group.display_order || 0,
|
||||
is_active: group.is_active === 'Y',
|
||||
menu_objid: group.menu_objid, // 기존 권한 시스템 연결용
|
||||
children: [],
|
||||
// menu_info 호환 필드
|
||||
menu_name_kor: group.group_name,
|
||||
menu_url: group.default_screen
|
||||
? `/screens/${group.default_screen.screen_id}`
|
||||
: null,
|
||||
parent_obj_id: null, // 나중에 설정
|
||||
seq: group.display_order || 0,
|
||||
status: group.is_active === 'Y' ? 'active' : 'inactive',
|
||||
};
|
||||
|
||||
groupMap.set(group.id, menuItem);
|
||||
}
|
||||
|
||||
// 부모-자식 관계 설정
|
||||
for (const group of groups) {
|
||||
const menuItem = groupMap.get(group.id);
|
||||
|
||||
if (group.parent_group_id && groupMap.has(group.parent_group_id)) {
|
||||
const parent = groupMap.get(group.parent_group_id);
|
||||
parent.children.push(menuItem);
|
||||
menuItem.parent_obj_id = parent.objid;
|
||||
} else {
|
||||
// 최상위 그룹
|
||||
rootGroups.push(menuItem);
|
||||
menuItem.parent_obj_id = "0";
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 통계 정보
|
||||
const stats = {
|
||||
totalGroups: groups.length,
|
||||
groupsWithScreens: groups.filter(g => g.default_screen).length,
|
||||
groupsWithMenuObjid: groups.filter(g => g.menu_objid).length,
|
||||
rootGroups: rootGroups.length,
|
||||
};
|
||||
|
||||
logger.info("[PoC] screen_groups 메뉴 트리 생성 완료", stats);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "[PoC] screen_groups 기반 메뉴 트리",
|
||||
data: rootGroups,
|
||||
stats,
|
||||
// 플랫 리스트도 제공 (기존 menu_info 형식 호환)
|
||||
flatList: Array.from(groupMap.values()).map(item => ({
|
||||
objid: String(item.objid),
|
||||
OBJID: String(item.objid),
|
||||
menu_name_kor: item.name,
|
||||
MENU_NAME_KOR: item.name,
|
||||
menu_url: item.url,
|
||||
MENU_URL: item.url,
|
||||
parent_obj_id: String(item.parent_obj_id || "0"),
|
||||
PARENT_OBJ_ID: String(item.parent_obj_id || "0"),
|
||||
seq: item.seq,
|
||||
SEQ: item.seq,
|
||||
status: item.status,
|
||||
STATUS: item.status,
|
||||
menu_type: 1, // 사용자 메뉴
|
||||
MENU_TYPE: 1,
|
||||
screen_group_id: item.id,
|
||||
menu_objid: item.menu_objid,
|
||||
})),
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("[PoC] screen_groups 메뉴 트리 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "메뉴 트리 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -834,3 +834,264 @@ export const cleanupDeletedScreenMenuAssignments = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
|
||||
export const updateTabScreenReferences = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { targetScreenIds, screenIdMap } = req.body;
|
||||
|
||||
if (!targetScreenIds || !Array.isArray(targetScreenIds)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "targetScreenIds 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!screenIdMap || typeof screenIdMap !== "object") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenIdMap 객체가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.updateTabScreenReferences(
|
||||
targetScreenIds,
|
||||
screenIdMap
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${result.updated}개 레이아웃의 탭 참조가 업데이트되었습니다.`,
|
||||
updated: result.updated,
|
||||
details: result.details,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("탭 screenId 참조 업데이트 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "탭 screenId 참조 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
|
||||
export const copyScreenMenuAssignments = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode, screenIdMap } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 권한 체크: 최고 관리자만 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!screenIdMap || typeof screenIdMap !== "object") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenIdMap 객체가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyScreenMenuAssignments(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode,
|
||||
screenIdMap
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `화면-메뉴 할당 ${result.copiedCount}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("화면-메뉴 할당 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면-메뉴 할당 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 코드 카테고리 + 코드 복제
|
||||
export const copyCodeCategoryAndCodes = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyCodeCategoryAndCodes(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `코드 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("코드 카테고리/코드 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 카테고리/코드 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 매핑 + 값 복제
|
||||
export const copyCategoryMapping = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyCategoryMapping(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `카테고리 매핑 ${result.copiedMappings}개, 값 ${result.copiedValues}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("카테고리 매핑/값 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 매핑/값 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 타입관리 입력타입 설정 복제
|
||||
export const copyTableTypeColumns = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyTableTypeColumns(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `테이블 타입 컬럼 ${result.copiedCount}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("테이블 타입 컬럼 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 타입 컬럼 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 연쇄관계 설정 복제
|
||||
export const copyCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyCascadingRelation(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `연쇄관계 설정 ${result.copiedCount}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("연쇄관계 설정 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄관계 설정 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ import {
|
|||
getScreensByMenu,
|
||||
unassignScreenFromMenu,
|
||||
cleanupDeletedScreenMenuAssignments,
|
||||
updateTabScreenReferences,
|
||||
copyScreenMenuAssignments,
|
||||
copyCodeCategoryAndCodes,
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -83,4 +89,22 @@ router.post(
|
|||
cleanupDeletedScreenMenuAssignments
|
||||
);
|
||||
|
||||
// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
|
||||
router.post("/screens/update-tab-references", updateTabScreenReferences);
|
||||
|
||||
// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
|
||||
router.post("/copy-menu-assignments", copyScreenMenuAssignments);
|
||||
|
||||
// 코드 카테고리 + 코드 복제
|
||||
router.post("/copy-code-category", copyCodeCategoryAndCodes);
|
||||
|
||||
// 카테고리 매핑 + 값 복제
|
||||
router.post("/copy-category-mapping", copyCategoryMapping);
|
||||
|
||||
// 테이블 타입 컬럼 복제
|
||||
router.post("/copy-table-type-columns", copyTableTypeColumns);
|
||||
|
||||
// 연쇄관계 설정 복제
|
||||
router.post("/copy-cascading-relation", copyCascadingRelation);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface MenuCopyResult {
|
|||
copiedCategoryMappings: number;
|
||||
copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정
|
||||
copiedCascadingRelations: number; // 연쇄관계 설정
|
||||
copiedNodeFlows: number; // 노드 플로우 (제어관리)
|
||||
copiedDataflowDiagrams: number; // 데이터플로우 다이어그램 (버튼 제어)
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
@ -983,6 +985,14 @@ export class MenuCopyService {
|
|||
client
|
||||
);
|
||||
|
||||
// === 2.1단계: 노드 플로우 복사는 화면 복사에서 처리 ===
|
||||
// (screenManagementService.ts의 copyScreen에서 처리)
|
||||
const copiedNodeFlows = 0;
|
||||
|
||||
// === 2.2단계: 데이터플로우 다이어그램 복사는 화면 복사에서 처리 ===
|
||||
// (screenManagementService.ts의 copyScreen에서 처리)
|
||||
const copiedDataflowDiagrams = 0;
|
||||
|
||||
// 변수 초기화
|
||||
let copiedCodeCategories = 0;
|
||||
let copiedCodes = 0;
|
||||
|
|
@ -1132,6 +1142,8 @@ export class MenuCopyService {
|
|||
copiedCategoryMappings,
|
||||
copiedTableTypeColumns,
|
||||
copiedCascadingRelations,
|
||||
copiedNodeFlows,
|
||||
copiedDataflowDiagrams,
|
||||
menuIdMap: Object.fromEntries(menuIdMap),
|
||||
screenIdMap: Object.fromEntries(screenIdMap),
|
||||
flowIdMap: Object.fromEntries(flowIdMap),
|
||||
|
|
@ -1144,6 +1156,8 @@ export class MenuCopyService {
|
|||
- 메뉴: ${result.copiedMenus}개
|
||||
- 화면: ${result.copiedScreens}개
|
||||
- 플로우: ${result.copiedFlows}개
|
||||
- 노드 플로우(제어관리): ${copiedNodeFlows}개
|
||||
- 데이터플로우 다이어그램(버튼 제어): ${copiedDataflowDiagrams}개
|
||||
- 코드 카테고리: ${copiedCodeCategories}개
|
||||
- 코드: ${copiedCodes}개
|
||||
- 채번규칙: ${copiedNumberingRules}개
|
||||
|
|
@ -2556,33 +2570,34 @@ export class MenuCopyService {
|
|||
}
|
||||
|
||||
// 4. 배치 INSERT로 채번 규칙 복사
|
||||
if (rulesToCopy.length > 0) {
|
||||
const ruleValues = rulesToCopy
|
||||
// menu 스코프인데 menu_objid 매핑이 없는 규칙은 제외 (연결 없이 복제하지 않음)
|
||||
const validRulesToCopy = rulesToCopy.filter((r) => {
|
||||
if (r.scope_type === "menu") {
|
||||
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.info(` ⏭️ 채번규칙 "${r.rule_name}" 건너뜀: 메뉴 연결 없음 (원본 menu_objid: ${r.menu_objid})`);
|
||||
// ruleIdMap에서도 제거
|
||||
ruleIdMap.delete(r.rule_id);
|
||||
return false; // 복제 대상에서 제외
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validRulesToCopy.length > 0) {
|
||||
const ruleValues = validRulesToCopy
|
||||
.map(
|
||||
(_, i) =>
|
||||
`($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const ruleParams = rulesToCopy.flatMap((r) => {
|
||||
const ruleParams = validRulesToCopy.flatMap((r) => {
|
||||
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
||||
// scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건)
|
||||
// menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로
|
||||
// scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리
|
||||
// menu 스코프인 경우 반드시 menu_objid가 있음 (위에서 필터링됨)
|
||||
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
|
||||
// scope_type 결정 로직:
|
||||
// 1. menu 스코프인데 menu_objid 매핑이 없는 경우
|
||||
// - table_name이 있으면 'table' 스코프로 변경
|
||||
// - table_name이 없으면 'global' 스코프로 변경
|
||||
// 2. 그 외에는 원본 scope_type 유지
|
||||
let finalScopeType = r.scope_type;
|
||||
if (r.scope_type === "menu" && finalMenuObjid === null) {
|
||||
if (r.table_name) {
|
||||
finalScopeType = "table"; // table_name이 있으면 table 스코프
|
||||
} else {
|
||||
finalScopeType = "global"; // table_name도 없으면 global 스코프
|
||||
}
|
||||
}
|
||||
// scope_type은 원본 유지 (menu 스코프는 반드시 menu_objid가 있으므로)
|
||||
const finalScopeType = r.scope_type;
|
||||
|
||||
return [
|
||||
r.newRuleId,
|
||||
|
|
@ -2610,8 +2625,8 @@ export class MenuCopyService {
|
|||
ruleParams
|
||||
);
|
||||
|
||||
copiedCount = rulesToCopy.length;
|
||||
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`);
|
||||
copiedCount = validRulesToCopy.length;
|
||||
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사 (${rulesToCopy.length - validRulesToCopy.length}개 건너뜀)`);
|
||||
}
|
||||
|
||||
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
|
||||
|
|
@ -3324,4 +3339,175 @@ export class MenuCopyService {
|
|||
logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`);
|
||||
return copiedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 복사 (node_flows 테이블 - 제어관리에서 사용)
|
||||
* - 원본 회사의 모든 node_flows를 대상 회사로 복사
|
||||
* - 대상 회사에 같은 이름의 노드 플로우가 있으면 재사용
|
||||
* - 없으면 새로 복사 (flow_data 포함)
|
||||
* - 원본 ID → 새 ID 매핑 반환 (버튼의 flowId, selectedDiagramId 매핑용)
|
||||
*/
|
||||
private async copyNodeFlows(
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{ copiedCount: number; nodeFlowIdMap: Map<number, number> }> {
|
||||
logger.info(`📋 노드 플로우(제어관리) 복사 시작`);
|
||||
const nodeFlowIdMap = new Map<number, number>();
|
||||
let copiedCount = 0;
|
||||
|
||||
// 1. 원본 회사의 모든 node_flows 조회
|
||||
const sourceFlowsResult = await client.query(
|
||||
`SELECT * FROM node_flows WHERE company_code = $1`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
if (sourceFlowsResult.rows.length === 0) {
|
||||
logger.info(` 📭 원본 회사에 노드 플로우 없음`);
|
||||
return { copiedCount: 0, nodeFlowIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}개`);
|
||||
|
||||
// 2. 대상 회사의 기존 노드 플로우 조회 (이름 기준)
|
||||
const existingFlowsResult = await client.query(
|
||||
`SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const existingFlowsByName = new Map<string, number>(
|
||||
existingFlowsResult.rows.map((f) => [f.flow_name, f.flow_id])
|
||||
);
|
||||
|
||||
// 3. 복사할 플로우 필터링 + 기존 플로우 매핑
|
||||
const flowsToCopy: any[] = [];
|
||||
for (const flow of sourceFlowsResult.rows) {
|
||||
const existingId = existingFlowsByName.get(flow.flow_name);
|
||||
if (existingId) {
|
||||
// 기존 플로우 재사용 - ID 매핑 추가
|
||||
nodeFlowIdMap.set(flow.flow_id, existingId);
|
||||
logger.info(` ♻️ 기존 노드 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`);
|
||||
} else {
|
||||
flowsToCopy.push(flow);
|
||||
}
|
||||
}
|
||||
|
||||
if (flowsToCopy.length === 0) {
|
||||
logger.info(` 📭 모든 노드 플로우가 이미 존재함 (매핑 ${nodeFlowIdMap.size}개)`);
|
||||
return { copiedCount: 0, nodeFlowIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 🔄 복사할 노드 플로우: ${flowsToCopy.length}개`);
|
||||
|
||||
// 4. 개별 INSERT (RETURNING으로 새 ID 획득)
|
||||
for (const flow of flowsToCopy) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING flow_id`,
|
||||
[
|
||||
flow.flow_name,
|
||||
flow.flow_description,
|
||||
JSON.stringify(flow.flow_data),
|
||||
targetCompanyCode,
|
||||
]
|
||||
);
|
||||
|
||||
const newFlowId = insertResult.rows[0].flow_id;
|
||||
nodeFlowIdMap.set(flow.flow_id, newFlowId);
|
||||
logger.info(` ➕ 노드 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`);
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
logger.info(` ✅ 노드 플로우 복사 완료: ${copiedCount}개, 매핑 ${nodeFlowIdMap.size}개`);
|
||||
|
||||
return { copiedCount, nodeFlowIdMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터플로우 다이어그램 복사 (dataflow_diagrams 테이블 - 버튼 제어 설정에서 사용)
|
||||
* - 원본 회사의 모든 dataflow_diagrams를 대상 회사로 복사
|
||||
* - 대상 회사에 같은 이름의 다이어그램이 있으면 재사용
|
||||
* - 없으면 새로 복사 (relationships, node_positions, control, plan, category 포함)
|
||||
* - 원본 ID → 새 ID 매핑 반환
|
||||
*/
|
||||
private async copyDataflowDiagrams(
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<{ copiedCount: number; diagramIdMap: Map<number, number> }> {
|
||||
logger.info(`📋 데이터플로우 다이어그램(버튼 제어) 복사 시작`);
|
||||
const diagramIdMap = new Map<number, number>();
|
||||
let copiedCount = 0;
|
||||
|
||||
// 1. 원본 회사의 모든 dataflow_diagrams 조회
|
||||
const sourceDiagramsResult = await client.query(
|
||||
`SELECT * FROM dataflow_diagrams WHERE company_code = $1`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
if (sourceDiagramsResult.rows.length === 0) {
|
||||
logger.info(` 📭 원본 회사에 데이터플로우 다이어그램 없음`);
|
||||
return { copiedCount: 0, diagramIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 📋 원본 데이터플로우 다이어그램: ${sourceDiagramsResult.rows.length}개`);
|
||||
|
||||
// 2. 대상 회사의 기존 다이어그램 조회 (이름 기준)
|
||||
const existingDiagramsResult = await client.query(
|
||||
`SELECT diagram_id, diagram_name FROM dataflow_diagrams WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const existingDiagramsByName = new Map<string, number>(
|
||||
existingDiagramsResult.rows.map((d) => [d.diagram_name, d.diagram_id])
|
||||
);
|
||||
|
||||
// 3. 복사할 다이어그램 필터링 + 기존 다이어그램 매핑
|
||||
const diagramsToCopy: any[] = [];
|
||||
for (const diagram of sourceDiagramsResult.rows) {
|
||||
const existingId = existingDiagramsByName.get(diagram.diagram_name);
|
||||
if (existingId) {
|
||||
// 기존 다이어그램 재사용 - ID 매핑 추가
|
||||
diagramIdMap.set(diagram.diagram_id, existingId);
|
||||
logger.info(` ♻️ 기존 다이어그램 재사용: ${diagram.diagram_name} (${diagram.diagram_id} → ${existingId})`);
|
||||
} else {
|
||||
diagramsToCopy.push(diagram);
|
||||
}
|
||||
}
|
||||
|
||||
if (diagramsToCopy.length === 0) {
|
||||
logger.info(` 📭 모든 다이어그램이 이미 존재함 (매핑 ${diagramIdMap.size}개)`);
|
||||
return { copiedCount: 0, diagramIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 🔄 복사할 다이어그램: ${diagramsToCopy.length}개`);
|
||||
|
||||
// 4. 개별 INSERT (RETURNING으로 새 ID 획득)
|
||||
for (const diagram of diagramsToCopy) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO dataflow_diagrams (diagram_name, relationships, company_code, created_by, node_positions, control, plan, category)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING diagram_id`,
|
||||
[
|
||||
diagram.diagram_name,
|
||||
JSON.stringify(diagram.relationships),
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
diagram.node_positions ? JSON.stringify(diagram.node_positions) : null,
|
||||
diagram.control ? JSON.stringify(diagram.control) : null,
|
||||
diagram.plan ? JSON.stringify(diagram.plan) : null,
|
||||
diagram.category ? JSON.stringify(diagram.category) : null,
|
||||
]
|
||||
);
|
||||
|
||||
const newDiagramId = insertResult.rows[0].diagram_id;
|
||||
diagramIdMap.set(diagram.diagram_id, newDiagramId);
|
||||
logger.info(` ➕ 다이어그램 복사: ${diagram.diagram_name} (${diagram.diagram_id} → ${newDiagramId})`);
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
logger.info(` ✅ 데이터플로우 다이어그램 복사 완료: ${copiedCount}개, 매핑 ${diagramIdMap.size}개`);
|
||||
|
||||
return { copiedCount, diagramIdMap };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -854,7 +854,13 @@ class NumberingRuleService {
|
|||
return { ...ruleResult.rows[0], parts };
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("채번 규칙 수정 실패", { error: error.message });
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
updates
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
|
|
@ -1062,6 +1068,203 @@ class NumberingRuleService {
|
|||
);
|
||||
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출)
|
||||
* 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결
|
||||
* 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트
|
||||
*/
|
||||
async copyRulesForCompany(
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string
|
||||
): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record<string, string> }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record<string, string> };
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두)
|
||||
const sourceRulesResult = await client.query(
|
||||
`SELECT nr.*, mi.menu_name_kor as source_menu_name
|
||||
FROM numbering_rules nr
|
||||
LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid
|
||||
WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
logger.info("원본 채번규칙 조회", {
|
||||
sourceCompanyCode,
|
||||
count: sourceRulesResult.rowCount
|
||||
});
|
||||
|
||||
// 2. 각 채번규칙 복제
|
||||
for (const rule of sourceRulesResult.rows) {
|
||||
// 새 rule_id 생성
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 이미 존재하는지 확인 (이름 기반)
|
||||
const existsCheck = await client.query(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE company_code = $1 AND rule_name = $2`,
|
||||
[targetCompanyCode, rule.rule_name]
|
||||
);
|
||||
|
||||
if (existsCheck.rows.length > 0) {
|
||||
// 이미 존재하면 매핑만 추가
|
||||
result.ruleIdMap[rule.rule_id] = existsCheck.rows[0].rule_id;
|
||||
result.skippedCount++;
|
||||
result.details.push(`건너뜀 (이미 존재): ${rule.rule_name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let targetMenuObjid = null;
|
||||
|
||||
// menu 스코프인 경우 대상 메뉴 찾기
|
||||
if (rule.scope_type === 'menu' && rule.source_menu_name) {
|
||||
const targetMenuResult = await client.query(
|
||||
`SELECT objid FROM menu_info
|
||||
WHERE company_code = $1 AND menu_name_kor = $2
|
||||
LIMIT 1`,
|
||||
[targetCompanyCode, rule.source_menu_name]
|
||||
);
|
||||
|
||||
if (targetMenuResult.rows.length === 0) {
|
||||
result.skippedCount++;
|
||||
result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
targetMenuObjid = targetMenuResult.rows[0].objid;
|
||||
}
|
||||
|
||||
// 채번규칙 복제
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
created_at, updated_at, created_by, scope_type, menu_objid
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`,
|
||||
[
|
||||
newRuleId,
|
||||
rule.rule_name,
|
||||
rule.description,
|
||||
rule.separator,
|
||||
rule.reset_period,
|
||||
0, // 시퀀스 초기화
|
||||
rule.table_name,
|
||||
rule.column_name,
|
||||
targetCompanyCode,
|
||||
rule.created_by,
|
||||
rule.scope_type,
|
||||
targetMenuObjid,
|
||||
]
|
||||
);
|
||||
|
||||
// 채번규칙 파트 복제
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
|
||||
[rule.rule_id]
|
||||
);
|
||||
|
||||
for (const part of partsResult.rows) {
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||
[
|
||||
newRuleId,
|
||||
part.part_order,
|
||||
part.part_type,
|
||||
part.generation_method,
|
||||
part.auto_config ? JSON.stringify(part.auto_config) : null,
|
||||
part.manual_config ? JSON.stringify(part.manual_config) : null,
|
||||
targetCompanyCode,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 매핑 추가
|
||||
result.ruleIdMap[rule.rule_id] = newRuleId;
|
||||
result.copiedCount++;
|
||||
result.details.push(`복제 완료: ${rule.rule_name} (${rule.scope_type})`);
|
||||
logger.info("채번규칙 복제 완료", {
|
||||
ruleName: rule.rule_name,
|
||||
oldRuleId: rule.rule_id,
|
||||
newRuleId,
|
||||
targetMenuObjid
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 화면 레이아웃의 numberingRuleId 참조 업데이트
|
||||
if (Object.keys(result.ruleIdMap).length > 0) {
|
||||
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
|
||||
targetCompanyCode,
|
||||
mappingCount: Object.keys(result.ruleIdMap).length
|
||||
});
|
||||
|
||||
// 대상 회사의 모든 화면 레이아웃 조회
|
||||
const layoutsResult = await client.query(
|
||||
`SELECT sl.layout_id, sl.properties
|
||||
FROM screen_layouts sl
|
||||
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
|
||||
WHERE sd.company_code = $1
|
||||
AND sl.properties::text LIKE '%numberingRuleId%'`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
|
||||
let updatedLayouts = 0;
|
||||
|
||||
for (const layout of layoutsResult.rows) {
|
||||
let propsStr = JSON.stringify(layout.properties);
|
||||
let updated = false;
|
||||
|
||||
// 각 매핑에 대해 치환
|
||||
for (const [oldRuleId, newRuleId] of Object.entries(result.ruleIdMap)) {
|
||||
if (propsStr.includes(`"${oldRuleId}"`)) {
|
||||
propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
await client.query(
|
||||
`UPDATE screen_layouts SET properties = $1::jsonb WHERE layout_id = $2`,
|
||||
[propsStr, layout.layout_id]
|
||||
);
|
||||
updatedLayouts++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
|
||||
targetCompanyCode,
|
||||
updatedLayouts
|
||||
});
|
||||
result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("회사별 채번규칙 복제 완료", {
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode,
|
||||
copiedCount: result.copiedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
ruleIdMapCount: Object.keys(result.ruleIdMap).length
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const numberingRuleService = new NumberingRuleService();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -111,7 +111,12 @@ export default function ScreenManagementPage() {
|
|||
};
|
||||
|
||||
// 검색어로 필터링된 화면
|
||||
const filteredScreens = screens.filter((screen) =>
|
||||
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
|
||||
// 단일 키워드면 해당 키워드로 화면 필터링
|
||||
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
||||
const filteredScreens = searchKeywords.length > 1
|
||||
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
|
||||
: screens.filter((screen) =>
|
||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
|
@ -183,6 +188,7 @@ export default function ScreenManagementPage() {
|
|||
selectedScreen={selectedScreen}
|
||||
onScreenSelect={handleScreenSelect}
|
||||
onScreenDesign={handleDesignScreen}
|
||||
searchTerm={searchTerm}
|
||||
onGroupSelect={(group) => {
|
||||
setSelectedGroup(group);
|
||||
setSelectedScreen(null); // 화면 선택 해제
|
||||
|
|
|
|||
|
|
@ -217,13 +217,16 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
});
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
|
||||
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
|
||||
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
|
||||
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
|
||||
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
||||
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
||||
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
|
||||
};
|
||||
|
||||
console.log("💾 채번 규칙 저장:", {
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ import {
|
|||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree } from "lucide-react";
|
||||
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree, Hash, Code, Table, Settings, Database } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { screenApi, updateTabScreenReferences } from "@/lib/api/screen";
|
||||
import { ScreenGroup, addScreenToGroup, createScreenGroup } from "@/lib/api/screenGroup";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -135,6 +136,15 @@ export default function CopyScreenModal({
|
|||
// 그룹 복제 모드: "all" (전체), "folder_only" (폴더만), "screen_only" (화면만)
|
||||
const [groupCopyMode, setGroupCopyMode] = useState<"all" | "folder_only" | "screen_only">("all");
|
||||
|
||||
// 채번규칙 복제 옵션 (체크 시: 복제 → 메뉴 동기화 → 채번규칙 복제 순서로 실행)
|
||||
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
|
||||
|
||||
// 추가 복사 옵션들
|
||||
const [copyCodeCategory, setCopyCodeCategory] = useState(false); // 코드 카테고리 + 코드 복사
|
||||
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); // 카테고리 매핑 + 값 복사
|
||||
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); // 테이블 타입관리 입력타입 설정 복사
|
||||
const [copyCascadingRelation, setCopyCascadingRelation] = useState(false); // 연쇄관계 설정 복사
|
||||
|
||||
// 복사 중 상태
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0, message: "" });
|
||||
|
|
@ -584,6 +594,7 @@ export default function CopyScreenModal({
|
|||
screen_id: result.mainScreen.screenId,
|
||||
screen_role: "MAIN",
|
||||
display_order: 1,
|
||||
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`);
|
||||
} catch (groupError) {
|
||||
|
|
@ -609,7 +620,7 @@ export default function CopyScreenModal({
|
|||
};
|
||||
|
||||
// 이름 변환 헬퍼 함수 (일괄 이름 변경 적용)
|
||||
const transformName = (originalName: string, isRootGroup: boolean = false): string => {
|
||||
const transformName = (originalName: string, isRootGroup: boolean = false, sourceCompanyCode?: string): string => {
|
||||
// 루트 그룹은 사용자가 직접 입력한 이름 사용
|
||||
if (isRootGroup) {
|
||||
return newGroupName.trim();
|
||||
|
|
@ -621,7 +632,12 @@ export default function CopyScreenModal({
|
|||
return originalName.replace(new RegExp(groupFindText, "g"), groupReplaceText);
|
||||
}
|
||||
|
||||
// 기본: "(복제)" 붙이기
|
||||
// 다른 회사로 복제하는 경우: 원본 이름 그대로 사용 (중복될 일 없음)
|
||||
if (sourceCompanyCode && sourceCompanyCode !== targetCompanyCode) {
|
||||
return originalName;
|
||||
}
|
||||
|
||||
// 같은 회사 내 복제: "(복제)" 붙이기 (중복 방지)
|
||||
return `${originalName} (복제)`;
|
||||
};
|
||||
|
||||
|
|
@ -633,17 +649,19 @@ export default function CopyScreenModal({
|
|||
screenCodes: string[], // 미리 생성된 화면 코드 배열
|
||||
codeIndex: { current: number }, // 현재 사용할 코드 인덱스 (참조로 전달)
|
||||
stats: { groups: number; screens: number },
|
||||
totalScreenCount: number // 전체 화면 수 (진행률 표시용)
|
||||
totalScreenCount: number, // 전체 화면 수 (진행률 표시용)
|
||||
screenIdMap: { [key: number]: number } // 원본 화면 ID -> 새 화면 ID 매핑
|
||||
): Promise<void> => {
|
||||
// 1. 현재 그룹 생성 (원본 display_order 유지)
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.floor(Math.random() * 1000);
|
||||
const newGroupCode = `${targetCompany}_GROUP_${timestamp}_${randomSuffix}`;
|
||||
|
||||
console.log(`📁 그룹 생성: ${sourceGroupData.group_name} (복제)`);
|
||||
const transformedGroupName = transformName(sourceGroupData.group_name, false, sourceGroupData.company_code);
|
||||
console.log(`📁 그룹 생성: ${transformedGroupName}`);
|
||||
|
||||
const newGroupResponse = await createScreenGroup({
|
||||
group_name: transformName(sourceGroupData.group_name), // 일괄 이름 변경 적용
|
||||
group_name: transformedGroupName, // 일괄 이름 변경 적용
|
||||
group_code: newGroupCode,
|
||||
parent_group_id: parentGroupId,
|
||||
target_company_code: targetCompany,
|
||||
|
|
@ -663,13 +681,29 @@ export default function CopyScreenModal({
|
|||
const sourceScreensInfo = sourceGroupData.screens || [];
|
||||
|
||||
// 화면 정보와 display_order를 함께 매핑
|
||||
// allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시)
|
||||
const screensWithOrder = sourceScreensInfo.map((s: any) => {
|
||||
const screenId = typeof s === 'object' ? s.screen_id : s;
|
||||
const displayOrder = typeof s === 'object' ? s.display_order : 0;
|
||||
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
|
||||
const screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
const screenName = typeof s === 'object' ? s.screen_name : '';
|
||||
const tableName = typeof s === 'object' ? s.table_name : '';
|
||||
|
||||
// allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체
|
||||
let screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
if (!screenData && screenId && screenName) {
|
||||
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
|
||||
screenData = {
|
||||
screenId: screenId,
|
||||
screenName: screenName,
|
||||
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
|
||||
tableName: tableName || '',
|
||||
description: '',
|
||||
companyCode: sourceGroupData.company_code || '',
|
||||
} as any;
|
||||
}
|
||||
return { screenId, displayOrder, screenRole, screenData };
|
||||
}).filter(item => item.screenData); // 화면 데이터가 있는 것만
|
||||
}).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만
|
||||
|
||||
// display_order 순으로 정렬
|
||||
screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
|
||||
|
|
@ -687,12 +721,13 @@ export default function CopyScreenModal({
|
|||
message: `화면 복제 중: ${screen.screenName}`
|
||||
});
|
||||
|
||||
console.log(` 📄 화면 복제: ${screen.screenName} → ${newScreenCode}`);
|
||||
const transformedScreenName = transformName(screen.screenName, false, sourceGroupData.company_code);
|
||||
console.log(` 📄 화면 복제: ${screen.screenName} → ${transformedScreenName}`);
|
||||
|
||||
const result = await screenApi.copyScreenWithModals(screen.screenId, {
|
||||
targetCompanyCode: targetCompany,
|
||||
mainScreen: {
|
||||
screenName: transformName(screen.screenName), // 일괄 이름 변경 적용
|
||||
screenName: transformedScreenName, // 일괄 이름 변경 적용
|
||||
screenCode: newScreenCode,
|
||||
description: screen.description || "",
|
||||
},
|
||||
|
|
@ -700,14 +735,18 @@ export default function CopyScreenModal({
|
|||
});
|
||||
|
||||
if (result.mainScreen?.screenId) {
|
||||
// 원본 화면 ID -> 새 화면 ID 매핑 기록
|
||||
screenIdMap[screen.screenId] = result.mainScreen.screenId;
|
||||
|
||||
await addScreenToGroup({
|
||||
group_id: newGroup.id,
|
||||
screen_id: result.mainScreen.screenId,
|
||||
screen_role: screenRole || "MAIN",
|
||||
display_order: displayOrder, // 원본 정렬순서 유지
|
||||
target_company_code: targetCompany, // 대상 회사 코드 전달
|
||||
});
|
||||
stats.screens++;
|
||||
console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName}`);
|
||||
console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId} → ${result.mainScreen.screenId})`);
|
||||
}
|
||||
} catch (screenError) {
|
||||
console.error(` ❌ 화면 복제 실패 (${screen.screenCode}):`, screenError);
|
||||
|
|
@ -730,7 +769,8 @@ export default function CopyScreenModal({
|
|||
screenCodes,
|
||||
codeIndex,
|
||||
stats,
|
||||
totalScreenCount
|
||||
totalScreenCount,
|
||||
screenIdMap // screenIdMap 전달
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -769,6 +809,7 @@ export default function CopyScreenModal({
|
|||
|
||||
const finalCompanyCode = targetCompanyCode || sourceGroup.company_code;
|
||||
const stats = { groups: 0, screens: 0 };
|
||||
const screenIdMap: { [key: number]: number } = {}; // 원본 화면 ID -> 새 화면 ID 매핑
|
||||
|
||||
console.log("🔄 그룹 복제 시작 (재귀적):", {
|
||||
sourceGroup: sourceGroup.group_name,
|
||||
|
|
@ -795,7 +836,7 @@ export default function CopyScreenModal({
|
|||
|
||||
// 일괄 이름 변경이 활성화된 경우 원본 이름에 변환 적용
|
||||
const rootGroupName = useGroupBulkRename && groupFindText
|
||||
? transformName(sourceGroup.group_name)
|
||||
? transformName(sourceGroup.group_name, false, sourceGroup.company_code)
|
||||
: newGroupName.trim();
|
||||
|
||||
const newGroupResponse = await createScreenGroup({
|
||||
|
|
@ -818,14 +859,41 @@ export default function CopyScreenModal({
|
|||
if (groupCopyMode !== "folder_only") {
|
||||
const sourceScreensInfo = sourceGroup.screens || [];
|
||||
|
||||
// 화면 정보와 display_order를 함께 매핑
|
||||
const screensWithOrder = sourceScreensInfo.map((s: any) => {
|
||||
const screenId = typeof s === 'object' ? s.screen_id : s;
|
||||
const displayOrder = typeof s === 'object' ? s.display_order : 0;
|
||||
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
|
||||
const screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
return { screenId, displayOrder, screenRole, screenData };
|
||||
}).filter(item => item.screenData);
|
||||
// 화면 정보와 display_order를 함께 매핑
|
||||
// allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시)
|
||||
console.log(`🔍 루트 그룹 화면 매핑 시작: ${sourceScreensInfo.length}개 화면, allScreens: ${allScreens.length}개`);
|
||||
const screensWithOrder = sourceScreensInfo.map((s: any) => {
|
||||
const screenId = typeof s === 'object' ? s.screen_id : s;
|
||||
const displayOrder = typeof s === 'object' ? s.display_order : 0;
|
||||
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
|
||||
const screenName = typeof s === 'object' ? s.screen_name : '';
|
||||
const tableName = typeof s === 'object' ? s.table_name : '';
|
||||
|
||||
// allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체
|
||||
let screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
const foundInAllScreens = !!screenData;
|
||||
|
||||
if (!screenData && screenId && screenName) {
|
||||
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
|
||||
console.log(` ⚠️ allScreens에서 못 찾음, 그룹 정보 사용: ${screenId} - ${screenName}`);
|
||||
screenData = {
|
||||
screenId: screenId,
|
||||
screenName: screenName,
|
||||
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
|
||||
tableName: tableName || '',
|
||||
description: '',
|
||||
companyCode: sourceGroup.company_code || '',
|
||||
} as any;
|
||||
} else if (screenData) {
|
||||
console.log(` ✅ allScreens에서 찾음: ${screenId} - ${screenData.screenName}`);
|
||||
} else {
|
||||
console.log(` ❌ 화면 정보 없음: screenId=${screenId}, screenName=${screenName}`);
|
||||
}
|
||||
return { screenId, displayOrder, screenRole, screenData };
|
||||
}).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만
|
||||
|
||||
console.log(`🔍 매핑 완료: ${screensWithOrder.length}개 화면 복사 예정`);
|
||||
screensWithOrder.forEach(item => console.log(` - ${item.screenId}: ${item.screenData?.screenName}`));
|
||||
|
||||
// display_order 순으로 정렬
|
||||
screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
|
||||
|
|
@ -843,12 +911,13 @@ export default function CopyScreenModal({
|
|||
message: `화면 복제 중: ${screen.screenName}`
|
||||
});
|
||||
|
||||
console.log(`📄 화면 복제: ${screen.screenName} → ${newScreenCode}`);
|
||||
const transformedScreenName = transformName(screen.screenName, false, sourceGroup.company_code);
|
||||
console.log(`📄 화면 복제: ${screen.screenName} → ${transformedScreenName}`);
|
||||
|
||||
const result = await screenApi.copyScreenWithModals(screen.screenId, {
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
mainScreen: {
|
||||
screenName: transformName(screen.screenName), // 일괄 이름 변경 적용
|
||||
screenName: transformedScreenName, // 일괄 이름 변경 적용
|
||||
screenCode: newScreenCode,
|
||||
description: screen.description || "",
|
||||
},
|
||||
|
|
@ -856,14 +925,18 @@ export default function CopyScreenModal({
|
|||
});
|
||||
|
||||
if (result.mainScreen?.screenId) {
|
||||
// 원본 화면 ID -> 새 화면 ID 매핑 기록
|
||||
screenIdMap[screen.screenId] = result.mainScreen.screenId;
|
||||
|
||||
await addScreenToGroup({
|
||||
group_id: newRootGroup.id,
|
||||
screen_id: result.mainScreen.screenId,
|
||||
screen_role: screenRole || "MAIN",
|
||||
display_order: displayOrder, // 원본 정렬순서 유지
|
||||
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
stats.screens++;
|
||||
console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName}`);
|
||||
console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId} → ${result.mainScreen.screenId})`);
|
||||
}
|
||||
} catch (screenError) {
|
||||
console.error(`화면 복제 실패 (${screen.screenCode}):`, screenError);
|
||||
|
|
@ -886,11 +959,180 @@ export default function CopyScreenModal({
|
|||
screenCodes,
|
||||
codeIndex,
|
||||
stats,
|
||||
totalScreenCount
|
||||
totalScreenCount,
|
||||
screenIdMap // screenIdMap 전달
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 탭 컴포넌트의 screenId 참조 일괄 업데이트
|
||||
console.log("🔍 screenIdMap 상태:", screenIdMap, "키 개수:", Object.keys(screenIdMap).length);
|
||||
if (Object.keys(screenIdMap).length > 0) {
|
||||
console.log("🔗 탭 screenId 참조 업데이트 중...", screenIdMap);
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "탭 참조 업데이트 중..." });
|
||||
|
||||
const targetScreenIds = Object.values(screenIdMap);
|
||||
try {
|
||||
const updateResult = await updateTabScreenReferences(targetScreenIds, screenIdMap);
|
||||
console.log(`✅ 탭 screenId 참조 업데이트 완료: ${updateResult.updated}개 레이아웃`);
|
||||
} catch (tabUpdateError) {
|
||||
console.warn("탭 screenId 참조 업데이트 실패 (무시):", tabUpdateError);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 채번규칙 복제 옵션이 선택된 경우 (복제 → 메뉴 동기화 → 채번규칙 복제)
|
||||
if (copyNumberingRules) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "메뉴 동기화 중..." });
|
||||
console.log("📋 메뉴 동기화 시작 (채번규칙 복제 준비)...");
|
||||
|
||||
// 7-1. 메뉴 동기화 (화면 그룹 → 메뉴)
|
||||
const syncResponse = await apiClient.post("/screen-groups/sync/screen-to-menu", {
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (syncResponse.data?.success) {
|
||||
console.log("✅ 메뉴 동기화 완료:", syncResponse.data.data);
|
||||
|
||||
// 7-2. 채번규칙 복제
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "채번규칙 복제 중..." });
|
||||
console.log("📋 채번규칙 복제 시작...");
|
||||
|
||||
const numberingResponse = await apiClient.post("/numbering-rules/copy-for-company", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (numberingResponse.data?.success) {
|
||||
console.log("✅ 채번규칙 복제 완료:", numberingResponse.data.data);
|
||||
toast.success(`채번규칙 ${numberingResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("채번규칙 복제 실패:", numberingResponse.data?.error);
|
||||
toast.warning("채번규칙 복제에 실패했습니다. 수동으로 복제해주세요.");
|
||||
}
|
||||
|
||||
// 7-3. 화면-메뉴 할당 복제 (screen_menu_assignments)
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "화면-메뉴 할당 복제 중..." });
|
||||
console.log("📋 화면-메뉴 할당 복제 시작...");
|
||||
|
||||
const menuAssignResponse = await apiClient.post("/screen-management/copy-menu-assignments", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
screenIdMap,
|
||||
});
|
||||
|
||||
if (menuAssignResponse.data?.success) {
|
||||
console.log("✅ 화면-메뉴 할당 복제 완료:", menuAssignResponse.data.data);
|
||||
toast.success(`화면-메뉴 할당 ${menuAssignResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("화면-메뉴 할당 복제 실패:", menuAssignResponse.data?.error);
|
||||
}
|
||||
} else {
|
||||
console.warn("메뉴 동기화 실패:", syncResponse.data?.error);
|
||||
toast.warning("메뉴 동기화에 실패했습니다. 채번규칙이 복제되지 않았습니다.");
|
||||
}
|
||||
} catch (numberingError) {
|
||||
console.error("채번규칙 복제 중 오류:", numberingError);
|
||||
toast.warning("채번규칙 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 코드 카테고리 + 코드 복제
|
||||
if (copyCodeCategory) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "코드 카테고리/코드 복제 중..." });
|
||||
console.log("📋 코드 카테고리/코드 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-code-category", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 코드 카테고리/코드 복제 완료:", response.data.data);
|
||||
toast.success(`코드 카테고리 ${response.data.data?.copiedCategories || 0}개, 코드 ${response.data.data?.copiedCodes || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("코드 카테고리/코드 복제 실패:", response.data?.error);
|
||||
toast.warning("코드 카테고리/코드 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 카테고리/코드 복제 중 오류:", error);
|
||||
toast.warning("코드 카테고리/코드 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 카테고리 매핑 + 값 복제
|
||||
if (copyCategoryMapping) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "카테고리 매핑/값 복제 중..." });
|
||||
console.log("📋 카테고리 매핑/값 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-category-mapping", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 카테고리 매핑/값 복제 완료:", response.data.data);
|
||||
toast.success(`카테고리 매핑 ${response.data.data?.copiedMappings || 0}개, 값 ${response.data.data?.copiedValues || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("카테고리 매핑/값 복제 실패:", response.data?.error);
|
||||
toast.warning("카테고리 매핑/값 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 매핑/값 복제 중 오류:", error);
|
||||
toast.warning("카테고리 매핑/값 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 테이블 타입관리 입력타입 설정 복제
|
||||
if (copyTableTypeColumns) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "테이블 타입 컬럼 복제 중..." });
|
||||
console.log("📋 테이블 타입 컬럼 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-table-type-columns", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 테이블 타입 컬럼 복제 완료:", response.data.data);
|
||||
toast.success(`테이블 타입 컬럼 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("테이블 타입 컬럼 복제 실패:", response.data?.error);
|
||||
toast.warning("테이블 타입 컬럼 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 타입 컬럼 복제 중 오류:", error);
|
||||
toast.warning("테이블 타입 컬럼 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 연쇄관계 설정 복제
|
||||
if (copyCascadingRelation) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "연쇄관계 설정 복제 중..." });
|
||||
console.log("📋 연쇄관계 설정 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-cascading-relation", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 연쇄관계 설정 복제 완료:", response.data.data);
|
||||
toast.success(`연쇄관계 설정 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("연쇄관계 설정 복제 실패:", response.data?.error);
|
||||
toast.warning("연쇄관계 설정 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("연쇄관계 설정 복제 중 오류:", error);
|
||||
toast.warning("연쇄관계 설정 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
||||
);
|
||||
|
|
@ -1045,6 +1287,89 @@ export default function CopyScreenModal({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 추가 복사 옵션 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm font-medium">추가 복사 옵션 (선택사항):</Label>
|
||||
|
||||
{/* 코드 카테고리 + 코드 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyCodeCategory"
|
||||
checked={copyCodeCategory}
|
||||
onCheckedChange={(checked) => setCopyCodeCategory(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyCodeCategory" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-muted-foreground" />
|
||||
코드 카테고리 + 코드 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 채번규칙 복제 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyNumberingRules"
|
||||
checked={copyNumberingRules}
|
||||
onCheckedChange={(checked) => setCopyNumberingRules(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyNumberingRules" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 text-muted-foreground" />
|
||||
채번 규칙 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 매핑 + 값 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyCategoryMapping"
|
||||
checked={copyCategoryMapping}
|
||||
onCheckedChange={(checked) => setCopyCategoryMapping(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyCategoryMapping" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Table className="h-4 w-4 text-muted-foreground" />
|
||||
카테고리 매핑 + 값 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 테이블 타입관리 입력타입 설정 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyTableTypeColumns"
|
||||
checked={copyTableTypeColumns}
|
||||
onCheckedChange={(checked) => setCopyTableTypeColumns(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyTableTypeColumns" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
테이블 타입관리 입력타입 설정 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 연쇄관계 설정 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyCascadingRelation"
|
||||
checked={copyCascadingRelation}
|
||||
onCheckedChange={(checked) => setCopyCascadingRelation(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyCascadingRelation" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
연쇄관계 설정 복사
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 복사 항목 안내 */}
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<p className="text-xs font-medium mb-1">기본 복사 항목:</p>
|
||||
<ul className="text-[10px] sm:text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||
<li>플로우 제어 (스텝, 연결)</li>
|
||||
</ul>
|
||||
<p className="text-[10px] text-muted-foreground mt-2 italic">
|
||||
* 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 새 그룹명 + 정렬 순서 */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -462,3 +462,5 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -414,3 +414,5 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -448,4 +448,17 @@ export const menuScreenApi = {
|
|||
unassignScreenFromMenu: async (screenId: number, menuObjid: number): Promise<void> => {
|
||||
await apiClient.delete(`/screen-management/screens/${screenId}/menus/${menuObjid}`);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트 (별도 export)
|
||||
export async function updateTabScreenReferences(
|
||||
targetScreenIds: number[],
|
||||
screenIdMap: { [key: number]: number }
|
||||
): Promise<{ success: boolean; updated: number; details: string[] }> {
|
||||
const response = await apiClient.post("/screen-management/screens/update-tab-references", {
|
||||
targetScreenIds,
|
||||
screenIdMap,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export async function addScreenToGroup(data: {
|
|||
screen_role?: string;
|
||||
display_order?: number;
|
||||
is_default?: string;
|
||||
target_company_code?: string; // 최고 관리자가 다른 회사로 복제할 때 사용
|
||||
}): Promise<ApiResponse<ScreenGroupScreen>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-groups/group-screens", data);
|
||||
|
|
@ -498,3 +499,166 @@ export async function getScreenSubTables(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 메뉴-화면그룹 동기화 API
|
||||
// ============================================================
|
||||
|
||||
export interface SyncDetail {
|
||||
action: 'created' | 'linked' | 'skipped' | 'error';
|
||||
sourceName: string;
|
||||
sourceId: number | string;
|
||||
targetId?: number | string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
created: number;
|
||||
linked: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
details: SyncDetail[];
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
screenGroups: { total: number; linked: number; unlinked: number };
|
||||
menuItems: { total: number; linked: number; unlinked: number };
|
||||
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
|
||||
}
|
||||
|
||||
// 동기화 상태 조회
|
||||
export async function getMenuScreenSyncStatus(
|
||||
targetCompanyCode?: string
|
||||
): Promise<ApiResponse<SyncStatus>> {
|
||||
try {
|
||||
const queryParams = targetCompanyCode ? `?targetCompanyCode=${targetCompanyCode}` : '';
|
||||
const response = await apiClient.get(`/screen-groups/sync/status${queryParams}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 화면관리 → 메뉴 동기화
|
||||
export async function syncScreenGroupsToMenu(
|
||||
targetCompanyCode?: string
|
||||
): Promise<ApiResponse<SyncResult>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-groups/sync/screen-to-menu", { targetCompanyCode });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 메뉴 → 화면관리 동기화
|
||||
export async function syncMenuToScreenGroups(
|
||||
targetCompanyCode?: string
|
||||
): Promise<ApiResponse<SyncResult>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-groups/sync/menu-to-screen", { targetCompanyCode });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 동기화 결과 타입
|
||||
export interface AllCompaniesSyncResult {
|
||||
totalCompanies: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
totalCreated: number;
|
||||
totalLinked: number;
|
||||
details: Array<{
|
||||
companyCode: string;
|
||||
companyName: string;
|
||||
direction: 'screens-to-menus' | 'menus-to-screens';
|
||||
created: number;
|
||||
linked: number;
|
||||
skipped: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 전체 회사 동기화 (최고 관리자만)
|
||||
export async function syncAllCompanies(): Promise<ApiResponse<AllCompaniesSyncResult>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-groups/sync/all");
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// [PoC] screen_groups 기반 메뉴 트리 조회
|
||||
// 화면관리 → 메뉴관리 통합 테스트용
|
||||
// ============================================================
|
||||
|
||||
export interface MenuTreeItem {
|
||||
id: number;
|
||||
objid: number | string;
|
||||
name: string;
|
||||
name_kor: string;
|
||||
icon?: string;
|
||||
url: string | null;
|
||||
screen_id: number | null;
|
||||
screen_code?: string;
|
||||
screen_count: number;
|
||||
parent_id: number | null;
|
||||
level: number;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
menu_objid: number | null;
|
||||
children: MenuTreeItem[];
|
||||
// menu_info 호환 필드
|
||||
menu_name_kor: string;
|
||||
menu_url: string | null;
|
||||
parent_obj_id: string | null;
|
||||
seq: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MenuTreeResult {
|
||||
data: MenuTreeItem[];
|
||||
stats: {
|
||||
totalGroups: number;
|
||||
groupsWithScreens: number;
|
||||
groupsWithMenuObjid: number;
|
||||
rootGroups: number;
|
||||
};
|
||||
flatList: Array<{
|
||||
objid: string;
|
||||
OBJID: string;
|
||||
menu_name_kor: string;
|
||||
MENU_NAME_KOR: string;
|
||||
menu_url: string | null;
|
||||
MENU_URL: string | null;
|
||||
parent_obj_id: string;
|
||||
PARENT_OBJ_ID: string;
|
||||
seq: number;
|
||||
SEQ: number;
|
||||
status: string;
|
||||
STATUS: string;
|
||||
menu_type: number;
|
||||
MENU_TYPE: number;
|
||||
screen_group_id: number;
|
||||
menu_objid: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
// [PoC] screen_groups 기반 메뉴 트리 조회
|
||||
export async function getMenuTreeFromScreenGroups(targetCompanyCode?: string): Promise<ApiResponse<MenuTreeResult>> {
|
||||
try {
|
||||
const params = targetCompanyCode ? { targetCompanyCode } : {};
|
||||
const response = await apiClient.get("/screen-groups/menu-tree", { params });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("[PoC] 메뉴 트리 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue