feat(pop): POP 화면 카테고리 관리 시스템 구현 및 저장/로드 안정화

POP 전용 카테고리 트리 UI 구현 (계층적 폴더 구조)
카테고리 CRUD API 추가 (hierarchy_path LIKE 'POP/%' 필터)
화면 이동 기능 (기존 연결 삭제 후 새 연결 추가 방식)
카테고리/화면 순서 변경 기능 (display_order 교환)
이동 UI를 서브메뉴에서 검색 가능한 모달로 개선
POP 레이아웃 버전 통일 (pop-1.0) 및 로드 로직 수정
DB 스키마 호환성 수정 (writer 컬럼, is_active VARCHAR)
This commit is contained in:
SeongHyun Kim
2026-02-02 18:01:05 +09:00
parent 8c045acab3
commit d9b7ef9ad4
13 changed files with 3104 additions and 209 deletions

View File

@@ -2424,3 +2424,281 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res
}
};
// ============================================================
// POP 전용 화면 그룹 API
// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회
// ============================================================
// POP 화면 그룹 목록 조회 (카테고리 트리용)
export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const { searchTerm } = req.query;
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);
paramIndex++;
}
// 검색어 필터링
if (searchTerm) {
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
params.push(`%${searchTerm}%`);
paramIndex++;
}
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
const dataQuery = `
SELECT
sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
(SELECT json_agg(
json_build_object(
'id', sgs.id,
'screen_id', sgs.screen_id,
'screen_name', sd.screen_name,
'screen_role', sgs.screen_role,
'display_order', sgs.display_order,
'is_default', sgs.is_default,
'table_name', sd.table_name
) ORDER BY sgs.display_order
) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id
) as screens
FROM screen_groups sg
${whereClause}
ORDER BY sg.display_order ASC, sg.hierarchy_path ASC
`;
const result = await pool.query(dataQuery, params);
logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length });
res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("POP 화면 그룹 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message });
}
};
// POP 화면 그룹 생성 (hierarchy_path 자동 설정)
export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "";
const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body;
if (!group_name || !group_code) {
return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." });
}
// 회사 코드 결정
const effectiveCompanyCode = target_company_code || userCompanyCode;
if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) {
return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." });
}
// hierarchy_path 계산 - POP 하위로 설정
let hierarchyPath = "POP";
if (parent_group_id) {
// 부모 그룹의 hierarchy_path 조회
const parentResult = await pool.query(
`SELECT hierarchy_path FROM screen_groups WHERE id = $1`,
[parent_group_id]
);
if (parentResult.rows.length > 0) {
hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`;
}
} else {
// 최상위 POP 카테고리
hierarchyPath = `POP/${group_code}`;
}
// 중복 체크
const duplicateCheck = await pool.query(
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
[group_code, effectiveCompanyCode]
);
if (duplicateCheck.rows.length > 0) {
return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." });
}
// 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
const insertQuery = `
INSERT INTO screen_groups (
group_name, group_code, description, icon, display_order,
parent_group_id, hierarchy_path, company_code, writer, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y')
RETURNING *
`;
const insertParams = [
group_name,
group_code,
description || null,
icon || null,
display_order || 0,
parent_group_id || null,
hierarchyPath,
effectiveCompanyCode,
userId,
];
const result = await pool.query(insertQuery, insertParams);
logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode });
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." });
} catch (error: any) {
logger.error("POP 화면 그룹 생성 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message });
}
};
// POP 화면 그룹 수정
export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
const { group_name, description, icon, display_order, is_active } = req.body;
// 기존 그룹 확인
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
const checkParams: any[] = [id];
if (companyCode !== "*") {
checkQuery += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) {
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
}
// POP 그룹인지 확인
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." });
}
// 업데이트
const updateQuery = `
UPDATE screen_groups
SET group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
icon = COALESCE($3, icon),
display_order = COALESCE($4, display_order),
is_active = COALESCE($5, is_active),
updated_date = NOW()
WHERE id = $6
RETURNING *
`;
const updateParams = [group_name, description, icon, display_order, is_active, id];
const result = await pool.query(updateQuery, updateParams);
logger.info("POP 화면 그룹 수정", { groupId: id, companyCode });
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." });
} catch (error: any) {
logger.error("POP 화면 그룹 수정 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message });
}
};
// POP 화면 그룹 삭제
export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
// 기존 그룹 확인
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
const checkParams: any[] = [id];
if (companyCode !== "*") {
checkQuery += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) {
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
}
// POP 그룹인지 확인
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." });
}
// 하위 그룹 확인
const childCheck = await pool.query(
`SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`,
[id]
);
if (parseInt(childCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
}
// 연결된 화면 확인
const screenCheck = await pool.query(
`SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`,
[id]
);
if (parseInt(screenCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
}
// 삭제
await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]);
logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode });
res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." });
} catch (error: any) {
logger.error("POP 화면 그룹 삭제 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message });
}
};
// 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 루트 그룹이 이미 존재합니다." });
}
// 없으면 생성 (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)
RETURNING *
`;
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
} catch (error: any) {
logger.error("POP 루트 그룹 확보 실패:", error);
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
}
};

View File

@@ -36,6 +36,12 @@ import {
syncMenuToScreenGroupsController,
getSyncStatusController,
syncAllCompaniesController,
// POP 전용 화면 그룹
getPopScreenGroups,
createPopScreenGroup,
updatePopScreenGroup,
deletePopScreenGroup,
ensurePopRootGroup,
} from "../controllers/screenGroupController";
const router = Router();
@@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
// 전체 회사 동기화 (최고 관리자만)
router.post("/sync/all", syncAllCompaniesController);
// ============================================================
// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%')
// ============================================================
router.get("/pop/groups", getPopScreenGroups);
router.post("/pop/groups", createPopScreenGroup);
router.put("/pop/groups/:id", updatePopScreenGroup);
router.delete("/pop/groups/:id", deletePopScreenGroup);
router.post("/pop/ensure-root", ensurePopRootGroup);
export default router;

View File

@@ -4829,9 +4829,9 @@ export class ScreenManagementService {
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가
// 버전 정보 추가 (프론트엔드 pop-1.0과 통일)
const dataToSave = {
version: "2.0",
version: "pop-1.0",
...layoutData
};