feat(pop): POP 화면 복사 기능 구현 (단일 화면 + 카테고리 일괄 복사)

최고관리자의 POP 화면을 다른 회사로 복사하는 기능 추가.
화면 단위 복사와 카테고리(그룹) 단위 일괄 복사를 모두 지원하며,
화면 간 참조(cartScreenId, sourceScreenId 등)를 자동 치환하고
카테고리 구조까지 대상 회사에 재생성한다.
[백엔드]
- analyzePopScreenLinks: POP 레이아웃 내 다른 화면 참조 스캔
- deployPopScreens: screen_definitions + screen_layouts_pop 복사,
  screenId 참조 치환, numberingRuleId 초기화, 그룹 구조 복사
- POP 그룹 조회 쿼리 개선 (screen_layouts_pop JOIN으로 실제 POP 화면만 카운트)
- ensurePopRootGroup 최고관리자 전용으로 변경
[프론트엔드]
- PopDeployModal: 단일 화면/카테고리 일괄 복사 모달 (대상 회사 선택,
  연결 화면 감지, 카테고리 트리 미리보기)
- PopCategoryTree: 그룹 컨텍스트 메뉴에 '카테고리 복사' 추가,
  하위 그룹 화면까지 재귀 수집
- PopScreenSettingModal: UI 간소화 및 화면명 저장 기능 보완
- screenApi: analyzePopScreenLinks, deployPopScreens 클라이언트 함수 추가
This commit is contained in:
SeongHyun Kim
2026-03-04 11:41:31 +09:00
parent 6c9e35e8b2
commit ce5c2426b5
10 changed files with 1539 additions and 125 deletions

View File

@@ -2574,11 +2574,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
const companyCode = req.user?.companyCode || "*";
const { searchTerm } = req.query;
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'";
let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')";
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링 (멀티테넌시)
// 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만
if (companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
@@ -2592,11 +2592,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
paramIndex++;
}
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
// POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함)
const dataQuery = `
SELECT
sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
(SELECT COUNT(*) FROM screen_group_screens sgs
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count,
(SELECT json_agg(
json_build_object(
'id', sgs.id,
@@ -2609,7 +2611,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
) 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
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
) as screens
FROM screen_groups sg
${whereClause}
@@ -2768,6 +2771,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) {
// 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공
const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]);
if (anyGroup.rows.length > 0) {
return res.status(403).json({
success: false,
message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.`
});
}
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
}
@@ -2782,7 +2793,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id]
);
if (parseInt(childCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
return res.status(400).json({
success: false,
message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.`
});
}
// 연결된 화면 확인
@@ -2791,7 +2805,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id]
);
if (parseInt(screenCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
return res.status(400).json({
success: false,
message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.`
});
}
// 삭제
@@ -2806,33 +2823,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
}
};
// POP 루트 그룹 확보 (없으면 자동 생성)
// POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포)
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
// POP 루트 그룹 확인
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = $1
`;
const existing = await pool.query(checkQuery, [companyCode]);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
// 최고관리자만 자동 생성
if (companyCode !== "*") {
const existing = await pool.query(
`SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`,
[companyCode]
);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0] });
}
return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." });
}
// 최고관리자(*): 루트 그룹 확인 후 없으면 생성
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = '*'
`;
const existing = await pool.query(checkQuery, []);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0] });
}
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
const insertQuery = `
INSERT INTO screen_groups (
group_name, group_code, hierarchy_path, company_code,
description, display_order, is_active, writer
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1)
RETURNING *
`;
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
const result = await pool.query(insertQuery, [req.user?.userId || ""]);
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id });
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
} catch (error: any) {

View File

@@ -1237,3 +1237,82 @@ export const copyCascadingRelation = async (
});
}
};
// POP 화면 연결 분석
export const analyzePopScreenLinks = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const result = await screenManagementService.analyzePopScreenLinks(
parseInt(screenId),
companyCode,
);
res.json({ success: true, data: result });
} catch (error: any) {
console.error("POP 화면 연결 분석 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 연결 분석에 실패했습니다.",
});
}
};
// POP 화면 배포 (다른 회사로 복사)
export const deployPopScreens = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screens, targetCompanyCode, groupStructure } = req.body;
const { companyCode, userId } = req.user as any;
if (!screens || !Array.isArray(screens) || screens.length === 0) {
res.status(400).json({
success: false,
message: "배포할 화면 목록이 필요합니다.",
});
return;
}
if (!targetCompanyCode) {
res.status(400).json({
success: false,
message: "대상 회사 코드가 필요합니다.",
});
return;
}
if (companyCode !== "*") {
res.status(403).json({
success: false,
message: "최고 관리자만 POP 화면을 배포할 수 있습니다.",
});
return;
}
const result = await screenManagementService.deployPopScreens({
screens,
groupStructure: groupStructure || undefined,
targetCompanyCode,
companyCode,
userId,
});
res.json({
success: true,
data: result,
message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`,
});
} catch (error: any) {
console.error("POP 화면 배포 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 배포에 실패했습니다.",
});
}
};