From 21e89922eb64d2e9d1e39921f096abc22ffe9af3 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 7 Apr 2026 10:46:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20POP=20=ED=9A=8C=EC=82=AC=EB=B3=84=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=84=B8=ED=8C=85=20=E2=80=94=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=ED=99=9C=EC=84=B1=ED=99=94=20=EC=8B=9C=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=9E=90=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 시 POP 메뉴 발견 → 해당 회사용 POP 레이아웃 8개 자동 복제 - 템플릿: 공통(*) 우선, COMPANY_7 폴백 - 회사명 자동 치환 (탑씰 → 해당 회사명) - screen_definitions 공통(*) 화면은 모든 회사 접근 허용 - 프로필 POP 모드 메뉴: POP 메뉴 있는 회사만 표시 - getLayoutPop 개별 자동 복제 (2중 안전망) --- .../src/controllers/authController.ts | 83 +++++++++++++++++++ .../src/services/screenManagementService.ts | 71 +++++++++++++--- frontend/components/layout/AppLayout.tsx | 41 +++++++-- 3 files changed, 176 insertions(+), 19 deletions(-) diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 4b40ce6e..0616ba14 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -7,6 +7,7 @@ import { JwtUtils } from "../utils/jwtUtils"; import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; import { sendSmartFactoryLog } from "../utils/smartFactoryLog"; +import { query, queryOne } from "../database/db"; export class AuthController { /** @@ -104,6 +105,14 @@ export class AuthController { popLandingPath = "/pop"; } logger.debug(`POP 랜딩 경로: ${popLandingPath}`); + + // POP 메뉴가 존재하면 해당 회사의 POP 레이아웃 자동 초기화 (비동기) + if (popLandingPath) { + const companyCode = loginResult.userInfo.companyCode || "ILSHIN"; + AuthController.initPopLayoutsForCompany(companyCode).catch((err) => { + logger.warn("POP 레이아웃 자동 초기화 중 오류 (무시):", err); + }); + } } catch (popError) { logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); } @@ -562,4 +571,78 @@ export class AuthController { }); } } + + /** + * POP 레이아웃 자동 초기화 + * 해당 회사의 screen_layouts_pop 레코드가 없으면 + * 템플릿(공통 '*' 또는 COMPANY_7)에서 복제하여 생성 + * + * 기본 POP 화면 ID: 5, 6, 7, 8, 6526, 6527, 6528, 6529 + */ + static async initPopLayoutsForCompany(companyCode: string): Promise { + // SUPER_ADMIN이나 공통(*)은 초기화 불필요 + if (companyCode === "*" || companyCode === "COMPANY_7") return; + + const POP_SCREEN_IDS = [5, 6, 7, 8, 6526, 6527, 6528, 6529]; + + // 이미 해당 회사의 POP 레이아웃이 하나라도 있으면 스킵 (중복 초기화 방지) + const existing = await query<{ cnt: string }>( + `SELECT COUNT(*)::text AS cnt FROM screen_layouts_pop + WHERE company_code = $1 AND screen_id = ANY($2::int[])`, + [companyCode, POP_SCREEN_IDS], + ); + const existingCount = parseInt(existing[0]?.cnt || "0", 10); + if (existingCount > 0) { + logger.debug(`POP 레이아웃 이미 존재 (${companyCode}): ${existingCount}개, 스킵`); + return; + } + + logger.info(`POP 레이아웃 자동 초기화 시작: ${companyCode}`); + + // 회사명 조회 (레이아웃 내 회사명 치환용) + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode], + ); + const companyName = companyInfo?.company_name || companyCode; + + let initCount = 0; + for (const screenId of POP_SCREEN_IDS) { + // 템플릿 조회: 공통(*) 우선, 없으면 COMPANY_7 폴백 + let template = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screenId], + ); + if (!template) { + template = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = 'COMPANY_7'`, + [screenId], + ); + } + + if (!template) { + logger.debug(`POP 템플릿 없음 (screen_id=${screenId}), 스킵`); + continue; + } + + // 레이아웃 복제 + 회사명 치환 + const layoutStr = JSON.stringify(template.layout_data); + const replacedStr = layoutStr + .replace(/\(주\)탑씰/g, companyName) + .replace(/탑씰/g, companyName) + .replace(/TOPSEAL/gi, companyName); + + await 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(), 'SYSTEM', 'SYSTEM') + ON CONFLICT (screen_id, company_code) DO NOTHING`, + [screenId, companyCode, replacedStr], + ); + initCount++; + } + + logger.info(`POP 레이아웃 자동 초기화 완료: ${companyCode}, ${initCount}개 화면`); + } } diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index e54b2cfa..471935db 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5909,7 +5909,8 @@ export class ScreenManagementService { const existingScreen = screens[0]; // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 - if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 접근 허용 + if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다."); } @@ -5935,20 +5936,64 @@ export class ScreenManagementService { ); } } else { - // 일반 사용자: 회사별 우선, 없으면 공통(*) 조회 + // 일반 사용자: 회사별 우선, 없으면 템플릿에서 자동 복제 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, [screenId, companyCode], ); - // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 + // 회사별 레이아웃이 없으면 템플릿에서 자동 복제 if (!layout && companyCode !== "*") { - layout = await queryOne<{ layout_data: any }>( + // 1. 공통(*) 템플릿 조회 + let templateLayout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = '*'`, [screenId], ); + + // 2. 공통 없으면 COMPANY_7(탑씰) 폴백 + if (!templateLayout) { + templateLayout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = 'COMPANY_7'`, + [screenId], + ); + } + + // 3. 템플릿이 있으면 해당 회사용으로 복제 + if (templateLayout) { + console.log(`POP 레이아웃 자동 복제: screen_id=${screenId}, 대상 회사=${companyCode}`); + + // 회사명 조회 (레이아웃 내 회사명 치환용) + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode], + ); + const companyName = companyInfo?.company_name || companyCode; + + let clonedData = JSON.parse(JSON.stringify(templateLayout.layout_data)); + + // layout_data 내 회사명 텍스트 치환 (탑씰 관련 문자열 → 대상 회사명) + const layoutStr = JSON.stringify(clonedData); + const replacedStr = layoutStr + .replace(/\(주\)탑씰/g, companyName) + .replace(/탑씰/g, companyName) + .replace(/TOPSEAL/gi, companyName); + clonedData = JSON.parse(replacedStr); + + // 해당 회사 코드로 INSERT (UPSERT) + await 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(), 'SYSTEM', 'SYSTEM') + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = 'SYSTEM'`, + [screenId, companyCode, JSON.stringify(clonedData)], + ); + + console.log(`POP 레이아웃 자동 복제 완료: screen_id=${screenId}, company=${companyCode}`); + layout = { layout_data: clonedData }; + } } } @@ -6041,13 +6086,15 @@ export class ScreenManagementService { const existingScreen = screens[0]; - if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 저장 허용 + if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } - // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게) - const targetCompanyCode = companyCode === "*" - ? (existingScreen.company_code || "*") + // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 + // 공통 화면(*)인 경우: 일반 사용자는 자기 회사 코드로 저장 (회사별 레이아웃 분리) + const targetCompanyCode = companyCode === "*" + ? (existingScreen.company_code || "*") : companyCode; console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); @@ -6086,10 +6133,11 @@ export class ScreenManagementService { [], ); } else { - // 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용) + // 일반 회사: 해당 회사 레이아웃 + 공통(*)/COMPANY_7 템플릿도 포함 + // (getLayoutPop에서 자동 복제하므로 템플릿이 있으면 해당 회사도 사용 가능) result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop - WHERE company_code = $1`, + WHERE company_code IN ($1, '*', 'COMPANY_7')`, [companyCode], ); } @@ -6121,7 +6169,8 @@ export class ScreenManagementService { const existingScreen = screens[0]; - if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 삭제 허용 + if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다."); } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index faa38879..ec761e04 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -249,6 +249,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { const [isMobile, setIsMobile] = useState(false); const [showCompanySwitcher, setShowCompanySwitcher] = useState(false); const [currentCompanyName, setCurrentCompanyName] = useState(""); + const [hasPopMenus, setHasPopMenus] = useState(false); // URL 직접 접근 시 탭 자동 열기 useEffect(() => { @@ -313,6 +314,26 @@ function AppLayoutInner({ children }: AppLayoutProps) { return () => window.removeEventListener("resize", checkIsMobile); }, []); + // POP 메뉴 존재 여부 확인 + useEffect(() => { + const checkPopMenus = async () => { + try { + const response = await menuApi.getPopMenus(); + if (response.success && response.data) { + const { childMenus, landingMenu } = response.data; + setHasPopMenus(!!(landingMenu?.menu_url || childMenus.length > 0)); + } else { + setHasPopMenus(false); + } + } catch { + setHasPopMenus(false); + } + }; + if (user) { + checkPopMenus(); + } + }, [user]); + // 프로필 관련 로직 const { isModalOpen, @@ -667,10 +688,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { 결재함 - - - POP 모드 - + {hasPopMenus && ( + + + POP 모드 + + )}
@@ -843,10 +866,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { 결재함 - - - POP 모드 - + {hasPopMenus && ( + + + POP 모드 + + )}