diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md index 6b4ff99c..9f7ef180 100644 --- a/.cursor/agents/pipeline-backend.md +++ b/.cursor/agents/pipeline-backend.md @@ -51,6 +51,14 @@ export const getList = async (req: Request, res: Response) => { - backend-node/src/routes/index.ts에 import 추가 필수 - authenticateToken 미들웨어 적용 필수 +# CRITICAL: 사용자 메뉴 화면은 프론트엔드 페이지로 만들지 않는다! + +백엔드 에이전트는 프론트엔드 page.tsx를 직접 생성하지 않지만, +다른 에이전트에게 "프론트엔드 페이지를 만들어달라"고 요청하거나 제안해서도 안 된다. + +사용자 메뉴 화면은 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다. +백엔드 에이전트가 할 일은 API 엔드포인트(controller/routes)와 DB 마이그레이션까지다. + # Your Domain - backend-node/src/controllers/ - backend-node/src/services/ diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md index 57049ce6..575f355f 100644 --- a/.cursor/agents/pipeline-common-rules.md +++ b/.cursor/agents/pipeline-common-rules.md @@ -1,5 +1,79 @@ # WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) +--- + +# !!!! STOP - 작업 시작 전 필수 게이트 (이것을 건너뛰면 모든 작업이 REJECT 된다) !!!! + +## PRE-CHECK GATE: 파일 생성/수정 전 반드시 확인 + +**어떤 에이전트든 파일을 생성하거나 수정하기 전에 반드시 이 게이트를 통과해야 한다.** +**이 게이트를 건너뛰거나 무시한 작업은 전부 REJECT + ROLLBACK 대상이다.** + +### GATE 1: 이 파일을 만들어도 되는가? + +아래 경로에 `.tsx` 페이지 파일을 **절대 생성하지 마라**: +``` +frontend/app/(main)/production/** ← 금지! 사용자 메뉴! +frontend/app/(main)/warehouse/** ← 금지! 사용자 메뉴! +frontend/app/(main)/quality/** ← 금지! 사용자 메뉴! +frontend/app/(main)/logistics/** ← 금지! 사용자 메뉴! +frontend/app/(main)/inventory/** ← 금지! 사용자 메뉴! +frontend/app/(main)/purchase/** ← 금지! 사용자 메뉴! +frontend/app/(main)/sales/** ← 금지! 사용자 메뉴! +frontend/app/(main)/bom/** ← 금지! 사용자 메뉴! +frontend/app/(main)/mold/** ← 금지! 사용자 메뉴! +frontend/app/(main)/packaging/** ← 금지! 사용자 메뉴! +frontend/app/(main)/document/** ← 금지! 사용자 메뉴! +frontend/app/(main)/work/** ← 금지! 사용자 메뉴! +frontend/app/(main)/order/** ← 금지! 사용자 메뉴! +frontend/app/(main)/material/** ← 금지! 사용자 메뉴! +frontend/app/(main)/equipment/** ← 금지! 사용자 메뉴! +frontend/app/(main)/inspection/** ← 금지! 사용자 메뉴! +``` + +**유일하게 React 페이지(.tsx)를 만들 수 있는 경로:** +``` +frontend/app/(main)/admin/** ← 허용! 관리자 메뉴만! +``` + +**판단 로직 (의사코드):** +``` +IF 생성하려는 파일 경로가 "frontend/app/(main)/admin/" 하위가 아니다 + AND 파일이 page.tsx 또는 layout.tsx 또는 React 컴포넌트다 +THEN + !!!! 즉시 중단 !!!! + → 이것은 사용자 메뉴다 + → React 페이지를 만들면 안 된다 + → DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 전환하라 + → pipeline-common-rules.md의 "사용자 메뉴 구현 방법" 섹션을 따르라 +END IF +``` + +### GATE 2: 사용자 메뉴인데 코드로 만들려고 하는가? + +아래 키워드가 요구사항에 포함되어 있으면 **사용자 메뉴**일 가능성이 높다: +- 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비 +- "목록 + 상세" 구조, "좌측 테이블 + 우측 폼" 구조 +- 일반 업무 화면, CRUD 화면 + +**사용자 메뉴라면:** +- .tsx 페이지 파일 생성 → 금지 +- screen_definitions + screen_layouts_v2 + menu_info INSERT → 올바른 방법 +- 백엔드 API(controller/routes)는 필요하면 코드로 작성 가능 +- 프론트엔드 API 클라이언트(lib/api/)도 필요하면 코드로 작성 가능 +- 하지만 **프론트엔드 화면 UI 자체**는 절대 코드로 만들지 않는다! + +### GATE 3: 관리자 메뉴가 맞는가? + +관리자 메뉴는 다음 조건을 **전부** 만족해야 한다: +- 시스템 관리자만 사용하는 기능 (사용자 관리, 권한 관리, 시스템 설정 등) +- URL이 `/admin/*` 패턴 +- `frontend/app/(main)/admin/` 하위에만 page.tsx 생성 + +**이 3가지 게이트를 모두 통과한 후에만 작업을 시작하라.** + +--- + ## 1. 화면 유형 구분 (절대 규칙!) 이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. @@ -7,7 +81,7 @@ ### 관리자 메뉴 (Admin) - **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) -- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` +- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!** - **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) - **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 - **특징**: 하드코딩된 UI, 관리자만 접근 @@ -20,6 +94,7 @@ - **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **특징**: 코드 수정 없이 화면 구성 변경 가능 - **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! +- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업 ### 판단 기준 @@ -166,7 +241,7 @@ VALUES ( - [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) - [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) -## 6. 절대 하지 말 것 +## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT) 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) 2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) @@ -174,9 +249,39 @@ VALUES ( 4. 하드코딩 색상/URL/사용자ID 사용 5. Card 안에 Card 중첩 (중첩 박스 금지) 6. 백엔드 재실행하기 (nodemon이 자동 재시작) -7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** - - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 +7. **[최우선 금지] 사용자 메뉴를 React 하드코딩(.tsx)으로 만들기** + - `frontend/app/(main)/` 하위에서 `/admin/` 이외의 경로에 page.tsx를 만드는 것은 절대 금지 + - 구체적 금지 경로: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ 및 기타 모든 비-admin 경로 - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 + - **위반 발견 시: 해당 라운드 전체 FAIL 처리, 생성된 파일 즉시 삭제, DB 등록 방식으로 처음부터 재작업** + +## 7. 위반 사례 및 올바른 대응 + +### 위반 사례 (실제 발생한 문제) +``` +# 이런 파일을 만들면 절대 안 된다! +frontend/app/(main)/production/packaging/page.tsx ← REJECT! +frontend/app/(main)/warehouse/inventory/page.tsx ← REJECT! +frontend/app/(main)/quality/inspection/page.tsx ← REJECT! +frontend/app/(main)/mold/management/page.tsx ← REJECT! +``` + +### 올바른 대응 +```sql +-- 1. screen_definitions에 등록 +INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) +VALUES ('포장관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y'); + +-- 2. screen_layouts_v2에 V2 레이아웃 JSON 등록 +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) +VALUES ({screen_id}, 'COMPANY_7', 1, '기본 레이어', '{...V2 JSON...}'::jsonb); + +-- 3. menu_info에 메뉴 등록 +INSERT INTO menu_info (..., menu_url, screen_code, ...) +VALUES (..., '/screen/COMPANY_7_PKG', 'COMPANY_7_PKG', ...); +``` + +**React 페이지(.tsx) 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md index 223b5b38..7c8f5a31 100644 --- a/.cursor/agents/pipeline-frontend.md +++ b/.cursor/agents/pipeline-frontend.md @@ -8,6 +8,63 @@ model: inherit You are a Frontend specialist for ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. +--- + +# !!!! STOP - 파일 생성 전 필수 게이트 (반드시 읽고 확인하라) !!!! + +## 파일을 생성하거나 수정하기 전에 반드시 이 체크를 수행하라: + +### CHECK 1: page.tsx를 만들려고 하는가? + +``` +IF 파일 경로가 "frontend/app/(main)/" 하위이다 + AND 파일명이 page.tsx 또는 layout.tsx이다 + AND 경로에 "/admin/"이 포함되어 있지 않다 +THEN + !!!! 즉시 중단 !!!! 이것은 사용자 메뉴다! + → React 페이지를 만들면 안 된다 + → DB 등록 방식으로 전환하라 (screen_definitions + screen_layouts_v2 + menu_info) + → 이 파일의 "올바른 패턴" 섹션을 참조하라 +END IF +``` + +### 금지 경로 목록 (이 경로에 page.tsx 생성 시 즉시 REJECT): +``` +frontend/app/(main)/production/** ← 금지! +frontend/app/(main)/warehouse/** ← 금지! +frontend/app/(main)/quality/** ← 금지! +frontend/app/(main)/logistics/** ← 금지! +frontend/app/(main)/inventory/** ← 금지! +frontend/app/(main)/purchase/** ← 금지! +frontend/app/(main)/sales/** ← 금지! +frontend/app/(main)/bom/** ← 금지! +frontend/app/(main)/mold/** ← 금지! +frontend/app/(main)/packaging/** ← 금지! +frontend/app/(main)/document/** ← 금지! +frontend/app/(main)/work/** ← 금지! +frontend/app/(main)/order/** ← 금지! +frontend/app/(main)/material/** ← 금지! +frontend/app/(main)/equipment/** ← 금지! +frontend/app/(main)/inspection/** ← 금지! +(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지 대상이다!) +``` + +### 유일하게 허용되는 page.tsx 생성 경로: +``` +frontend/app/(main)/admin/** ← 유일하게 허용! +``` + +### CHECK 2: 사용자 메뉴 키워드 감지 + +요구사항에 아래 키워드가 포함되면 사용자 메뉴일 가능성이 높다: +> 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비, 목록+상세, 좌측 테이블+우측 폼, CRUD 화면 + +사용자 메뉴라면 **page.tsx 생성을 절대 하지 말고** DB 등록으로 전환하라. + +**이 게이트를 통과하지 않은 파일 생성은 전부 REJECT 된다.** + +--- + # CRITICAL PROJECT RULES ## 1. API Client (ABSOLUTE RULE!) @@ -49,18 +106,23 @@ export async function getYourData(id: number) { } ``` -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! +--- + +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) **이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** 사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! -## 금지 패턴 (절대 하지 말 것) +## 금지 패턴 (이 파일을 만드는 순간 작업 전체 REJECT) ``` -frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! -frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! +frontend/app/(main)/production/packaging/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/warehouse/something/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/quality/inspection/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/mold/management/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT! ``` -## 올바른 패턴 +## 올바른 패턴 (사용자 메뉴는 DB 등록만으로 완성된다) 사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다: 1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) 2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) @@ -70,17 +132,20 @@ frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 - `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환 - `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 +**React 페이지 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** + ## 프론트엔드 에이전트가 할 수 있는 것 - `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) - V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) -- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능 +- 관리자 메뉴(`/admin/*`)만 React 페이지 코딩 가능 -## 프론트엔드 에이전트가 할 수 없는 것 -- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 +## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT) +- `/admin/` 이외 경로에 page.tsx 생성 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩 # Your Domain - frontend/components/ -- frontend/app/ +- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!) - frontend/lib/ - frontend/hooks/ diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md index 05d3359e..3717b455 100644 --- a/.cursor/agents/pipeline-ui.md +++ b/.cursor/agents/pipeline-ui.md @@ -8,6 +8,43 @@ model: inherit You are a UI/UX Design specialist for the ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. +--- + +# !!!! STOP - 파일 생성/수정 전 필수 게이트 !!!! + +## 파일을 만들거나 수정하기 전에 반드시 확인하라: + +**page.tsx를 생성하려는 경로가 `frontend/app/(main)/admin/` 하위인가?** +- YES → 진행 가능 +- NO → **즉시 중단!** 사용자 메뉴는 React 페이지로 만들지 않는다! + +**금지 경로 (이 경로에 page.tsx 생성 시 즉시 REJECT):** +``` +frontend/app/(main)/production/** ← 금지! +frontend/app/(main)/warehouse/** ← 금지! +frontend/app/(main)/quality/** ← 금지! +frontend/app/(main)/logistics/** ← 금지! +frontend/app/(main)/inventory/** ← 금지! +frontend/app/(main)/purchase/** ← 금지! +frontend/app/(main)/sales/** ← 금지! +frontend/app/(main)/bom/** ← 금지! +frontend/app/(main)/mold/** ← 금지! +frontend/app/(main)/packaging/** ← 금지! +frontend/app/(main)/document/** ← 금지! +frontend/app/(main)/work/** ← 금지! +frontend/app/(main)/order/** ← 금지! +frontend/app/(main)/material/** ← 금지! +frontend/app/(main)/equipment/** ← 금지! +frontend/app/(main)/inspection/** ← 금지! +(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지!) +``` + +**사용자 메뉴 화면은 DB 등록(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.** + +**이 게이트를 무시하면 작업 전체 REJECT + 파일 삭제 + 재작업 대상이다.** + +--- + # Design Philosophy - Apple-level polish with enterprise functionality - Consistent spacing, typography, color usage @@ -39,22 +76,23 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black - Use cn() for conditional classes - Use lucide-react for ALL icons -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) 사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! -UI 에이전트가 할 수 있는 것: +## UI 에이전트가 할 수 있는 것 - V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) - 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 - 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 -UI 에이전트가 할 수 없는 것: +## UI 에이전트가 할 수 없는 것 (위반 시 REJECT) +- `/admin/` 이외 경로에 page.tsx 생성 또는 수정 - 사용자 메뉴 화면을 React 페이지로 직접 코딩 # Your Domain - frontend/components/ (UI components) -- frontend/app/ (pages - 관리자 메뉴만) +- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**) - frontend/lib/registry/components/v2-*/ (V2 컴포넌트) # Output Rules diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md index a4f4186d..4030eb93 100644 --- a/.cursor/agents/pipeline-verifier.md +++ b/.cursor/agents/pipeline-verifier.md @@ -1,6 +1,6 @@ --- name: pipeline-verifier -description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지. model: fast readonly: true --- @@ -11,6 +11,29 @@ Your job is to verify that work claimed as complete actually works. # Verification Checklist +## 0. 하드코딩 페이지 탐지 (최최우선! 이것부터 먼저 확인!) + +**이 프로젝트에서 가장 심각한 위반은 사용자 메뉴를 React 페이지(.tsx)로 하드코딩하는 것이다.** +검증 시 반드시 아래를 제일 먼저 확인하라: + +- [ ] `frontend/app/(main)/` 하위에 `/admin/` 이외의 경로에 새로운 page.tsx가 생성되지 않았는가? +- [ ] 구체적 금지 경로 확인: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ +- [ ] 위 경로뿐 아니라 `/admin/` 이외의 **모든** 경로에 page.tsx가 새로 생성되었는지 확인 +- [ ] 사용자 메뉴 화면이 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 구현되었는가? + +**검증 방법:** +```bash +# 이 라운드에서 새로 생성된 파일 중 금지 경로의 page.tsx가 있는지 확인 +git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep -v "/admin/" +# 결과가 있으면 → 즉시 FAIL! +``` + +**위반 발견 시:** +- 검증 결과: **CRITICAL FAIL** +- 해당 파일 삭제 필수 +- DB 등록 방식으로 재작업 지시 +- 이 위반이 있으면 다른 항목 전부 PASS여도 최종 결과는 FAIL + ## 1. Multi-tenancy (최우선) - [ ] 모든 SQL에 company_code 필터 존재 - [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) @@ -28,6 +51,7 @@ Your job is to verify that work claimed as complete actually works. - [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) - [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) - [ ] Frontend: V2 컴포넌트 규격 준수 +- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!) - [ ] Backend: logger 사용 - [ ] Backend: try/catch 에러 처리 @@ -39,7 +63,10 @@ Your job is to verify that work claimed as complete actually works. # Reporting Format ``` -## 검증 결과: [PASS/FAIL] +## 검증 결과: [PASS/FAIL/CRITICAL FAIL] + +### [CRITICAL] 하드코딩 페이지 탐지 +- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)") ### 통과 항목 - item 1 @@ -55,3 +82,4 @@ Your job is to verify that work claimed as complete actually works. ``` Do not accept claims at face value. Check the actual code. +하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 FAIL이면 전체 FAIL이다. diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4c5bdc57..64b1dff0 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,19 +2346,24 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) + * 메뉴별 화면 목록 조회 + * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 + * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.* FROM screen_menu_assignments sma + `SELECT sd.*, sma.company_code AS assign_company_code + FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND sma.company_code = $2 + AND (sma.company_code = $2 OR sma.company_code = '*') AND sma.is_active = 'Y' - ORDER BY sma.display_order ASC`, + ORDER BY + CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, + sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 20175b5e..62280f9d 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; +import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; +import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -10,70 +12,320 @@ const LoadingFallback = () => (
); +const d = (loader: () => Promise) => + dynamic(loader, { ssr: false, loading: LoadingFallback }); + +/** + * /dashboard/[dashboardId] URL을 탭 내에서 직접 렌더링 + * Next.js params Promise 없이 dashboardId를 직접 전달 + */ +const LazyDashboardViewer = d(() => import("@/components/dashboard/DashboardViewer").then((mod) => ({ + default: mod.DashboardViewer, +}))); + +function DashboardTabRenderer({ dashboardId }: { dashboardId: string }) { + const [dashboard, setDashboard] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const load = async () => { + setIsLoading(true); + try { + const { dashboardApi } = await import("@/lib/api/dashboard"); + const data = await dashboardApi.getDashboard(dashboardId); + setDashboard({ ...data, elements: data.elements || [] }); + } catch { + const saved = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); + const found = saved.find((d: any) => d.id === dashboardId); + if (found) { + setDashboard(found); + } else { + setError("대시보드를 찾을 수 없습니다"); + } + } finally { + setIsLoading(false); + } + }; + load(); + }, [dashboardId]); + + if (isLoading) return ; + + if (error || !dashboard) { + return ( +
+
+

{error || "대시보드를 찾을 수 없습니다"}

+

대시보드 ID: {dashboardId}

+
+
+ ); + } + + return ( +
+ +
+ ); +} + +/** + * /screen/[screenCode] URL을 screenId로 변환해서 ScreenViewPageWrapper를 렌더링 + */ +function ScreenCodeResolver({ screenCode }: { screenCode: string }) { + const [screenId, setScreenId] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + const numericId = parseInt(screenCode); + if (!isNaN(numericId)) { + setScreenId(numericId); + return; + } + + const resolve = async () => { + try { + const res = await apiClient.get("/screen-management/screens", { + params: { searchTerm: screenCode, size: 50 }, + }); + const items = res.data?.data?.data || res.data?.data || []; + const arr = Array.isArray(items) ? items : []; + const exact = arr.find((s: any) => s.screenCode === screenCode || s.screen_code === screenCode); + const target = exact || arr[0]; + if (target) { + setScreenId(target.screenId || target.screen_id); + } else { + setError(true); + } + } catch { + setError(true); + } + }; + resolve(); + }, [screenCode]); + + if (error) { + return ( +
+
+

화면을 찾을 수 없습니다

+

+ 화면 코드: {screenCode} +

+
+
+ ); + } + + if (screenId === null) { + return ; + } + + return ; +} + /** * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. - * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. + * 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다. */ const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 - "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), + "/admin": d(() => import("@/app/(main)/admin/page")), // 메뉴 관리 - "/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }), + "/admin/menu": d(() => import("@/app/(main)/admin/menu/page")), // 사용자 관리 - "/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")), + "/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")), + "/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")), + "/admin/userMng/companyList": d(() => import("@/app/(main)/admin/userMng/companyList/page")), // 화면 관리 - "/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")), + "/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")), + "/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")), + "/admin/screenMng/reportList": d(() => import("@/app/(main)/admin/screenMng/reportList/page")), + "/admin/screenMng/barcodeList": d(() => import("@/app/(main)/admin/screenMng/barcodeList/page")), // 시스템 관리 - "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")), + "/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")), + "/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")), + "/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")), + "/admin/systemMng/dataflow": d(() => import("@/app/(main)/admin/systemMng/dataflow/page")), + "/admin/systemMng/dataflow/node-editorList": d(() => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page")), // 자동화 관리 - "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")), + "/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")), + "/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")), + "/admin/automaticMng/exCallConfList": d(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page")), // 메일 - "/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")), + "/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")), + "/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")), + "/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")), + "/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")), + "/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")), + "/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")), + "/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")), + "/admin/automaticMng/mail/bulk-send": d(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page")), // 배치 관리 - "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), + "/admin/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")), + "/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")), - // 기타 - "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), - "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), - "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), - "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), - "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), - "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), + // 결재 관리 + "/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")), + "/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")), + "/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")), + + // AI 어시스턴트 + "/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")), + "/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")), + "/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")), + "/admin/aiAssistant/api-keys": d(() => import("@/app/(main)/admin/aiAssistant/api-keys/page")), + "/admin/aiAssistant/dashboard": d(() => import("@/app/(main)/admin/aiAssistant/dashboard/page")), + "/admin/aiAssistant/chat": d(() => import("@/app/(main)/admin/aiAssistant/chat/page")), + "/admin/aiAssistant/api-test": d(() => import("@/app/(main)/admin/aiAssistant/api-test/page")), + + // 기타 관리 + "/admin/cascading-management": d(() => import("@/app/(main)/admin/cascading-management/page")), + "/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")), + "/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")), + "/admin/templates": d(() => import("@/app/(main)/admin/templates/page")), + "/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")), + "/admin/standards": d(() => import("@/app/(main)/admin/standards/page")), + "/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")), + "/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")), + "/admin/system-notices": d(() => import("@/app/(main)/admin/system-notices/page")), + "/admin/audit-log": d(() => import("@/app/(main)/admin/audit-log/page")), + + // 개발/테스트 + "/admin/debug": d(() => import("@/app/(main)/admin/debug/page")), + "/admin/debug-simple": d(() => import("@/app/(main)/admin/debug-simple/page")), + "/admin/debug-layout": d(() => import("@/app/(main)/admin/debug-layout/page")), + "/admin/test": d(() => import("@/app/(main)/admin/test/page")), + "/admin/ui-components-demo": d(() => import("@/app/(main)/admin/ui-components-demo/page")), + "/admin/validation-demo": d(() => import("@/app/(main)/admin/validation-demo/page")), + "/admin/token-test": d(() => import("@/app/(main)/admin/token-test/page")), + + // === 사용자 화면 (admin이 아닌 URL 기반 메뉴) === + "/approval": d(() => import("@/app/(main)/approval/page")), + "/dashboard": d(() => import("@/app/(main)/dashboard/page")), + "/multilang": d(() => import("@/app/(main)/multilang/page")), + "/test-flow": d(() => import("@/app/(main)/test-flow/page")), + "/main": d(() => import("@/app/(main)/main/page")), }; -// 매핑되지 않은 URL용 Fallback +/** + * 동적 라우트 패턴 매칭 (URL 경로에 동적 세그먼트가 포함된 경우) + * /admin/screenMng/dashboardList/123 → dashboardList/[id] 페이지에 매핑 + * + * extractParams: URL에서 동적 파라미터를 추출 (use(params)를 쓰는 페이지용) + * 추출된 값은 params={Promise.resolve(...)}로 전달되어 + * Next.js 라우팅 컨텍스트 없이도 use(params)가 정상 동작함 + */ +interface DynamicRouteEntry { + pattern: RegExp; + loader: () => Promise; + extractParams?: (url: string) => Record; +} + +const DYNAMIC_ROUTE_PATTERNS: DynamicRouteEntry[] = [ + { + pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, + loader: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), + extractParams: (url) => ({ companyCode: url.split("/")[4] }), + }, + { + pattern: /^\/admin\/automaticMng\/batchmngList\/create$/, + loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), + }, + { + pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/standards\/new$/, + loader: () => import("@/app/(main)/admin/standards/new/page"), + }, + { + pattern: /^\/admin\/standards\/([^/]+)\/edit$/, + loader: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), + extractParams: (url) => ({ webType: url.split("/")[3] }), + }, + { + pattern: /^\/admin\/standards\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/standards/[webType]/page"), + extractParams: (url) => ({ webType: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), + extractParams: (url) => ({ diagramId: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"), + extractParams: (url) => ({ labelId: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"), + extractParams: (url) => ({ reportId: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, +]; + +interface DynamicRouteResult { + component: React.ComponentType; + params?: Record; +} + +const dynamicRouteCache = new Map(); + +function resolveDynamicRoute(cleanUrl: string): DynamicRouteResult | null { + if (dynamicRouteCache.has(cleanUrl)) { + return dynamicRouteCache.get(cleanUrl)!; + } + + for (const entry of DYNAMIC_ROUTE_PATTERNS) { + if (entry.pattern.test(cleanUrl)) { + const comp = d(entry.loader); + const params = entry.extractParams?.(cleanUrl); + const result: DynamicRouteResult = { component: comp, params }; + dynamicRouteCache.set(cleanUrl, result); + return result; + } + } + return null; +} + function AdminPageFallback({ url }: { url: string }) { return (
@@ -95,15 +347,55 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const PageComponent = useMemo(() => { - // URL에서 쿼리스트링/해시 제거 후 매칭 - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - return ADMIN_PAGE_REGISTRY[cleanUrl] || null; - }, [url]); + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - if (!PageComponent) { + // 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링 + // 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달 + const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); + if (screenIdMatch) { + const screenId = parseInt(screenIdMatch[1]); + return ; + } + + // 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링 + const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); + if (screenCodeMatch) { + return ; + } + + // 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링 + // Next.js의 params Promise를 우회하여 dashboardId를 직접 전달 + const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); + if (dashboardMatch) { + return ; + } + + const resolved = useMemo(() => { + // 1) 정적 레지스트리 매칭 + if (ADMIN_PAGE_REGISTRY[cleanUrl]) { + return { component: ADMIN_PAGE_REGISTRY[cleanUrl] } as DynamicRouteResult; + } + + // 2) 동적 라우트 패턴 매칭 (/admin/xxx/[id] 등) + const dynamicMatch = resolveDynamicRoute(cleanUrl); + if (dynamicMatch) { + return dynamicMatch; + } + + return null; + }, [cleanUrl]); + + if (!resolved) { return ; } + const { component: PageComponent, params } = resolved; + + // 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달 + // Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨 + if (params) { + return ; + } + return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index ad9a6aaf..2fe934a4 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -362,8 +362,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { if (isMobile) setSidebarOpen(false); return; } - } catch { - console.warn("할당된 화면 조회 실패"); + } catch (err) { + console.error("할당된 화면 조회 실패:", err); + toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요."); + return; } if (menu.url && menu.url !== "#") {