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:
parent
6c9e35e8b2
commit
ce5c2426b5
|
|
@ -2574,11 +2574,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const { searchTerm } = req.query;
|
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[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 회사 코드 필터링 (멀티테넌시)
|
// 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereClause += ` AND company_code = $${paramIndex}`;
|
whereClause += ` AND company_code = $${paramIndex}`;
|
||||||
params.push(companyCode);
|
params.push(companyCode);
|
||||||
|
|
@ -2592,11 +2592,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
|
// POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함)
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
sg.*,
|
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(
|
(SELECT json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'id', sgs.id,
|
'id', sgs.id,
|
||||||
|
|
@ -2609,7 +2611,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
|
||||||
) ORDER BY sgs.display_order
|
) ORDER BY sgs.display_order
|
||||||
) FROM screen_group_screens sgs
|
) FROM screen_group_screens sgs
|
||||||
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
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
|
) as screens
|
||||||
FROM screen_groups sg
|
FROM screen_groups sg
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -2768,6 +2771,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
|
||||||
|
|
||||||
const existing = await pool.query(checkQuery, checkParams);
|
const existing = await pool.query(checkQuery, checkParams);
|
||||||
if (existing.rows.length === 0) {
|
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: "그룹을 찾을 수 없습니다." });
|
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2782,7 +2793,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
if (parseInt(childCheck.rows[0].count) > 0) {
|
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]
|
[id]
|
||||||
);
|
);
|
||||||
if (parseInt(screenCheck.rows[0].count) > 0) {
|
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) => {
|
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
// POP 루트 그룹 확인
|
// 최고관리자만 자동 생성
|
||||||
const checkQuery = `
|
if (companyCode !== "*") {
|
||||||
SELECT * FROM screen_groups
|
const existing = await pool.query(
|
||||||
WHERE hierarchy_path = 'POP' AND company_code = $1
|
`SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`,
|
||||||
`;
|
[companyCode]
|
||||||
const existing = await pool.query(checkQuery, [companyCode]);
|
);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
if (existing.rows.length > 0) {
|
return res.json({ success: true, data: existing.rows[0] });
|
||||||
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
|
}
|
||||||
|
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 = `
|
const insertQuery = `
|
||||||
INSERT INTO screen_groups (
|
INSERT INTO screen_groups (
|
||||||
group_name, group_code, hierarchy_path, company_code,
|
group_name, group_code, hierarchy_path, company_code,
|
||||||
description, display_order, is_active, writer
|
description, display_order, is_active, writer
|
||||||
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
|
) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1)
|
||||||
RETURNING *
|
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 루트 그룹이 생성되었습니다." });
|
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -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 화면 배포에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import {
|
||||||
copyCategoryMapping,
|
copyCategoryMapping,
|
||||||
copyTableTypeColumns,
|
copyTableTypeColumns,
|
||||||
copyCascadingRelation,
|
copyCascadingRelation,
|
||||||
|
analyzePopScreenLinks,
|
||||||
|
deployPopScreens,
|
||||||
} from "../controllers/screenManagementController";
|
} from "../controllers/screenManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -123,4 +125,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns);
|
||||||
// 연쇄관계 설정 복제
|
// 연쇄관계 설정 복제
|
||||||
router.post("/copy-cascading-relation", copyCascadingRelation);
|
router.post("/copy-cascading-relation", copyCascadingRelation);
|
||||||
|
|
||||||
|
// POP 화면 배포 (다른 회사로 복사)
|
||||||
|
router.get("/screens/:screenId/pop-links", analyzePopScreenLinks);
|
||||||
|
router.post("/deploy-pop-screens", deployPopScreens);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -5425,28 +5425,24 @@ export class ScreenManagementService {
|
||||||
async getScreenIdsWithPopLayout(
|
async getScreenIdsWithPopLayout(
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
|
|
||||||
console.log(`회사 코드: ${companyCode}`);
|
|
||||||
|
|
||||||
let result: { screen_id: number }[];
|
let result: { screen_id: number }[];
|
||||||
|
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
// 최고 관리자: 모든 POP 레이아웃 조회
|
|
||||||
result = await query<{ screen_id: number }>(
|
result = await query<{ screen_id: number }>(
|
||||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
|
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
|
// 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용)
|
||||||
result = await query<{ screen_id: number }>(
|
result = await query<{ screen_id: number }>(
|
||||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop
|
`SELECT DISTINCT screen_id FROM screen_layouts_pop
|
||||||
WHERE company_code = $1 OR company_code = '*'`,
|
WHERE company_code = $1`,
|
||||||
[companyCode],
|
[companyCode],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenIds = result.map((r) => r.screen_id);
|
const screenIds = result.map((r) => r.screen_id);
|
||||||
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`);
|
logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length });
|
||||||
return screenIds;
|
return screenIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5484,6 +5480,512 @@ export class ScreenManagementService {
|
||||||
console.log(`POP 레이아웃 삭제 완료`);
|
console.log(`POP 레이아웃 삭제 완료`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POP 화면 배포 (다른 회사로 복사)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP layout_data 내 다른 화면 참조를 스캔하여 연결 관계 분석
|
||||||
|
*/
|
||||||
|
async analyzePopScreenLinks(
|
||||||
|
screenId: number,
|
||||||
|
companyCode: string,
|
||||||
|
): Promise<{
|
||||||
|
linkedScreenIds: number[];
|
||||||
|
references: Array<{
|
||||||
|
componentId: string;
|
||||||
|
referenceType: string;
|
||||||
|
targetScreenId: number;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const layoutResult = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[screenId, companyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!layoutResult?.layout_data) {
|
||||||
|
return { linkedScreenIds: [], references: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutData = layoutResult.layout_data;
|
||||||
|
const references: Array<{
|
||||||
|
componentId: string;
|
||||||
|
referenceType: string;
|
||||||
|
targetScreenId: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const scanComponents = (components: Record<string, any>) => {
|
||||||
|
for (const [compId, comp] of Object.entries(components)) {
|
||||||
|
const config = (comp as any).config || {};
|
||||||
|
|
||||||
|
if (config.cart?.cartScreenId) {
|
||||||
|
const sid = parseInt(config.cart.cartScreenId);
|
||||||
|
if (!isNaN(sid) && sid !== screenId) {
|
||||||
|
references.push({
|
||||||
|
componentId: compId,
|
||||||
|
referenceType: "cartScreenId",
|
||||||
|
targetScreenId: sid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.cartListMode?.sourceScreenId) {
|
||||||
|
const sid =
|
||||||
|
typeof config.cartListMode.sourceScreenId === "number"
|
||||||
|
? config.cartListMode.sourceScreenId
|
||||||
|
: parseInt(config.cartListMode.sourceScreenId);
|
||||||
|
if (!isNaN(sid) && sid !== screenId) {
|
||||||
|
references.push({
|
||||||
|
componentId: compId,
|
||||||
|
referenceType: "sourceScreenId",
|
||||||
|
targetScreenId: sid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(config.followUpActions)) {
|
||||||
|
for (const action of config.followUpActions) {
|
||||||
|
if (action.targetScreenId) {
|
||||||
|
const sid = parseInt(action.targetScreenId);
|
||||||
|
if (!isNaN(sid) && sid !== screenId) {
|
||||||
|
references.push({
|
||||||
|
componentId: compId,
|
||||||
|
referenceType: "targetScreenId",
|
||||||
|
targetScreenId: sid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.action?.modalScreenId) {
|
||||||
|
const sid = parseInt(config.action.modalScreenId);
|
||||||
|
if (!isNaN(sid) && sid !== screenId) {
|
||||||
|
references.push({
|
||||||
|
componentId: compId,
|
||||||
|
referenceType: "modalScreenId",
|
||||||
|
targetScreenId: sid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (layoutData.components) {
|
||||||
|
scanComponents(layoutData.components);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(layoutData.modals)) {
|
||||||
|
for (const modal of layoutData.modals) {
|
||||||
|
if (modal.components) {
|
||||||
|
scanComponents(modal.components);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedScreenIds = [
|
||||||
|
...new Set(references.map((r) => r.targetScreenId)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return { linkedScreenIds, references };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 화면 배포 (최고관리자 화면을 특정 회사로 복사)
|
||||||
|
* - screen_definitions + screen_layouts_pop 복사
|
||||||
|
* - 화면 간 참조(cartScreenId, sourceScreenId 등) 자동 치환
|
||||||
|
* - numberingRuleId 초기화
|
||||||
|
*/
|
||||||
|
async deployPopScreens(data: {
|
||||||
|
screens: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}>;
|
||||||
|
groupStructure?: {
|
||||||
|
sourceGroupId: number;
|
||||||
|
groupName: string;
|
||||||
|
groupCode: string;
|
||||||
|
children?: Array<{
|
||||||
|
sourceGroupId: number;
|
||||||
|
groupName: string;
|
||||||
|
groupCode: string;
|
||||||
|
screenIds: number[];
|
||||||
|
}>;
|
||||||
|
screenIds: number[];
|
||||||
|
};
|
||||||
|
targetCompanyCode: string;
|
||||||
|
companyCode: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<{
|
||||||
|
deployedScreens: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
newScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}>;
|
||||||
|
createdGroups?: number;
|
||||||
|
}> {
|
||||||
|
if (data.companyCode !== "*") {
|
||||||
|
throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
const screenIdMap = new Map<number, number>();
|
||||||
|
const deployedScreens: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
newScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 1단계: screen_definitions 복사
|
||||||
|
for (const screen of data.screens) {
|
||||||
|
const sourceResult = await client.query<any>(
|
||||||
|
`SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||||
|
[screen.sourceScreenId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceResult.rows.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceScreen = sourceResult.rows[0];
|
||||||
|
|
||||||
|
const existingResult = await client.query<any>(
|
||||||
|
`SELECT screen_id FROM screen_definitions
|
||||||
|
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[screen.screenCode, data.targetCompanyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.rows.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newScreenResult = await client.query<any>(
|
||||||
|
`INSERT INTO screen_definitions (
|
||||||
|
screen_code, screen_name, description, company_code, table_name,
|
||||||
|
is_active, created_by, created_date, updated_by, updated_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
screen.screenCode,
|
||||||
|
screen.screenName,
|
||||||
|
sourceScreen.description,
|
||||||
|
data.targetCompanyCode,
|
||||||
|
sourceScreen.table_name,
|
||||||
|
"Y",
|
||||||
|
data.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newScreen = newScreenResult.rows[0];
|
||||||
|
screenIdMap.set(screen.sourceScreenId, newScreen.screen_id);
|
||||||
|
|
||||||
|
deployedScreens.push({
|
||||||
|
sourceScreenId: screen.sourceScreenId,
|
||||||
|
newScreenId: newScreen.screen_id,
|
||||||
|
screenName: screen.screenName,
|
||||||
|
screenCode: screen.screenCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("POP 화면 배포 - screen_definitions 생성", {
|
||||||
|
sourceScreenId: screen.sourceScreenId,
|
||||||
|
newScreenId: newScreen.screen_id,
|
||||||
|
targetCompanyCode: data.targetCompanyCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: screen_layouts_pop 복사 + 참조 치환
|
||||||
|
for (const screen of data.screens) {
|
||||||
|
const newScreenId = screenIdMap.get(screen.sourceScreenId);
|
||||||
|
if (!newScreenId) continue;
|
||||||
|
|
||||||
|
// 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback)
|
||||||
|
let layoutResult = await client.query<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = $1 AND company_code = '*'`,
|
||||||
|
[screen.sourceScreenId],
|
||||||
|
);
|
||||||
|
|
||||||
|
let layoutData = layoutResult.rows[0]?.layout_data;
|
||||||
|
if (!layoutData) {
|
||||||
|
const fallbackResult = await client.query<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = $1 LIMIT 1`,
|
||||||
|
[screen.sourceScreenId],
|
||||||
|
);
|
||||||
|
layoutData = fallbackResult.rows[0]?.layout_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layoutData) {
|
||||||
|
logger.warn("POP 레이아웃 없음, 건너뜀", {
|
||||||
|
sourceScreenId: screen.sourceScreenId,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLayoutData = this.updatePopLayoutScreenReferences(
|
||||||
|
JSON.parse(JSON.stringify(layoutData)),
|
||||||
|
screenIdMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||||
|
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
|
||||||
|
ON CONFLICT (screen_id, company_code)
|
||||||
|
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
|
||||||
|
[
|
||||||
|
newScreenId,
|
||||||
|
data.targetCompanyCode,
|
||||||
|
JSON.stringify(updatedLayoutData),
|
||||||
|
data.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("POP 레이아웃 복사 완료", {
|
||||||
|
sourceScreenId: screen.sourceScreenId,
|
||||||
|
newScreenId,
|
||||||
|
componentCount: Object.keys(updatedLayoutData.components || {})
|
||||||
|
.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3단계: 그룹 구조 복사 (groupStructure가 있는 경우)
|
||||||
|
let createdGroups = 0;
|
||||||
|
if (data.groupStructure) {
|
||||||
|
const gs = data.groupStructure;
|
||||||
|
|
||||||
|
// 대상 회사의 POP 루트 그룹 찾기/생성
|
||||||
|
let popRootResult = await client.query<any>(
|
||||||
|
`SELECT id FROM screen_groups
|
||||||
|
WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`,
|
||||||
|
[data.targetCompanyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
let popRootId: number;
|
||||||
|
if (popRootResult.rows.length > 0) {
|
||||||
|
popRootId = popRootResult.rows[0].id;
|
||||||
|
} else {
|
||||||
|
const createRootResult = await client.query<any>(
|
||||||
|
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order)
|
||||||
|
VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`,
|
||||||
|
[data.targetCompanyCode, data.userId],
|
||||||
|
);
|
||||||
|
popRootId = createRootResult.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가)
|
||||||
|
const mainGroupCode = gs.groupCode + "_COPY";
|
||||||
|
const dupCheck = await client.query<any>(
|
||||||
|
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||||
|
[mainGroupCode, data.targetCompanyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mainGroupId: number;
|
||||||
|
if (dupCheck.rows.length > 0) {
|
||||||
|
mainGroupId = dupCheck.rows[0].id;
|
||||||
|
} else {
|
||||||
|
const mainGroupResult = await client.query<any>(
|
||||||
|
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`,
|
||||||
|
[
|
||||||
|
gs.groupName,
|
||||||
|
mainGroupCode,
|
||||||
|
`POP/${mainGroupCode}`,
|
||||||
|
data.targetCompanyCode,
|
||||||
|
popRootId,
|
||||||
|
data.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
mainGroupId = mainGroupResult.rows[0].id;
|
||||||
|
createdGroups++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 그룹에 화면 연결
|
||||||
|
for (const oldScreenId of gs.screenIds) {
|
||||||
|
const newScreenId = screenIdMap.get(oldScreenId);
|
||||||
|
if (!newScreenId) continue;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
|
||||||
|
VALUES ($1, $2, 'main', 0, 'N', $3)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[mainGroupId, newScreenId, data.targetCompanyCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하위 그룹 생성 + 화면 연결
|
||||||
|
if (gs.children) {
|
||||||
|
for (let i = 0; i < gs.children.length; i++) {
|
||||||
|
const child = gs.children[i];
|
||||||
|
const childGroupCode = child.groupCode + "_COPY";
|
||||||
|
|
||||||
|
const childDupCheck = await client.query<any>(
|
||||||
|
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||||
|
[childGroupCode, data.targetCompanyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
let childGroupId: number;
|
||||||
|
if (childDupCheck.rows.length > 0) {
|
||||||
|
childGroupId = childDupCheck.rows[0].id;
|
||||||
|
} else {
|
||||||
|
const childResult = await client.query<any>(
|
||||||
|
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`,
|
||||||
|
[
|
||||||
|
child.groupName,
|
||||||
|
childGroupCode,
|
||||||
|
`POP/${mainGroupCode}/${childGroupCode}`,
|
||||||
|
data.targetCompanyCode,
|
||||||
|
mainGroupId,
|
||||||
|
data.userId,
|
||||||
|
i,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
childGroupId = childResult.rows[0].id;
|
||||||
|
createdGroups++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const oldScreenId of child.screenIds) {
|
||||||
|
const newScreenId = screenIdMap.get(oldScreenId);
|
||||||
|
if (!newScreenId) continue;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
|
||||||
|
VALUES ($1, $2, 'main', 0, 'N', $3)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[childGroupId, newScreenId, data.targetCompanyCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("POP 그룹 구조 복사 완료", {
|
||||||
|
targetCompanyCode: data.targetCompanyCode,
|
||||||
|
createdGroups,
|
||||||
|
mainGroupName: gs.groupName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deployedScreens, createdGroups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP layout_data 내 screen_id 참조 치환
|
||||||
|
* componentId, connectionId는 레이아웃 내부 식별자이므로 변경 불필요
|
||||||
|
*/
|
||||||
|
private updatePopLayoutScreenReferences(
|
||||||
|
layoutData: any,
|
||||||
|
screenIdMap: Map<number, number>,
|
||||||
|
): any {
|
||||||
|
if (!layoutData?.components) return layoutData;
|
||||||
|
|
||||||
|
const updateComponents = (
|
||||||
|
components: Record<string, any>,
|
||||||
|
): Record<string, any> => {
|
||||||
|
const updated: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [compId, comp] of Object.entries(components)) {
|
||||||
|
const updatedComp = JSON.parse(JSON.stringify(comp));
|
||||||
|
const config = updatedComp.config || {};
|
||||||
|
|
||||||
|
// cart.cartScreenId (string)
|
||||||
|
if (config.cart?.cartScreenId) {
|
||||||
|
const oldId = parseInt(config.cart.cartScreenId);
|
||||||
|
const newId = screenIdMap.get(oldId);
|
||||||
|
if (newId) {
|
||||||
|
config.cart.cartScreenId = String(newId);
|
||||||
|
logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cartListMode.sourceScreenId (number)
|
||||||
|
if (config.cartListMode?.sourceScreenId) {
|
||||||
|
const oldId =
|
||||||
|
typeof config.cartListMode.sourceScreenId === "number"
|
||||||
|
? config.cartListMode.sourceScreenId
|
||||||
|
: parseInt(config.cartListMode.sourceScreenId);
|
||||||
|
const newId = screenIdMap.get(oldId);
|
||||||
|
if (newId) {
|
||||||
|
config.cartListMode.sourceScreenId = newId;
|
||||||
|
logger.info(
|
||||||
|
`POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// followUpActions[].targetScreenId (string)
|
||||||
|
if (Array.isArray(config.followUpActions)) {
|
||||||
|
for (const action of config.followUpActions) {
|
||||||
|
if (action.targetScreenId) {
|
||||||
|
const oldId = parseInt(action.targetScreenId);
|
||||||
|
const newId = screenIdMap.get(oldId);
|
||||||
|
if (newId) {
|
||||||
|
action.targetScreenId = String(newId);
|
||||||
|
logger.info(
|
||||||
|
`POP 참조 치환: targetScreenId ${oldId} -> ${newId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// action.modalScreenId (숫자형이면 화면 참조로 간주)
|
||||||
|
if (config.action?.modalScreenId) {
|
||||||
|
const oldId = parseInt(config.action.modalScreenId);
|
||||||
|
if (!isNaN(oldId)) {
|
||||||
|
const newId = screenIdMap.get(oldId);
|
||||||
|
if (newId) {
|
||||||
|
config.action.modalScreenId = String(newId);
|
||||||
|
logger.info(
|
||||||
|
`POP 참조 치환: modalScreenId ${oldId} -> ${newId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요)
|
||||||
|
if (config.numberingRuleId) {
|
||||||
|
logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`);
|
||||||
|
config.numberingRuleId = "";
|
||||||
|
}
|
||||||
|
if (config.autoGenMappings) {
|
||||||
|
for (const mapping of Object.values(config.autoGenMappings) as any[]) {
|
||||||
|
if (mapping?.numberingRuleId) {
|
||||||
|
logger.info(
|
||||||
|
`POP 채번규칙 초기화: ${mapping.numberingRuleId}`,
|
||||||
|
);
|
||||||
|
mapping.numberingRuleId = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedComp.config = config;
|
||||||
|
updated[compId] = updatedComp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
layoutData.components = updateComponents(layoutData.components);
|
||||||
|
|
||||||
|
if (Array.isArray(layoutData.modals)) {
|
||||||
|
for (const modal of layoutData.modals) {
|
||||||
|
if (modal.components) {
|
||||||
|
modal.components = updateComponents(modal.components);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layoutData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스 인스턴스 export
|
// 서비스 인스턴스 export
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PopDesigner } from "@/components/pop/designer";
|
import { PopDesigner } from "@/components/pop/designer";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
PopScreenPreview,
|
PopScreenPreview,
|
||||||
PopScreenFlowView,
|
PopScreenFlowView,
|
||||||
PopScreenSettingModal,
|
PopScreenSettingModal,
|
||||||
|
PopDeployModal,
|
||||||
} from "@/components/pop/management";
|
} from "@/components/pop/management";
|
||||||
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
||||||
|
|
||||||
|
|
@ -62,6 +64,10 @@ export default function PopScreenManagementPage() {
|
||||||
// UI 상태
|
// UI 상태
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||||
|
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
|
||||||
|
const [deployGroupScreens, setDeployGroupScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [deployGroupName, setDeployGroupName] = useState("");
|
||||||
|
const [deployGroupInfo, setDeployGroupInfo] = useState<any>(undefined);
|
||||||
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
||||||
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
||||||
|
|
||||||
|
|
@ -235,6 +241,21 @@ export default function PopScreenManagementPage() {
|
||||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{selectedScreen && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeployGroupScreens([]);
|
||||||
|
setDeployGroupName("");
|
||||||
|
setDeployGroupInfo(undefined);
|
||||||
|
setIsDeployModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
새 POP 화면
|
새 POP 화면
|
||||||
|
|
@ -290,6 +311,24 @@ export default function PopScreenManagementPage() {
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
onScreenSelect={handleScreenSelect}
|
onScreenSelect={handleScreenSelect}
|
||||||
onScreenDesign={handleDesignScreen}
|
onScreenDesign={handleDesignScreen}
|
||||||
|
onScreenSettings={(screen) => {
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
setIsSettingModalOpen(true);
|
||||||
|
}}
|
||||||
|
onScreenCopy={(screen) => {
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
setDeployGroupScreens([]);
|
||||||
|
setDeployGroupName("");
|
||||||
|
setDeployGroupInfo(undefined);
|
||||||
|
setIsDeployModalOpen(true);
|
||||||
|
}}
|
||||||
|
onGroupCopy={(groupScreensList, groupName, gInfo) => {
|
||||||
|
setSelectedScreen(null);
|
||||||
|
setDeployGroupScreens(groupScreensList);
|
||||||
|
setDeployGroupName(groupName);
|
||||||
|
setDeployGroupInfo(gInfo);
|
||||||
|
setIsDeployModalOpen(true);
|
||||||
|
}}
|
||||||
onGroupSelect={handleGroupSelect}
|
onGroupSelect={handleGroupSelect}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
/>
|
/>
|
||||||
|
|
@ -383,6 +422,18 @@ export default function PopScreenManagementPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* POP 화면 배포 모달 */}
|
||||||
|
<PopDeployModal
|
||||||
|
open={isDeployModalOpen}
|
||||||
|
onOpenChange={setIsDeployModalOpen}
|
||||||
|
screen={selectedScreen}
|
||||||
|
groupScreens={deployGroupScreens.length > 0 ? deployGroupScreens : undefined}
|
||||||
|
groupName={deployGroupName || undefined}
|
||||||
|
groupInfo={deployGroupInfo}
|
||||||
|
allScreens={screens}
|
||||||
|
onDeployed={loadScreens}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
{/* Scroll to Top 버튼 */}
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
|
Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -68,11 +70,27 @@ import {
|
||||||
// 타입 정의
|
// 타입 정의
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
export interface GroupCopyInfo {
|
||||||
|
sourceGroupId: number;
|
||||||
|
groupName: string;
|
||||||
|
groupCode: string;
|
||||||
|
screenIds: number[];
|
||||||
|
children: Array<{
|
||||||
|
sourceGroupId: number;
|
||||||
|
groupName: string;
|
||||||
|
groupCode: string;
|
||||||
|
screenIds: number[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
interface PopCategoryTreeProps {
|
interface PopCategoryTreeProps {
|
||||||
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
|
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
|
||||||
selectedScreen: ScreenDefinition | null;
|
selectedScreen: ScreenDefinition | null;
|
||||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||||
|
onScreenSettings?: (screen: ScreenDefinition) => void;
|
||||||
|
onScreenCopy?: (screen: ScreenDefinition) => void;
|
||||||
|
onGroupCopy?: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||||
onGroupSelect?: (group: PopScreenGroup | null) => void;
|
onGroupSelect?: (group: PopScreenGroup | null) => void;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +105,8 @@ interface TreeNodeProps {
|
||||||
onGroupSelect: (group: PopScreenGroup) => void;
|
onGroupSelect: (group: PopScreenGroup) => void;
|
||||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||||
|
onScreenSettings: (screen: ScreenDefinition) => void;
|
||||||
|
onScreenCopy: (screen: ScreenDefinition) => void;
|
||||||
onEditGroup: (group: PopScreenGroup) => void;
|
onEditGroup: (group: PopScreenGroup) => void;
|
||||||
onDeleteGroup: (group: PopScreenGroup) => void;
|
onDeleteGroup: (group: PopScreenGroup) => void;
|
||||||
onAddSubGroup: (parentGroup: PopScreenGroup) => void;
|
onAddSubGroup: (parentGroup: PopScreenGroup) => void;
|
||||||
|
|
@ -101,6 +121,7 @@ interface TreeNodeProps {
|
||||||
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
|
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
|
||||||
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
|
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
|
||||||
onDeleteScreen: (screen: ScreenDefinition) => void;
|
onDeleteScreen: (screen: ScreenDefinition) => void;
|
||||||
|
onGroupCopy: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -118,6 +139,7 @@ function TreeNode({
|
||||||
onMoveScreenUp,
|
onMoveScreenUp,
|
||||||
onMoveScreenDown,
|
onMoveScreenDown,
|
||||||
onDeleteScreen,
|
onDeleteScreen,
|
||||||
|
onGroupCopy,
|
||||||
expandedGroups,
|
expandedGroups,
|
||||||
onToggle,
|
onToggle,
|
||||||
selectedGroupId,
|
selectedGroupId,
|
||||||
|
|
@ -125,6 +147,8 @@ function TreeNode({
|
||||||
onGroupSelect,
|
onGroupSelect,
|
||||||
onScreenSelect,
|
onScreenSelect,
|
||||||
onScreenDesign,
|
onScreenDesign,
|
||||||
|
onScreenSettings,
|
||||||
|
onScreenCopy,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onAddSubGroup,
|
onAddSubGroup,
|
||||||
|
|
@ -134,7 +158,7 @@ function TreeNode({
|
||||||
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
|
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
|
||||||
const isSelected = selectedGroupId === group.id;
|
const isSelected = selectedGroupId === group.id;
|
||||||
|
|
||||||
// 그룹에 연결된 화면 목록
|
// 그룹에 직접 연결된 화면 목록
|
||||||
const groupScreens = useMemo(() => {
|
const groupScreens = useMemo(() => {
|
||||||
if (!group.screens) return [];
|
if (!group.screens) return [];
|
||||||
return group.screens
|
return group.screens
|
||||||
|
|
@ -142,6 +166,20 @@ function TreeNode({
|
||||||
.filter((s): s is ScreenDefinition => s !== undefined);
|
.filter((s): s is ScreenDefinition => s !== undefined);
|
||||||
}, [group.screens, screensMap]);
|
}, [group.screens, screensMap]);
|
||||||
|
|
||||||
|
// 하위 그룹 포함 전체 화면 (복사용)
|
||||||
|
const allDescendantScreens = useMemo(() => {
|
||||||
|
const collected = new Map<number, ScreenDefinition>();
|
||||||
|
const collectRecursive = (g: PopScreenGroup) => {
|
||||||
|
g.screens?.forEach((gs) => {
|
||||||
|
const screen = screensMap.get(gs.screen_id);
|
||||||
|
if (screen) collected.set(screen.screenId, screen);
|
||||||
|
});
|
||||||
|
g.children?.forEach(collectRecursive);
|
||||||
|
};
|
||||||
|
collectRecursive(group);
|
||||||
|
return Array.from(collected.values());
|
||||||
|
}, [group, screensMap]);
|
||||||
|
|
||||||
// 루트 레벨(POP 화면)인지 확인
|
// 루트 레벨(POP 화면)인지 확인
|
||||||
const isRootLevel = level === 0;
|
const isRootLevel = level === 0;
|
||||||
|
|
||||||
|
|
@ -193,8 +231,15 @@ function TreeNode({
|
||||||
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
|
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 그룹명 - 루트는 볼드체 */}
|
{/* 그룹명 - 루트는 볼드체 + 회사코드 표시 */}
|
||||||
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span>
|
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>
|
||||||
|
{group.group_name}
|
||||||
|
{isRootLevel && group.company_code && (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground font-normal">
|
||||||
|
{group.company_code === "*" ? "(전체)" : `(${group.company_code})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* 화면 수 배지 */}
|
{/* 화면 수 배지 */}
|
||||||
{group.screen_count && group.screen_count > 0 && (
|
{group.screen_count && group.screen_count > 0 && (
|
||||||
|
|
@ -224,6 +269,34 @@ function TreeNode({
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
수정
|
수정
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{allDescendantScreens.length > 0 && (
|
||||||
|
<DropdownMenuItem onClick={() => {
|
||||||
|
const buildGroupInfo = (g: PopScreenGroup): GroupCopyInfo => {
|
||||||
|
const directScreenIds = (g.screens || [])
|
||||||
|
.map((gs) => gs.screen_id)
|
||||||
|
.filter((id) => screensMap.has(id));
|
||||||
|
const children = (g.children || []).map((child) => ({
|
||||||
|
sourceGroupId: child.id,
|
||||||
|
groupName: child.group_name,
|
||||||
|
groupCode: child.group_code,
|
||||||
|
screenIds: (child.screens || [])
|
||||||
|
.map((gs) => gs.screen_id)
|
||||||
|
.filter((id) => screensMap.has(id)),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
sourceGroupId: g.id,
|
||||||
|
groupName: g.group_name,
|
||||||
|
groupCode: g.group_code,
|
||||||
|
screenIds: directScreenIds,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
onGroupCopy(allDescendantScreens, group.group_name, buildGroupInfo(group));
|
||||||
|
}}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
카테고리 복사 ({allDescendantScreens.length}개 화면)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onMoveGroupUp(group)}
|
onClick={() => onMoveGroupUp(group)}
|
||||||
|
|
@ -267,6 +340,8 @@ function TreeNode({
|
||||||
onGroupSelect={onGroupSelect}
|
onGroupSelect={onGroupSelect}
|
||||||
onScreenSelect={onScreenSelect}
|
onScreenSelect={onScreenSelect}
|
||||||
onScreenDesign={onScreenDesign}
|
onScreenDesign={onScreenDesign}
|
||||||
|
onScreenSettings={onScreenSettings}
|
||||||
|
onScreenCopy={onScreenCopy}
|
||||||
onEditGroup={onEditGroup}
|
onEditGroup={onEditGroup}
|
||||||
onDeleteGroup={onDeleteGroup}
|
onDeleteGroup={onDeleteGroup}
|
||||||
onAddSubGroup={onAddSubGroup}
|
onAddSubGroup={onAddSubGroup}
|
||||||
|
|
@ -279,6 +354,7 @@ function TreeNode({
|
||||||
onMoveScreenUp={onMoveScreenUp}
|
onMoveScreenUp={onMoveScreenUp}
|
||||||
onMoveScreenDown={onMoveScreenDown}
|
onMoveScreenDown={onMoveScreenDown}
|
||||||
onDeleteScreen={onDeleteScreen}
|
onDeleteScreen={onDeleteScreen}
|
||||||
|
onGroupCopy={onGroupCopy}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -324,6 +400,14 @@ function TreeNode({
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
설계
|
설계
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onScreenSettings(screen)}>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
설정 (이름 변경)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onScreenCopy(screen)}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
복사
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onMoveScreenUp(screen, group.id)}
|
onClick={() => onMoveScreenUp(screen, group.id)}
|
||||||
|
|
@ -378,6 +462,9 @@ export function PopCategoryTree({
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
onScreenSelect,
|
onScreenSelect,
|
||||||
onScreenDesign,
|
onScreenDesign,
|
||||||
|
onScreenSettings,
|
||||||
|
onScreenCopy,
|
||||||
|
onGroupCopy,
|
||||||
onGroupSelect,
|
onGroupSelect,
|
||||||
searchTerm = "",
|
searchTerm = "",
|
||||||
}: PopCategoryTreeProps) {
|
}: PopCategoryTreeProps) {
|
||||||
|
|
@ -887,6 +974,8 @@ export function PopCategoryTree({
|
||||||
onGroupSelect={handleGroupSelect}
|
onGroupSelect={handleGroupSelect}
|
||||||
onScreenSelect={onScreenSelect}
|
onScreenSelect={onScreenSelect}
|
||||||
onScreenDesign={onScreenDesign}
|
onScreenDesign={onScreenDesign}
|
||||||
|
onScreenSettings={onScreenSettings || (() => {})}
|
||||||
|
onScreenCopy={onScreenCopy || (() => {})}
|
||||||
onEditGroup={(g) => openGroupModal(undefined, g)}
|
onEditGroup={(g) => openGroupModal(undefined, g)}
|
||||||
onDeleteGroup={(g) => {
|
onDeleteGroup={(g) => {
|
||||||
setDeletingGroup(g);
|
setDeletingGroup(g);
|
||||||
|
|
@ -902,66 +991,95 @@ export function PopCategoryTree({
|
||||||
onMoveScreenUp={handleMoveScreenUp}
|
onMoveScreenUp={handleMoveScreenUp}
|
||||||
onMoveScreenDown={handleMoveScreenDown}
|
onMoveScreenDown={handleMoveScreenDown}
|
||||||
onDeleteScreen={handleDeleteScreen}
|
onDeleteScreen={handleDeleteScreen}
|
||||||
|
onGroupCopy={onGroupCopy || (() => {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 미분류 화면 */}
|
{/* 미분류 화면 - 회사코드별 그룹핑 */}
|
||||||
{ungroupedScreens.length > 0 && (
|
{ungroupedScreens.length > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 pt-4 border-t">
|
||||||
<div className="text-xs text-muted-foreground px-2 mb-2">
|
<div className="text-xs text-muted-foreground px-2 mb-2">
|
||||||
미분류 ({ungroupedScreens.length})
|
미분류 ({ungroupedScreens.length})
|
||||||
</div>
|
</div>
|
||||||
{ungroupedScreens.map((screen) => (
|
{(() => {
|
||||||
<div
|
const grouped = ungroupedScreens.reduce<Record<string, typeof ungroupedScreens>>((acc, screen) => {
|
||||||
key={`ungrouped-${screen.screenId}`}
|
const code = screen.companyCode || "unknown";
|
||||||
className={cn(
|
if (!acc[code]) acc[code] = [];
|
||||||
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
acc[code].push(screen);
|
||||||
selectedScreen?.screenId === screen.screenId
|
return acc;
|
||||||
? "bg-primary/10 text-primary"
|
}, {});
|
||||||
: "hover:bg-muted",
|
const companyKeys = Object.keys(grouped).sort();
|
||||||
"group"
|
const hasMultipleCompanies = companyKeys.length > 1;
|
||||||
)}
|
|
||||||
onClick={() => onScreenSelect(screen)}
|
return companyKeys.map((companyCode) => (
|
||||||
onDoubleClick={() => onScreenDesign(screen)}
|
<div key={`ungrouped-company-${companyCode}`}>
|
||||||
>
|
{hasMultipleCompanies && (
|
||||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
<div className="text-[10px] font-medium text-muted-foreground px-2 py-1 mt-1 bg-muted/50 rounded">
|
||||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
{companyCode === "*" ? "최고관리자" : companyCode}
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
</div>
|
||||||
|
)}
|
||||||
{/* 더보기 메뉴 */}
|
{grouped[companyCode].map((screen) => (
|
||||||
<DropdownMenu>
|
<div
|
||||||
<DropdownMenuTrigger asChild>
|
key={`ungrouped-${screen.screenId}`}
|
||||||
<Button
|
className={cn(
|
||||||
variant="ghost"
|
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
||||||
size="icon"
|
selectedScreen?.screenId === screen.screenId
|
||||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
? "bg-primary/10 text-primary"
|
||||||
onClick={(e) => e.stopPropagation()}
|
: "hover:bg-muted",
|
||||||
|
"group",
|
||||||
|
hasMultipleCompanies && "pl-4"
|
||||||
|
)}
|
||||||
|
onClick={() => onScreenSelect(screen)}
|
||||||
|
onDoubleClick={() => onScreenDesign(screen)}
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||||
</Button>
|
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||||
</DropdownMenuTrigger>
|
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
<DropdownMenu>
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<DropdownMenuTrigger asChild>
|
||||||
설계
|
<Button
|
||||||
</DropdownMenuItem>
|
variant="ghost"
|
||||||
<DropdownMenuSeparator />
|
size="icon"
|
||||||
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
<MoveRight className="h-4 w-4 mr-2" />
|
onClick={(e) => e.stopPropagation()}
|
||||||
카테고리로 이동
|
>
|
||||||
</DropdownMenuItem>
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
<DropdownMenuSeparator />
|
</Button>
|
||||||
<DropdownMenuItem
|
</DropdownMenuTrigger>
|
||||||
className="text-destructive"
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
onClick={() => handleDeleteScreen(screen)}
|
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
||||||
>
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
설계
|
||||||
화면 삭제
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => onScreenSettings?.(screen)}>
|
||||||
</DropdownMenuContent>
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
</DropdownMenu>
|
설정 (이름 변경)
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
))}
|
<DropdownMenuItem onClick={() => onScreenCopy?.(screen)}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
복사
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
||||||
|
<MoveRight className="h-4 w-4 mr-2" />
|
||||||
|
카테고리로 이동
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteScreen(screen)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
화면 삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,560 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { GroupCopyInfo } from "./PopCategoryTree";
|
||||||
|
import { getCompanyList } from "@/lib/api/company";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
import { Company } from "@/types/company";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface LinkedScreenInfo {
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
references: Array<{
|
||||||
|
componentId: string;
|
||||||
|
referenceType: string;
|
||||||
|
}>;
|
||||||
|
deploy: boolean;
|
||||||
|
newScreenName: string;
|
||||||
|
newScreenCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenEntry {
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
newScreenName: string;
|
||||||
|
newScreenCode: string;
|
||||||
|
included: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopDeployModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
screen: ScreenDefinition | null;
|
||||||
|
groupScreens?: ScreenDefinition[];
|
||||||
|
groupName?: string;
|
||||||
|
groupInfo?: GroupCopyInfo;
|
||||||
|
allScreens: ScreenDefinition[];
|
||||||
|
onDeployed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopDeployModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
screen,
|
||||||
|
groupScreens,
|
||||||
|
groupName,
|
||||||
|
groupInfo,
|
||||||
|
allScreens,
|
||||||
|
onDeployed,
|
||||||
|
}: PopDeployModalProps) {
|
||||||
|
const isGroupMode = !!(groupScreens && groupScreens.length > 0);
|
||||||
|
|
||||||
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [targetCompanyCode, setTargetCompanyCode] = useState("");
|
||||||
|
|
||||||
|
// 단일 화면 모드
|
||||||
|
const [screenName, setScreenName] = useState("");
|
||||||
|
const [screenCode, setScreenCode] = useState("");
|
||||||
|
const [linkedScreens, setLinkedScreens] = useState<LinkedScreenInfo[]>([]);
|
||||||
|
|
||||||
|
// 그룹 모드
|
||||||
|
const [groupEntries, setGroupEntries] = useState<ScreenEntry[]>([]);
|
||||||
|
|
||||||
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
|
||||||
|
// 회사 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
getCompanyList({ status: "active" })
|
||||||
|
.then((list) => {
|
||||||
|
setCompanies(list.filter((c) => c.company_code !== "*"));
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 모달 열릴 때 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
setTargetCompanyCode("");
|
||||||
|
setLinkedScreens([]);
|
||||||
|
|
||||||
|
if (isGroupMode && groupScreens) {
|
||||||
|
setGroupEntries(
|
||||||
|
groupScreens.map((s) => ({
|
||||||
|
screenId: s.screenId,
|
||||||
|
screenName: s.screenName,
|
||||||
|
newScreenName: s.screenName,
|
||||||
|
newScreenCode: "",
|
||||||
|
included: true,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setScreenName("");
|
||||||
|
setScreenCode("");
|
||||||
|
} else if (screen) {
|
||||||
|
setScreenName(screen.screenName);
|
||||||
|
setScreenCode("");
|
||||||
|
setGroupEntries([]);
|
||||||
|
analyzeLinks(screen.screenId);
|
||||||
|
}
|
||||||
|
}, [open, screen, groupScreens, isGroupMode]);
|
||||||
|
|
||||||
|
// 회사 선택 시 화면 코드 자동 생성
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetCompanyCode) return;
|
||||||
|
|
||||||
|
if (isGroupMode) {
|
||||||
|
const count = groupEntries.filter((e) => e.included).length;
|
||||||
|
if (count > 0) {
|
||||||
|
screenApi
|
||||||
|
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||||
|
.then((codes) => {
|
||||||
|
let codeIdx = 0;
|
||||||
|
setGroupEntries((prev) =>
|
||||||
|
prev.map((e) =>
|
||||||
|
e.included
|
||||||
|
? { ...e, newScreenCode: codes[codeIdx++] || "" }
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const count = 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||||
|
screenApi
|
||||||
|
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||||
|
.then((codes) => {
|
||||||
|
setScreenCode(codes[0] || "");
|
||||||
|
setLinkedScreens((prev) =>
|
||||||
|
prev.map((ls, idx) => ({
|
||||||
|
...ls,
|
||||||
|
newScreenCode: codes[idx + 1] || "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}, [targetCompanyCode]);
|
||||||
|
|
||||||
|
const analyzeLinks = async (screenId: number) => {
|
||||||
|
setAnalyzing(true);
|
||||||
|
try {
|
||||||
|
const result = await screenApi.analyzePopScreenLinks(screenId);
|
||||||
|
const linked: LinkedScreenInfo[] = result.linkedScreenIds.map(
|
||||||
|
(linkedId) => {
|
||||||
|
const linkedScreen = allScreens.find(
|
||||||
|
(s) => s.screenId === linkedId,
|
||||||
|
);
|
||||||
|
const refs = result.references.filter(
|
||||||
|
(r) => r.targetScreenId === linkedId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
screenId: linkedId,
|
||||||
|
screenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||||
|
screenCode: linkedScreen?.screenCode || "",
|
||||||
|
references: refs.map((r) => ({
|
||||||
|
componentId: r.componentId,
|
||||||
|
referenceType: r.referenceType,
|
||||||
|
})),
|
||||||
|
deploy: true,
|
||||||
|
newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||||
|
newScreenCode: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setLinkedScreens(linked);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연결 분석 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeploy = async () => {
|
||||||
|
if (!targetCompanyCode) return;
|
||||||
|
|
||||||
|
setDeploying(true);
|
||||||
|
try {
|
||||||
|
let screensToSend: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (isGroupMode) {
|
||||||
|
screensToSend = groupEntries
|
||||||
|
.filter((e) => e.included && e.newScreenCode)
|
||||||
|
.map((e) => ({
|
||||||
|
sourceScreenId: e.screenId,
|
||||||
|
screenName: e.newScreenName,
|
||||||
|
screenCode: e.newScreenCode,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
if (!screen || !screenName || !screenCode) return;
|
||||||
|
screensToSend = [
|
||||||
|
{
|
||||||
|
sourceScreenId: screen.screenId,
|
||||||
|
screenName,
|
||||||
|
screenCode,
|
||||||
|
},
|
||||||
|
...linkedScreens
|
||||||
|
.filter((ls) => ls.deploy)
|
||||||
|
.map((ls) => ({
|
||||||
|
sourceScreenId: ls.screenId,
|
||||||
|
screenName: ls.newScreenName,
|
||||||
|
screenCode: ls.newScreenCode,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screensToSend.length === 0) {
|
||||||
|
toast.error("복사할 화면이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployPayload: Parameters<typeof screenApi.deployPopScreens>[0] = {
|
||||||
|
screens: screensToSend,
|
||||||
|
targetCompanyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isGroupMode && groupInfo) {
|
||||||
|
deployPayload.groupStructure = groupInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await screenApi.deployPopScreens(deployPayload);
|
||||||
|
|
||||||
|
const groupMsg = result.createdGroups
|
||||||
|
? ` (카테고리 ${result.createdGroups}개 생성)`
|
||||||
|
: "";
|
||||||
|
toast.success(
|
||||||
|
`POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`,
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
onDeployed?.();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || "복사에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeploying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCount = isGroupMode
|
||||||
|
? groupEntries.filter((e) => e.included).length
|
||||||
|
: 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||||
|
|
||||||
|
const canDeploy = isGroupMode
|
||||||
|
? !deploying && targetCompanyCode && groupEntries.some((e) => e.included)
|
||||||
|
: !deploying && targetCompanyCode && screenName && screenCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
POP 화면 복사
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{isGroupMode
|
||||||
|
? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.`
|
||||||
|
: screen
|
||||||
|
? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.`
|
||||||
|
: "화면을 선택해주세요."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 대상 회사 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
대상 회사 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={targetCompanyCode}
|
||||||
|
onValueChange={setTargetCompanyCode}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="회사를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<SelectItem
|
||||||
|
key={c.company_code}
|
||||||
|
value={c.company_code}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{c.company_name} ({c.company_code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */}
|
||||||
|
{isGroupMode ? (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
복사 구조 ({groupEntries.filter((e) => e.included).length}개
|
||||||
|
화면
|
||||||
|
{groupInfo
|
||||||
|
? ` + ${1 + (groupInfo.children?.length || 0)}개 카테고리`
|
||||||
|
: ""}
|
||||||
|
)
|
||||||
|
</Label>
|
||||||
|
<div className="mt-1 max-h-[280px] overflow-y-auto rounded-md border p-2">
|
||||||
|
{groupInfo ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{/* 메인 카테고리 */}
|
||||||
|
<div className="flex items-center gap-1.5 rounded bg-muted/50 p-1.5 text-xs font-medium">
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||||
|
<span>{groupInfo.groupName}</span>
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
새 카테고리 생성
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* 메인 카테고리의 직접 화면 */}
|
||||||
|
{groupEntries
|
||||||
|
.filter((e) => groupInfo.screenIds.includes(e.screenId))
|
||||||
|
.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.screenId}
|
||||||
|
className="flex items-center gap-2 rounded p-1.5 pl-6 text-xs hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={entry.included}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupEntries((prev) =>
|
||||||
|
prev.map((e) =>
|
||||||
|
e.screenId === entry.screenId
|
||||||
|
? { ...e, included: !!checked }
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{entry.screenName}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
#{entry.screenId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* 하위 카테고리들 */}
|
||||||
|
{groupInfo.children?.map((child) => (
|
||||||
|
<div key={child.sourceGroupId}>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/30 p-1.5 pl-4 text-xs font-medium">
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||||
|
<span>{child.groupName}</span>
|
||||||
|
</div>
|
||||||
|
{groupEntries
|
||||||
|
.filter((e) =>
|
||||||
|
child.screenIds.includes(e.screenId),
|
||||||
|
)
|
||||||
|
.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.screenId}
|
||||||
|
className="flex items-center gap-2 rounded p-1.5 pl-10 text-xs hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={entry.included}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupEntries((prev) =>
|
||||||
|
prev.map((e) =>
|
||||||
|
e.screenId === entry.screenId
|
||||||
|
? { ...e, included: !!checked }
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{entry.screenName}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
#{entry.screenId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupEntries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.screenId}
|
||||||
|
className="flex items-center gap-2 rounded p-1.5 text-xs hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={entry.included}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupEntries((prev) =>
|
||||||
|
prev.map((e) =>
|
||||||
|
e.screenId === entry.screenId
|
||||||
|
? { ...e, included: !!checked }
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Monitor className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{entry.screenName}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
#{entry.screenId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
카테고리 구조와 화면 간 연결(cartScreenId 등)이 자동으로
|
||||||
|
복사됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ===== 단일 모드: 화면명 + 코드 ===== */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
새 화면명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
value={screenName}
|
||||||
|
onChange={(e) => setScreenName(e.target.value)}
|
||||||
|
placeholder="화면 이름"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
화면 코드 (자동생성)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm"
|
||||||
|
value={screenCode}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 연결 화면 감지 */}
|
||||||
|
{analyzing ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border p-3 text-xs text-muted-foreground sm:text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
연결된 화면을 분석 중입니다...
|
||||||
|
</div>
|
||||||
|
) : linkedScreens.length > 0 ? (
|
||||||
|
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-800 dark:text-amber-300 sm:text-sm">
|
||||||
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
|
연결된 POP 화면 {linkedScreens.length}개 감지됨
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{linkedScreens.map((ls) => (
|
||||||
|
<div
|
||||||
|
key={ls.screenId}
|
||||||
|
className="flex items-center justify-between rounded bg-background p-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{ls.screenName}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
ID: {ls.screenId} |{" "}
|
||||||
|
{ls.references
|
||||||
|
.map((r) => r.referenceType)
|
||||||
|
.join(", ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={ls.deploy}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setLinkedScreens((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.screenId === ls.screenId
|
||||||
|
? { ...item, deploy: !!checked }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">함께 복사</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
|
||||||
|
함께 복사하면 화면 간 연결(cartScreenId 등)이 새 ID로 자동
|
||||||
|
치환됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!analyzing && (
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground sm:text-sm">
|
||||||
|
연결된 POP 화면이 없습니다. 이 화면만 복사됩니다.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
disabled={deploying}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeploy}
|
||||||
|
disabled={!canDeploy}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{deploying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
복사 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`${totalCount}개 화면 복사`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -165,19 +165,26 @@ export function PopScreenSettingModal({
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
// 화면 기본 정보 업데이트
|
|
||||||
const screenUpdate: Partial<ScreenDefinition> = {
|
const screenUpdate: Partial<ScreenDefinition> = {
|
||||||
screenName,
|
screenName,
|
||||||
description: screenDescription,
|
description: screenDescription,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// screen_definitions 테이블에 화면명/설명 업데이트
|
||||||
|
if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) {
|
||||||
|
await screenApi.updateScreenInfo(screen.screenId, {
|
||||||
|
screenName,
|
||||||
|
description: screenDescription,
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 레이아웃에 하위 화면 정보 저장
|
// 레이아웃에 하위 화면 정보 저장
|
||||||
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
|
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
|
||||||
const updatedLayout = {
|
const updatedLayout = {
|
||||||
...currentLayout,
|
...currentLayout,
|
||||||
version: "pop-1.0",
|
version: "pop-1.0",
|
||||||
subScreens: subScreens,
|
subScreens: subScreens,
|
||||||
// flow 배열 자동 생성 (메인 → 각 서브)
|
|
||||||
flow: subScreens.map((sub) => ({
|
flow: subScreens.map((sub) => ({
|
||||||
from: sub.triggerFrom || "main",
|
from: sub.triggerFrom || "main",
|
||||||
to: sub.id,
|
to: sub.id,
|
||||||
|
|
@ -201,11 +208,11 @@ export function PopScreenSettingModal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
|
||||||
<DialogHeader className="p-4 pb-0 shrink-0">
|
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||||
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
{screen.screenName} ({screen.screenCode})
|
{screen.screenName} [{screen.screenCode}]
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -214,57 +221,57 @@ export function PopScreenSettingModal({
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
className="flex-1 flex flex-col min-h-0"
|
className="flex-1 flex flex-col min-h-0"
|
||||||
>
|
>
|
||||||
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
<TabsList className="shrink-0 mx-6 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="overview"
|
value="overview"
|
||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 mr-2" />
|
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||||
개요
|
기본 정보
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="subscreens"
|
value="subscreens"
|
||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Layers className="h-4 w-4 mr-2" />
|
<Layers className="h-3.5 w-3.5 mr-1.5" />
|
||||||
하위 화면
|
하위 화면
|
||||||
{subScreens.length > 0 && (
|
{subScreens.length > 0 && (
|
||||||
<Badge variant="secondary" className="ml-2 text-xs">
|
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1.5">
|
||||||
{subScreens.length}
|
{subScreens.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="flow"
|
value="flow"
|
||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<GitBranch className="h-4 w-4 mr-2" />
|
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||||
화면 흐름
|
화면 흐름
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 개요 탭 */}
|
{/* 기본 정보 탭 */}
|
||||||
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
|
<TabsContent value="overview" className="flex-1 m-0 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 max-w-[500px]">
|
<div className="space-y-4 p-6">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
||||||
화면명 *
|
화면명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="screenName"
|
id="screenName"
|
||||||
value={screenName}
|
value={screenName}
|
||||||
onChange={(e) => setScreenName(e.target.value)}
|
onChange={(e) => setScreenName(e.target.value)}
|
||||||
placeholder="화면 이름"
|
placeholder="화면 이름을 입력하세요"
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="category" className="text-xs sm:text-sm">
|
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||||||
카테고리
|
카테고리
|
||||||
</Label>
|
</Label>
|
||||||
|
|
@ -282,7 +289,7 @@ export function PopScreenSettingModal({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||||
설명
|
설명
|
||||||
</Label>
|
</Label>
|
||||||
|
|
@ -290,13 +297,13 @@ export function PopScreenSettingModal({
|
||||||
id="description"
|
id="description"
|
||||||
value={screenDescription}
|
value={screenDescription}
|
||||||
onChange={(e) => setScreenDescription(e.target.value)}
|
onChange={(e) => setScreenDescription(e.target.value)}
|
||||||
placeholder="화면에 대한 설명"
|
placeholder="화면에 대한 설명을 입력하세요"
|
||||||
rows={3}
|
rows={3}
|
||||||
className="text-xs sm:text-sm resize-none"
|
className="text-xs sm:text-sm resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
||||||
아이콘
|
아이콘
|
||||||
</Label>
|
</Label>
|
||||||
|
|
@ -307,7 +314,7 @@ export function PopScreenSettingModal({
|
||||||
placeholder="lucide 아이콘 이름 (예: Package)"
|
placeholder="lucide 아이콘 이름 (예: Package)"
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
lucide-react 아이콘 이름을 입력하세요.
|
lucide-react 아이콘 이름을 입력하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -316,19 +323,19 @@ export function PopScreenSettingModal({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 하위 화면 탭 */}
|
{/* 하위 화면 탭 */}
|
||||||
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
|
<TabsContent value="subscreens" className="flex-1 m-0 overflow-auto">
|
||||||
<div className="space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다.
|
모달, 드로어 등 하위 화면을 관리합니다.
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" onClick={addSubScreen}>
|
<Button size="sm" className="h-8 text-xs" onClick={addSubScreen}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="h-[300px]">
|
<ScrollArea className="h-[280px]">
|
||||||
{subScreens.length === 0 ? (
|
{subScreens.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-8">
|
<div className="text-center text-muted-foreground py-8">
|
||||||
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||||
|
|
@ -339,12 +346,12 @@ export function PopScreenSettingModal({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{subScreens.map((subScreen, index) => (
|
{subScreens.map((subScreen) => (
|
||||||
<div
|
<div
|
||||||
key={subScreen.id}
|
key={subScreen.id}
|
||||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
className="flex items-start gap-2 p-3 border rounded-lg bg-muted/30"
|
||||||
>
|
>
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
|
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0 mt-1.5 cursor-grab" />
|
||||||
|
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -362,7 +369,7 @@ export function PopScreenSettingModal({
|
||||||
updateSubScreen(subScreen.id, "type", v)
|
updateSubScreen(subScreen.id, "type", v)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
<SelectTrigger className="h-8 text-xs w-[90px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -374,7 +381,7 @@ export function PopScreenSettingModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground shrink-0">
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
트리거:
|
트리거:
|
||||||
</span>
|
</span>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -403,10 +410,10 @@ export function PopScreenSettingModal({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => removeSubScreen(subScreen.id)}
|
onClick={() => removeSubScreen(subScreen.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -423,11 +430,19 @@ export function PopScreenSettingModal({
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* 푸터 */}
|
{/* 푸터 */}
|
||||||
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
|
<div className="shrink-0 px-6 py-4 border-t flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { PopCategoryTree } from "./PopCategoryTree";
|
export { PopCategoryTree } from "./PopCategoryTree";
|
||||||
|
export type { GroupCopyInfo } from "./PopCategoryTree";
|
||||||
export { PopScreenPreview } from "./PopScreenPreview";
|
export { PopScreenPreview } from "./PopScreenPreview";
|
||||||
export { PopScreenFlowView } from "./PopScreenFlowView";
|
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||||
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||||
|
export { PopDeployModal } from "./PopDeployModal";
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,59 @@ export const screenApi = {
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// POP 화면 연결 분석 (다른 화면과의 참조 관계)
|
||||||
|
analyzePopScreenLinks: async (
|
||||||
|
screenId: number,
|
||||||
|
): Promise<{
|
||||||
|
linkedScreenIds: number[];
|
||||||
|
references: Array<{
|
||||||
|
componentId: string;
|
||||||
|
referenceType: string;
|
||||||
|
targetScreenId: number;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/screen-management/screens/${screenId}/pop-links`,
|
||||||
|
);
|
||||||
|
return response.data.data || { linkedScreenIds: [], references: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
// POP 화면 배포 (다른 회사로 복사)
|
||||||
|
deployPopScreens: async (data: {
|
||||||
|
screens: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}>;
|
||||||
|
targetCompanyCode: string;
|
||||||
|
groupStructure?: {
|
||||||
|
sourceGroupId: number;
|
||||||
|
groupName: string;
|
||||||
|
groupCode: string;
|
||||||
|
screenIds: number[];
|
||||||
|
children?: Array<{
|
||||||
|
sourceGroupId: number;
|
||||||
|
groupName: string;
|
||||||
|
groupCode: string;
|
||||||
|
screenIds: number[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}): Promise<{
|
||||||
|
deployedScreens: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
newScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}>;
|
||||||
|
createdGroups?: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/screen-management/deploy-pop-screens`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
// 메인 화면 + 모달 화면들 일괄 복사
|
// 메인 화면 + 모달 화면들 일괄 복사
|
||||||
copyScreenWithModals: async (
|
copyScreenWithModals: async (
|
||||||
sourceScreenId: number,
|
sourceScreenId: number,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue