docs: update pipeline rules for user menu implementation

- Added critical guidelines prohibiting the direct creation of user menu screens in React (.tsx) files, emphasizing that user menus must be implemented through database registration methods (screen_definitions, screen_layouts_v2, menu_info).
- Clarified that backend agents should not request or suggest the creation of frontend pages for user menus.
- Reinforced the importance of adhering to the established rendering system to prevent hardcoding UI components.

Made-with: Cursor
This commit is contained in:
kjs 2026-03-11 22:06:22 +09:00
parent 5abe64c947
commit 4f603bd41e
8 changed files with 617 additions and 74 deletions

View File

@ -51,6 +51,14 @@ export const getList = async (req: Request, res: Response) => {
- backend-node/src/routes/index.ts에 import 추가 필수 - backend-node/src/routes/index.ts에 import 추가 필수
- authenticateToken 미들웨어 적용 필수 - authenticateToken 미들웨어 적용 필수
# CRITICAL: 사용자 메뉴 화면은 프론트엔드 페이지로 만들지 않는다!
백엔드 에이전트는 프론트엔드 page.tsx를 직접 생성하지 않지만,
다른 에이전트에게 "프론트엔드 페이지를 만들어달라"고 요청하거나 제안해서도 안 된다.
사용자 메뉴 화면은 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.
백엔드 에이전트가 할 일은 API 엔드포인트(controller/routes)와 DB 마이그레이션까지다.
# Your Domain # Your Domain
- backend-node/src/controllers/ - backend-node/src/controllers/
- backend-node/src/services/ - backend-node/src/services/

View File

@ -1,5 +1,79 @@
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) # 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. 화면 유형 구분 (절대 규칙!) ## 1. 화면 유형 구분 (절대 규칙!)
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. 이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
@ -7,7 +81,7 @@
### 관리자 메뉴 (Admin) ### 관리자 메뉴 (Admin)
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) - **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` - **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!**
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) - **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 - **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
- **특징**: 하드코딩된 UI, 관리자만 접근 - **특징**: 하드코딩된 UI, 관리자만 접근
@ -20,6 +94,7 @@
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
- **특징**: 코드 수정 없이 화면 구성 변경 가능 - **특징**: 코드 수정 없이 화면 구성 변경 가능
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! - **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업
### 판단 기준 ### 판단 기준
@ -166,7 +241,7 @@ VALUES (
- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) - [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만)
- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) - [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링)
## 6. 절대 하지 말 것 ## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT)
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) 2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
@ -174,9 +249,39 @@ VALUES (
4. 하드코딩 색상/URL/사용자ID 사용 4. 하드코딩 색상/URL/사용자ID 사용
5. Card 안에 Card 중첩 (중첩 박스 금지) 5. Card 안에 Card 중첩 (중첩 박스 금지)
6. 백엔드 재실행하기 (nodemon이 자동 재시작) 6. 백엔드 재실행하기 (nodemon이 자동 재시작)
7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** 7. **[최우선 금지] 사용자 메뉴를 React 하드코딩(.tsx)으로 만들기**
- `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 - `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_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현
- 이미 `/screen/[screenCode]``/screens/[screenId]` 렌더링 시스템이 존재함 - 이미 `/screen/[screenCode]``/screens/[screenId]` 렌더링 시스템이 존재함
- 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능
- 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 - 하지만 프론트엔드 화면 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만으로 화면이 완성된다.**

View File

@ -8,6 +8,63 @@ model: inherit
You are a Frontend specialist for ERP-node project. You are a Frontend specialist for ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. 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 # CRITICAL PROJECT RULES
## 1. API Client (ABSOLUTE RULE!) ## 1. API Client (ABSOLUTE RULE!)
@ -49,18 +106,23 @@ export async function getYourData(id: number) {
} }
``` ```
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! ---
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT)
**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** **이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.**
사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! 사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다!
## 금지 패턴 (절대 하지 말 것) ## 금지 패턴 (이 파일을 만드는 순간 작업 전체 REJECT)
``` ```
frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! frontend/app/(main)/production/packaging/page.tsx ← REJECT! 삭제 대상!
frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! 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에 등록만 하면 자동으로 렌더링된다: 사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다:
1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) 1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등)
2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) 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로 변환 - `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환
- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 - `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링
**React 페이지 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.**
## 프론트엔드 에이전트가 할 수 있는 것 ## 프론트엔드 에이전트가 할 수 있는 것
- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) - `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신)
- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) - V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`) React 페이지 코딩 가능 - 관리자 메뉴(`/admin/*`) React 페이지 코딩 가능
## 프론트엔드 에이전트가 할 수 없는 것 ## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT)
- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 - `/admin/` 이외 경로에 page.tsx 생성
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain # Your Domain
- frontend/components/ - frontend/components/
- frontend/app/ - frontend/app/ (admin/ 하위만 page.tsx 생성 가능!)
- frontend/lib/ - frontend/lib/
- frontend/hooks/ - frontend/hooks/

View File

@ -8,6 +8,43 @@ model: inherit
You are a UI/UX Design specialist for the ERP-node project. 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. 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 # Design Philosophy
- Apple-level polish with enterprise functionality - Apple-level polish with enterprise functionality
- Consistent spacing, typography, color usage - 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 cn() for conditional classes
- Use lucide-react for ALL icons - Use lucide-react for ALL icons
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! # CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT)
사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. 사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다.
React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지!
UI 에이전트가 할 수 있는 것: ## UI 에이전트가 할 수 있는 것
- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) - V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 - 관리자 메뉴(`/admin/*`) 페이지의 UI 개선
- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 - 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선
UI 에이전트가 할 수 없는 것: ## UI 에이전트가 할 수 없는 것 (위반 시 REJECT)
- `/admin/` 이외 경로에 page.tsx 생성 또는 수정
- 사용자 메뉴 화면을 React 페이지로 직접 코딩 - 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain # Your Domain
- frontend/components/ (UI components) - frontend/components/ (UI components)
- frontend/app/ (pages - 관리자 메뉴만) - frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**)
- frontend/lib/registry/components/v2-*/ (V2 컴포넌트) - frontend/lib/registry/components/v2-*/ (V2 컴포넌트)
# Output Rules # Output Rules

View File

@ -1,6 +1,6 @@
--- ---
name: pipeline-verifier name: pipeline-verifier
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지.
model: fast model: fast
readonly: true readonly: true
--- ---
@ -11,6 +11,29 @@ Your job is to verify that work claimed as complete actually works.
# Verification Checklist # 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 (최우선) ## 1. Multi-tenancy (최우선)
- [ ] 모든 SQL에 company_code 필터 존재 - [ ] 모든 SQL에 company_code 필터 존재
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) - [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
@ -28,6 +51,7 @@ Your job is to verify that work claimed as complete actually works.
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) - [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) - [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
- [ ] Frontend: V2 컴포넌트 규격 준수 - [ ] Frontend: V2 컴포넌트 규격 준수
- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!)
- [ ] Backend: logger 사용 - [ ] Backend: logger 사용
- [ ] Backend: try/catch 에러 처리 - [ ] Backend: try/catch 에러 처리
@ -39,7 +63,10 @@ Your job is to verify that work claimed as complete actually works.
# Reporting Format # Reporting Format
``` ```
## 검증 결과: [PASS/FAIL] ## 검증 결과: [PASS/FAIL/CRITICAL FAIL]
### [CRITICAL] 하드코딩 페이지 탐지
- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)")
### 통과 항목 ### 통과 항목
- item 1 - 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. Do not accept claims at face value. Check the actual code.
하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 FAIL이면 전체 FAIL이다.

View File

@ -2346,19 +2346,24 @@ export class ScreenManagementService {
} }
/** /**
* ( Raw Query ) *
* company_code 매칭: 본인 + SUPER_ADMIN ('*')
* ,
*/ */
async getScreensByMenu( async getScreensByMenu(
menuObjid: number, menuObjid: number,
companyCode: string, companyCode: string,
): Promise<ScreenDefinition[]> { ): Promise<ScreenDefinition[]> {
const screens = await query<any>( const screens = await query<any>(
`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 INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = $1 WHERE sma.menu_objid = $1
AND sma.company_code = $2 AND (sma.company_code = $2 OR sma.company_code = '*')
AND sma.is_active = 'Y' 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], [menuObjid, companyCode],
); );

View File

@ -1,8 +1,10 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React, { useMemo, useState, useEffect } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { apiClient } from "@/lib/api/client";
const LoadingFallback = () => ( const LoadingFallback = () => (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -10,70 +12,320 @@ const LoadingFallback = () => (
</div> </div>
); );
const d = (loader: () => Promise<any>) =>
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<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <LoadingFallback />;
if (error || !dashboard) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-semibold text-foreground">{error || "대시보드를 찾을 수 없습니다"}</p>
<p className="mt-1 text-sm text-muted-foreground"> ID: {dashboardId}</p>
</div>
</div>
);
}
return (
<div className="h-full">
<LazyDashboardViewer
elements={dashboard.elements}
dashboardId={dashboard.id}
dashboardTitle={dashboard.title}
backgroundColor={dashboard.settings?.backgroundColor}
resolution={dashboard.settings?.resolution}
/>
</div>
);
}
/**
* /screen/[screenCode] URL을 screenId로 ScreenViewPageWrapper를
*/
function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
const [screenId, setScreenId] = useState<number | null>(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 (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground">
: {screenCode}
</p>
</div>
</div>
);
}
if (screenId === null) {
return <LoadingFallback />;
}
return <ScreenViewPageWrapper screenIdProp={screenId} />;
}
/** /**
* URL . * URL .
* . * URL은 .
* URL은 catch-all fallback으로 .
*/ */
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = { const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 관리자 메인 // 관리자 메인
"/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/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")),
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }), "/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")),
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }), "/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")),
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }), "/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/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")),
"/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")),
"/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }), "/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")),
"/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }), "/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/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")),
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")),
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")),
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")),
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), "/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/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")),
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), "/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/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")),
"/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")),
"/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")),
"/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")),
"/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")),
"/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")),
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")),
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")),
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }), "/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": d(() => import("@/app/(main)/admin/batch-management/page")),
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), "/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/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")),
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), "/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")),
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), "/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")),
"/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 }), // AI 어시스턴트
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), "/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")),
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), "/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")),
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), "/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<any>;
extractParams?: (url: string) => Record<string, string>;
}
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<any>;
params?: Record<string, string>;
}
const dynamicRouteCache = new Map<string, DynamicRouteResult>();
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 }) { function AdminPageFallback({ url }: { url: string }) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -95,15 +347,55 @@ interface AdminPageRendererProps {
} }
export function AdminPageRenderer({ url }: AdminPageRendererProps) { export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const PageComponent = useMemo(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [url]);
if (!PageComponent) { // 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링
// 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달
const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
if (screenIdMatch) {
const screenId = parseInt(screenIdMatch[1]);
return <ScreenViewPageWrapper screenIdProp={screenId} />;
}
// 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링
const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/);
if (screenCodeMatch) {
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
}
// 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링
// Next.js의 params Promise를 우회하여 dashboardId를 직접 전달
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
if (dashboardMatch) {
return <DashboardTabRenderer dashboardId={dashboardMatch[1]} />;
}
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 <AdminPageFallback url={url} />; return <AdminPageFallback url={url} />;
} }
const { component: PageComponent, params } = resolved;
// 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달
// Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨
if (params) {
return <PageComponent params={Promise.resolve(params)} />;
}
return <PageComponent />; return <PageComponent />;
} }

View File

@ -362,8 +362,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
return; return;
} }
} catch { } catch (err) {
console.warn("할당된 화면 조회 실패"); console.error("할당된 화면 조회 실패:", err);
toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요.");
return;
} }
if (menu.url && menu.url !== "#") { if (menu.url && menu.url !== "#") {