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

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

View File

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

View File

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

View File

@ -42,6 +42,8 @@ import {
copyCategoryMapping,
copyTableTypeColumns,
copyCascadingRelation,
analyzePopScreenLinks,
deployPopScreens,
} from "../controllers/screenManagementController";
const router = express.Router();
@ -123,4 +125,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns);
// 연쇄관계 설정 복제
router.post("/copy-cascading-relation", copyCascadingRelation);
// POP 화면 배포 (다른 회사로 복사)
router.get("/screens/:screenId/pop-links", analyzePopScreenLinks);
router.post("/deploy-pop-screens", deployPopScreens);
export default router;

View File

@ -5425,28 +5425,24 @@ export class ScreenManagementService {
async getScreenIdsWithPopLayout(
companyCode: string,
): Promise<number[]> {
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
console.log(`회사 코드: ${companyCode}`);
let result: { screen_id: number }[];
if (companyCode === "*") {
// 최고 관리자: 모든 POP 레이아웃 조회
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
[],
);
} else {
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
// 일반 회사: 해당 회사 레이아웃 조회 (company_code='*'는 최고관리자 전용)
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop
WHERE company_code = $1 OR company_code = '*'`,
WHERE company_code = $1`,
[companyCode],
);
}
const screenIds = result.map((r) => r.screen_id);
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}`);
logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length });
return screenIds;
}
@ -5484,6 +5480,512 @@ export class ScreenManagementService {
console.log(`POP 레이아웃 삭제 완료`);
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

View File

@ -13,6 +13,7 @@ import {
Settings,
LayoutGrid,
GitBranch,
Upload,
} from "lucide-react";
import { PopDesigner } from "@/components/pop/designer";
import { ScrollToTop } from "@/components/common/ScrollToTop";
@ -27,6 +28,7 @@ import {
PopScreenPreview,
PopScreenFlowView,
PopScreenSettingModal,
PopDeployModal,
} from "@/components/pop/management";
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
@ -62,6 +64,10 @@ export default function PopScreenManagementPage() {
// UI 상태
const [isCreateOpen, setIsCreateOpen] = 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 [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
@ -235,6 +241,21 @@ export default function PopScreenManagementPage() {
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</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">
<Plus className="h-4 w-4" />
POP
@ -290,6 +311,24 @@ export default function PopScreenManagementPage() {
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
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}
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 버튼 */}
<ScrollToTop />
</div>

View File

@ -20,6 +20,8 @@ import {
ArrowUp,
ArrowDown,
Search,
Settings,
Copy,
} from "lucide-react";
import { Button } from "@/components/ui/button";
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 {
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
selectedScreen: ScreenDefinition | null;
onScreenSelect: (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;
searchTerm?: string;
}
@ -87,6 +105,8 @@ interface TreeNodeProps {
onGroupSelect: (group: PopScreenGroup) => void;
onScreenSelect: (screen: ScreenDefinition) => void;
onScreenDesign: (screen: ScreenDefinition) => void;
onScreenSettings: (screen: ScreenDefinition) => void;
onScreenCopy: (screen: ScreenDefinition) => void;
onEditGroup: (group: PopScreenGroup) => void;
onDeleteGroup: (group: PopScreenGroup) => void;
onAddSubGroup: (parentGroup: PopScreenGroup) => void;
@ -101,6 +121,7 @@ interface TreeNodeProps {
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
onDeleteScreen: (screen: ScreenDefinition) => void;
onGroupCopy: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
}
// ============================================================
@ -118,6 +139,7 @@ function TreeNode({
onMoveScreenUp,
onMoveScreenDown,
onDeleteScreen,
onGroupCopy,
expandedGroups,
onToggle,
selectedGroupId,
@ -125,6 +147,8 @@ function TreeNode({
onGroupSelect,
onScreenSelect,
onScreenDesign,
onScreenSettings,
onScreenCopy,
onEditGroup,
onDeleteGroup,
onAddSubGroup,
@ -134,7 +158,7 @@ function TreeNode({
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
const isSelected = selectedGroupId === group.id;
// 그룹에 연결된 화면 목록
// 그룹에 직접 연결된 화면 목록
const groupScreens = useMemo(() => {
if (!group.screens) return [];
return group.screens
@ -142,6 +166,20 @@ function TreeNode({
.filter((s): s is ScreenDefinition => s !== undefined);
}, [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 화면)인지 확인
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")} />
)}
{/* 그룹명 - 루트는 볼드체 */}
<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 && (
@ -224,6 +269,34 @@ function TreeNode({
<Edit className="h-4 w-4 mr-2" />
</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 />
<DropdownMenuItem
onClick={() => onMoveGroupUp(group)}
@ -267,6 +340,8 @@ function TreeNode({
onGroupSelect={onGroupSelect}
onScreenSelect={onScreenSelect}
onScreenDesign={onScreenDesign}
onScreenSettings={onScreenSettings}
onScreenCopy={onScreenCopy}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddSubGroup={onAddSubGroup}
@ -279,6 +354,7 @@ function TreeNode({
onMoveScreenUp={onMoveScreenUp}
onMoveScreenDown={onMoveScreenDown}
onDeleteScreen={onDeleteScreen}
onGroupCopy={onGroupCopy}
/>
))}
@ -324,6 +400,14 @@ function TreeNode({
<Edit className="h-4 w-4 mr-2" />
</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 />
<DropdownMenuItem
onClick={() => onMoveScreenUp(screen, group.id)}
@ -378,6 +462,9 @@ export function PopCategoryTree({
selectedScreen,
onScreenSelect,
onScreenDesign,
onScreenSettings,
onScreenCopy,
onGroupCopy,
onGroupSelect,
searchTerm = "",
}: PopCategoryTreeProps) {
@ -887,6 +974,8 @@ export function PopCategoryTree({
onGroupSelect={handleGroupSelect}
onScreenSelect={onScreenSelect}
onScreenDesign={onScreenDesign}
onScreenSettings={onScreenSettings || (() => {})}
onScreenCopy={onScreenCopy || (() => {})}
onEditGroup={(g) => openGroupModal(undefined, g)}
onDeleteGroup={(g) => {
setDeletingGroup(g);
@ -902,66 +991,95 @@ export function PopCategoryTree({
onMoveScreenUp={handleMoveScreenUp}
onMoveScreenDown={handleMoveScreenDown}
onDeleteScreen={handleDeleteScreen}
onGroupCopy={onGroupCopy || (() => {})}
/>
))}
{/* 미분류 화면 */}
{/* 미분류 화면 - 회사코드별 그룹핑 */}
{ungroupedScreens.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground px-2 mb-2">
({ungroupedScreens.length})
</div>
{ungroupedScreens.map((screen) => (
<div
key={`ungrouped-${screen.screenId}`}
className={cn(
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
selectedScreen?.screenId === screen.screenId
? "bg-primary/10 text-primary"
: "hover:bg-muted",
"group"
)}
onClick={() => onScreenSelect(screen)}
onDoubleClick={() => onScreenDesign(screen)}
>
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
{/* 더보기 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => e.stopPropagation()}
{(() => {
const grouped = ungroupedScreens.reduce<Record<string, typeof ungroupedScreens>>((acc, screen) => {
const code = screen.companyCode || "unknown";
if (!acc[code]) acc[code] = [];
acc[code].push(screen);
return acc;
}, {});
const companyKeys = Object.keys(grouped).sort();
const hasMultipleCompanies = companyKeys.length > 1;
return companyKeys.map((companyCode) => (
<div key={`ungrouped-company-${companyCode}`}>
{hasMultipleCompanies && (
<div className="text-[10px] font-medium text-muted-foreground px-2 py-1 mt-1 bg-muted/50 rounded">
{companyCode === "*" ? "최고관리자" : companyCode}
</div>
)}
{grouped[companyCode].map((screen) => (
<div
key={`ungrouped-${screen.screenId}`}
className={cn(
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
selectedScreen?.screenId === screen.screenId
? "bg-primary/10 text-primary"
: "hover:bg-muted",
"group",
hasMultipleCompanies && "pl-4"
)}
onClick={() => onScreenSelect(screen)}
onDoubleClick={() => onScreenDesign(screen)}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
<Edit 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>
))}
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
<Edit className="h-4 w-4 mr-2" />
</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 />
<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>
)}
</>

View File

@ -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>
);
}

View File

@ -165,19 +165,26 @@ export function PopScreenSettingModal({
try {
setSaving(true);
// 화면 기본 정보 업데이트
const screenUpdate: Partial<ScreenDefinition> = {
screenName,
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 updatedLayout = {
...currentLayout,
version: "pop-1.0",
subScreens: subScreens,
// flow 배열 자동 생성 (메인 → 각 서브)
flow: subScreens.map((sub) => ({
from: sub.triggerFrom || "main",
to: sub.id,
@ -201,11 +208,11 @@ export function PopScreenSettingModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="p-4 pb-0 shrink-0">
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle className="text-base sm:text-lg">POP </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{screen.screenName} ({screen.screenCode})
{screen.screenName} [{screen.screenCode}]
</DialogDescription>
</DialogHeader>
@ -214,57 +221,57 @@ export function PopScreenSettingModal({
onValueChange={setActiveTab}
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
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
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 && (
<Badge variant="secondary" className="ml-2 text-xs">
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1.5">
{subScreens.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger
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>
</TabsList>
{/* 개요 탭 */}
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
{/* 기본 정보 탭 */}
<TabsContent value="overview" className="flex-1 m-0 overflow-auto">
{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" />
</div>
) : (
<div className="space-y-4 max-w-[500px]">
<div>
<div className="space-y-4 p-6">
<div className="space-y-1.5">
<Label htmlFor="screenName" className="text-xs sm:text-sm">
*
<span className="text-destructive">*</span>
</Label>
<Input
id="screenName"
value={screenName}
onChange={(e) => setScreenName(e.target.value)}
placeholder="화면 이름"
placeholder="화면 이름을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor="category" className="text-xs sm:text-sm">
</Label>
@ -282,7 +289,7 @@ export function PopScreenSettingModal({
</Select>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
@ -290,13 +297,13 @@ export function PopScreenSettingModal({
id="description"
value={screenDescription}
onChange={(e) => setScreenDescription(e.target.value)}
placeholder="화면에 대한 설명"
placeholder="화면에 대한 설명을 입력하세요"
rows={3}
className="text-xs sm:text-sm resize-none"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor="icon" className="text-xs sm:text-sm">
</Label>
@ -307,7 +314,7 @@ export function PopScreenSettingModal({
placeholder="lucide 아이콘 이름 (예: Package)"
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 .
</p>
</div>
@ -316,19 +323,19 @@ export function PopScreenSettingModal({
</TabsContent>
{/* 하위 화면 탭 */}
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
<div className="space-y-4">
<TabsContent value="subscreens" className="flex-1 m-0 overflow-auto">
<div className="p-6 space-y-4">
<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>
<Button size="sm" onClick={addSubScreen}>
<Plus className="h-4 w-4 mr-1" />
<Button size="sm" className="h-8 text-xs" onClick={addSubScreen}>
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
<ScrollArea className="h-[300px]">
<ScrollArea className="h-[280px]">
{subScreens.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
@ -339,12 +346,12 @@ export function PopScreenSettingModal({
</div>
) : (
<div className="space-y-3">
{subScreens.map((subScreen, index) => (
{subScreens.map((subScreen) => (
<div
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 items-center gap-2">
@ -362,7 +369,7 @@ export function PopScreenSettingModal({
updateSubScreen(subScreen.id, "type", v)
}
>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectTrigger className="h-8 text-xs w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -374,7 +381,7 @@ export function PopScreenSettingModal({
</div>
<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>
<Select
@ -403,10 +410,10 @@ export function PopScreenSettingModal({
<Button
variant="ghost"
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)}
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
@ -423,11 +430,19 @@ export function PopScreenSettingModal({
</Tabs>
{/* 푸터 */}
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
<div className="shrink-0 px-6 py-4 border-t flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
<Button onClick={handleSave} disabled={saving}>
<Button
onClick={handleSave}
disabled={saving}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (

View File

@ -3,6 +3,8 @@
*/
export { PopCategoryTree } from "./PopCategoryTree";
export type { GroupCopyInfo } from "./PopCategoryTree";
export { PopScreenPreview } from "./PopScreenPreview";
export { PopScreenFlowView } from "./PopScreenFlowView";
export { PopScreenSettingModal } from "./PopScreenSettingModal";
export { PopDeployModal } from "./PopDeployModal";

View File

@ -269,6 +269,59 @@ export const screenApi = {
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 (
sourceScreenId: number,