revert: 파이프라인 커밋 롤백 (직접 구현으로 전환)

- 1b2d42ff: packagingController.ts, packagingRoutes.ts 롤백
- 4f603bd4: pipeline rules 강화, AdminPageRenderer 롤백

Made-with: Cursor
This commit is contained in:
kjs 2026-03-11 23:11:07 +09:00
parent 1b2d42ffc5
commit 7269867d91
10 changed files with 75 additions and 1653 deletions

View File

@ -51,14 +51,6 @@ 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,79 +1,5 @@
# 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. 화면 유형 구분 (절대 규칙!)
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. 이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
@ -81,7 +7,7 @@ END IF
### 관리자 메뉴 (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, 관리자만 접근
@ -94,7 +20,6 @@ END IF
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
- **특징**: 코드 수정 없이 화면 구성 변경 가능 - **특징**: 코드 수정 없이 화면 구성 변경 가능
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! - **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업
### 판단 기준 ### 판단 기준
@ -241,7 +166,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. 절대 하지 말 것 (위반 시 전체 작업 REJECT) ## 6. 절대 하지 말 것
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) 2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
@ -249,39 +174,9 @@ 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)/` 하위에서 `/admin/` 이외의 경로에 page.tsx를 만드는 것은 절대 금지 - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지
- 구체적 금지 경로: 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,63 +8,6 @@ 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!)
@ -106,23 +49,18 @@ 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 ← REJECT! 삭제 대상! frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라!
frontend/app/(main)/warehouse/something/page.tsx ← REJECT! 삭제 대상! frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라!
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 등)
@ -132,20 +70,17 @@ frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT!
- `/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) ## 프론트엔드 에이전트가 할 수 없는 것
- `/admin/` 이외 경로에 page.tsx 생성 - 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain # Your Domain
- frontend/components/ - frontend/components/
- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!) - frontend/app/
- frontend/lib/ - frontend/lib/
- frontend/hooks/ - frontend/hooks/

View File

@ -8,43 +8,6 @@ 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
@ -76,23 +39,22 @@ 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: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) # CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
사용자 업무 화면(포장관리, 금형관리, 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 에이전트가 할 수 없는 것 (위반 시 REJECT) UI 에이전트가 할 수 없는 것:
- `/admin/` 이외 경로에 page.tsx 생성 또는 수정
- 사용자 메뉴 화면을 React 페이지로 직접 코딩 - 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain # Your Domain
- frontend/components/ (UI components) - frontend/components/ (UI components)
- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**) - frontend/app/ (pages - 관리자 메뉴만)
- 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,29 +11,6 @@ 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 사용 (클라이언트 입력 아님)
@ -51,7 +28,6 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep
- [ ] 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 에러 처리
@ -63,10 +39,7 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep
# Reporting Format # Reporting Format
``` ```
## 검증 결과: [PASS/FAIL/CRITICAL FAIL] ## 검증 결과: [PASS/FAIL]
### [CRITICAL] 하드코딩 페이지 탐지
- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)")
### 통과 항목 ### 통과 항목
- item 1 - item 1
@ -82,4 +55,3 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep
``` ```
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

@ -1,995 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, transaction } from "../database/db";
import { logger } from "../utils/logger";
// ============================================================
// 포장단위(pkg_unit) CRUD
// ============================================================
/**
*
* GET /api/packaging/pkg-units
*/
export const getPkgUnits = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { search, pkg_type, status } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`pu.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (search && typeof search === "string" && search.trim()) {
conditions.push(
`(pu.pkg_code ILIKE $${paramIndex} OR pu.pkg_name ILIKE $${paramIndex})`
);
params.push(`%${search.trim()}%`);
paramIndex++;
}
if (pkg_type && typeof pkg_type === "string" && pkg_type.trim()) {
conditions.push(`pu.pkg_type = $${paramIndex}`);
params.push(pkg_type.trim());
paramIndex++;
}
if (status && typeof status === "string" && status.trim()) {
conditions.push(`pu.status = $${paramIndex}`);
params.push(status.trim());
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT
pu.*,
(SELECT COUNT(*) FROM pkg_unit_item pui
WHERE pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code
) AS item_count
FROM pkg_unit pu
${whereClause}
ORDER BY pu.created_date DESC
`;
const rows = await query(sql, params);
logger.info("포장단위 목록 조회 성공", {
companyCode,
count: rows.length,
});
res.json({ success: true, data: rows });
} catch (error: any) {
logger.error("포장단위 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* GET /api/packaging/pkg-units/:id
*/
export const getPkgUnitById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM pkg_unit WHERE id = $1`;
params = [id];
} else {
sql = `SELECT * FROM pkg_unit WHERE id = $1 AND company_code = $2`;
params = [id, companyCode];
}
const rows = await query(sql, params);
if (rows.length === 0) {
res.status(404).json({
success: false,
message: "포장단위를 찾을 수 없습니다.",
});
return;
}
res.json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("포장단위 상세 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* POST /api/packaging/pkg-units
*/
export const createPkgUnit = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
pkg_code,
pkg_name,
pkg_type,
status,
width_mm,
length_mm,
height_mm,
self_weight_kg,
max_load_kg,
volume_l,
remarks,
} = req.body;
if (!pkg_code || !pkg_name) {
res.status(400).json({
success: false,
message: "포장코드(pkg_code)와 포장명(pkg_name)은 필수입니다.",
});
return;
}
// 중복 체크
const dupCheck = await query(
`SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`,
[pkg_code, companyCode]
);
if (dupCheck.length > 0) {
res.status(409).json({
success: false,
message: `포장코드 '${pkg_code}'가 이미 존재합니다.`,
});
return;
}
const sql = `
INSERT INTO pkg_unit
(company_code, pkg_code, pkg_name, pkg_type, status,
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *
`;
const rows = await query(sql, [
companyCode,
pkg_code,
pkg_name,
pkg_type || null,
status || "ACTIVE",
width_mm || null,
length_mm || null,
height_mm || null,
self_weight_kg || null,
max_load_kg || null,
volume_l || null,
remarks || null,
userId,
]);
logger.info("포장단위 등록 성공", { companyCode, pkg_code });
res.status(201).json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("포장단위 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* PUT /api/packaging/pkg-units/:id
*/
export const updatePkgUnit = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
pkg_name,
pkg_type,
status,
width_mm,
length_mm,
height_mm,
self_weight_kg,
max_load_kg,
volume_l,
remarks,
} = req.body;
const setClauses: string[] = ["updated_date = NOW()", "writer = $1"];
const params: any[] = [userId];
let paramIndex = 2;
const fieldMap: Record<string, any> = {
pkg_name,
pkg_type,
status,
width_mm,
length_mm,
height_mm,
self_weight_kg,
max_load_kg,
volume_l,
remarks,
};
for (const [col, val] of Object.entries(fieldMap)) {
if (val !== undefined) {
setClauses.push(`${col} = $${paramIndex}`);
params.push(val);
paramIndex++;
}
}
// WHERE: id + company_code
params.push(id);
const idIdx = paramIndex;
paramIndex++;
let whereClause: string;
if (companyCode === "*") {
whereClause = `WHERE id = $${idIdx}`;
} else {
params.push(companyCode);
whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`;
}
const sql = `
UPDATE pkg_unit
SET ${setClauses.join(", ")}
${whereClause}
RETURNING *
`;
const rows = await query(sql, params);
if (rows.length === 0) {
res.status(404).json({
success: false,
message: "포장단위를 찾을 수 없거나 권한이 없습니다.",
});
return;
}
logger.info("포장단위 수정 성공", { companyCode, id });
res.json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("포장단위 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* DELETE /api/packaging/pkg-units/:id
*/
export const deletePkgUnit = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
// 트랜잭션으로 관련 데이터 함께 삭제
const result = await transaction(async (client) => {
// 삭제 대상의 pkg_code 조회 (관계 데이터 삭제용)
let findSql: string;
let findParams: any[];
if (companyCode === "*") {
findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1`;
findParams = [id];
} else {
findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1 AND company_code = $2`;
findParams = [id, companyCode];
}
const found = await client.query(findSql, findParams);
if (found.rowCount === 0) return null;
const { pkg_code, company_code: targetCompany } = found.rows[0];
// 매칭품목 먼저 삭제
await client.query(
`DELETE FROM pkg_unit_item WHERE pkg_code = $1 AND company_code = $2`,
[pkg_code, targetCompany]
);
// 적재함 포장구성에서 참조 삭제
await client.query(
`DELETE FROM loading_unit_pkg WHERE pkg_code = $1 AND company_code = $2`,
[pkg_code, targetCompany]
);
// 포장단위 삭제
const del = await client.query(
`DELETE FROM pkg_unit WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, targetCompany]
);
return del.rows[0];
});
if (!result) {
res.status(404).json({
success: false,
message: "포장단위를 찾을 수 없거나 권한이 없습니다.",
});
return;
}
logger.info("포장단위 삭제 성공", { companyCode, id });
res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("포장단위 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
// ============================================================
// 포장단위 매칭품목(pkg_unit_item) N:M
// ============================================================
/**
* ( )
* GET /api/packaging/pkg-unit-items?pkg_code=XXX
*/
export const getPkgUnitItems = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { pkg_code } = req.query;
if (!pkg_code) {
res.status(400).json({
success: false,
message: "pkg_code 파라미터가 필요합니다.",
});
return;
}
const conditions: string[] = [`pui.pkg_code = $1`];
const params: any[] = [pkg_code];
let paramIndex = 2;
if (companyCode !== "*") {
conditions.push(`pui.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
const sql = `
SELECT pui.*
FROM pkg_unit_item pui
WHERE ${conditions.join(" AND ")}
ORDER BY pui.created_date DESC
`;
const rows = await query(sql, params);
logger.info("매칭품목 조회 성공", { companyCode, pkg_code, count: rows.length });
res.json({ success: true, data: rows });
} catch (error: any) {
logger.error("매칭품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* POST /api/packaging/pkg-unit-items
*/
export const createPkgUnitItem = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { pkg_code, item_number, pkg_qty } = req.body;
if (!pkg_code || !item_number) {
res.status(400).json({
success: false,
message: "pkg_code와 item_number는 필수입니다.",
});
return;
}
// 중복 체크
const dup = await query(
`SELECT id FROM pkg_unit_item WHERE pkg_code = $1 AND item_number = $2 AND company_code = $3`,
[pkg_code, item_number, companyCode]
);
if (dup.length > 0) {
res.status(409).json({
success: false,
message: `이미 매칭된 품목입니다: ${item_number}`,
});
return;
}
const sql = `
INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`;
const rows = await query(sql, [
companyCode,
pkg_code,
item_number,
pkg_qty || null,
userId,
]);
logger.info("매칭품목 추가 성공", { companyCode, pkg_code, item_number });
res.status(201).json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("매칭품목 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* DELETE /api/packaging/pkg-unit-items/:id
*/
export const deletePkgUnitItem = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `DELETE FROM pkg_unit_item WHERE id = $1 RETURNING id`;
params = [id];
} else {
sql = `DELETE FROM pkg_unit_item WHERE id = $1 AND company_code = $2 RETURNING id`;
params = [id, companyCode];
}
const rows = await query(sql, params);
if (rows.length === 0) {
res.status(404).json({
success: false,
message: "매칭품목을 찾을 수 없거나 권한이 없습니다.",
});
return;
}
logger.info("매칭품목 삭제 성공", { companyCode, id });
res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("매칭품목 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
// ============================================================
// 적재함(loading_unit) CRUD
// ============================================================
/**
*
* GET /api/packaging/loading-units
*/
export const getLoadingUnits = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { search, loading_type, status } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`lu.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (search && typeof search === "string" && search.trim()) {
conditions.push(
`(lu.loading_code ILIKE $${paramIndex} OR lu.loading_name ILIKE $${paramIndex})`
);
params.push(`%${search.trim()}%`);
paramIndex++;
}
if (loading_type && typeof loading_type === "string" && loading_type.trim()) {
conditions.push(`lu.loading_type = $${paramIndex}`);
params.push(loading_type.trim());
paramIndex++;
}
if (status && typeof status === "string" && status.trim()) {
conditions.push(`lu.status = $${paramIndex}`);
params.push(status.trim());
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT
lu.*,
(SELECT COUNT(*) FROM loading_unit_pkg lup
WHERE lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code
) AS pkg_count
FROM loading_unit lu
${whereClause}
ORDER BY lu.created_date DESC
`;
const rows = await query(sql, params);
logger.info("적재함 목록 조회 성공", { companyCode, count: rows.length });
res.json({ success: true, data: rows });
} catch (error: any) {
logger.error("적재함 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* GET /api/packaging/loading-units/:id
*/
export const getLoadingUnitById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM loading_unit WHERE id = $1`;
params = [id];
} else {
sql = `SELECT * FROM loading_unit WHERE id = $1 AND company_code = $2`;
params = [id, companyCode];
}
const rows = await query(sql, params);
if (rows.length === 0) {
res.status(404).json({
success: false,
message: "적재함을 찾을 수 없습니다.",
});
return;
}
res.json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("적재함 상세 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* POST /api/packaging/loading-units
*/
export const createLoadingUnit = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
loading_code,
loading_name,
loading_type,
status,
width_mm,
length_mm,
height_mm,
self_weight_kg,
max_load_kg,
max_stack,
remarks,
} = req.body;
if (!loading_code || !loading_name) {
res.status(400).json({
success: false,
message: "적재코드(loading_code)와 적재명(loading_name)은 필수입니다.",
});
return;
}
// 중복 체크
const dupCheck = await query(
`SELECT id FROM loading_unit WHERE loading_code = $1 AND company_code = $2`,
[loading_code, companyCode]
);
if (dupCheck.length > 0) {
res.status(409).json({
success: false,
message: `적재코드 '${loading_code}'가 이미 존재합니다.`,
});
return;
}
const sql = `
INSERT INTO loading_unit
(company_code, loading_code, loading_name, loading_type, status,
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *
`;
const rows = await query(sql, [
companyCode,
loading_code,
loading_name,
loading_type || null,
status || "ACTIVE",
width_mm || null,
length_mm || null,
height_mm || null,
self_weight_kg || null,
max_load_kg || null,
max_stack || null,
remarks || null,
userId,
]);
logger.info("적재함 등록 성공", { companyCode, loading_code });
res.status(201).json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("적재함 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* PUT /api/packaging/loading-units/:id
*/
export const updateLoadingUnit = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
loading_name,
loading_type,
status,
width_mm,
length_mm,
height_mm,
self_weight_kg,
max_load_kg,
max_stack,
remarks,
} = req.body;
const setClauses: string[] = ["updated_date = NOW()", "writer = $1"];
const params: any[] = [userId];
let paramIndex = 2;
const fieldMap: Record<string, any> = {
loading_name,
loading_type,
status,
width_mm,
length_mm,
height_mm,
self_weight_kg,
max_load_kg,
max_stack,
remarks,
};
for (const [col, val] of Object.entries(fieldMap)) {
if (val !== undefined) {
setClauses.push(`${col} = $${paramIndex}`);
params.push(val);
paramIndex++;
}
}
params.push(id);
const idIdx = paramIndex;
paramIndex++;
let whereClause: string;
if (companyCode === "*") {
whereClause = `WHERE id = $${idIdx}`;
} else {
params.push(companyCode);
whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`;
}
const sql = `
UPDATE loading_unit
SET ${setClauses.join(", ")}
${whereClause}
RETURNING *
`;
const rows = await query(sql, params);
if (rows.length === 0) {
res.status(404).json({
success: false,
message: "적재함을 찾을 수 없거나 권한이 없습니다.",
});
return;
}
logger.info("적재함 수정 성공", { companyCode, id });
res.json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("적재함 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* DELETE /api/packaging/loading-units/:id
*/
export const deleteLoadingUnit = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const result = await transaction(async (client) => {
let findSql: string;
let findParams: any[];
if (companyCode === "*") {
findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1`;
findParams = [id];
} else {
findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1 AND company_code = $2`;
findParams = [id, companyCode];
}
const found = await client.query(findSql, findParams);
if (found.rowCount === 0) return null;
const { loading_code, company_code: targetCompany } = found.rows[0];
// 포장구성 먼저 삭제
await client.query(
`DELETE FROM loading_unit_pkg WHERE loading_code = $1 AND company_code = $2`,
[loading_code, targetCompany]
);
// 적재함 삭제
const del = await client.query(
`DELETE FROM loading_unit WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, targetCompany]
);
return del.rows[0];
});
if (!result) {
res.status(404).json({
success: false,
message: "적재함을 찾을 수 없거나 권한이 없습니다.",
});
return;
}
logger.info("적재함 삭제 성공", { companyCode, id });
res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("적재함 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
// ============================================================
// 적재함 포장구성(loading_unit_pkg) N:M
// ============================================================
/**
* ( )
* GET /api/packaging/loading-unit-pkgs?loading_code=XXX
*/
export const getLoadingUnitPkgs = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { loading_code } = req.query;
if (!loading_code) {
res.status(400).json({
success: false,
message: "loading_code 파라미터가 필요합니다.",
});
return;
}
const conditions: string[] = [`lup.loading_code = $1`];
const params: any[] = [loading_code];
let paramIndex = 2;
if (companyCode !== "*") {
conditions.push(`lup.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
const sql = `
SELECT
lup.*,
pu.pkg_name
FROM loading_unit_pkg lup
LEFT JOIN pkg_unit pu
ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY lup.created_date DESC
`;
const rows = await query(sql, params);
logger.info("포장구성 조회 성공", {
companyCode,
loading_code,
count: rows.length,
});
res.json({ success: true, data: rows });
} catch (error: any) {
logger.error("포장구성 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* POST /api/packaging/loading-unit-pkgs
*/
export const createLoadingUnitPkg = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { loading_code, pkg_code, max_load_qty, load_method } = req.body;
if (!loading_code || !pkg_code) {
res.status(400).json({
success: false,
message: "loading_code와 pkg_code는 필수입니다.",
});
return;
}
// 중복 체크
const dup = await query(
`SELECT id FROM loading_unit_pkg WHERE loading_code = $1 AND pkg_code = $2 AND company_code = $3`,
[loading_code, pkg_code, companyCode]
);
if (dup.length > 0) {
res.status(409).json({
success: false,
message: `이미 등록된 포장구성입니다: ${pkg_code}`,
});
return;
}
const sql = `
INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
const rows = await query(sql, [
companyCode,
loading_code,
pkg_code,
max_load_qty || null,
load_method || null,
userId,
]);
logger.info("포장구성 추가 성공", { companyCode, loading_code, pkg_code });
res.status(201).json({ success: true, data: rows[0] });
} catch (error: any) {
logger.error("포장구성 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* DELETE /api/packaging/loading-unit-pkgs/:id
*/
export const deleteLoadingUnitPkg = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `DELETE FROM loading_unit_pkg WHERE id = $1 RETURNING id`;
params = [id];
} else {
sql = `DELETE FROM loading_unit_pkg WHERE id = $1 AND company_code = $2 RETURNING id`;
params = [id, companyCode];
}
const rows = await query(sql, params);
if (rows.length === 0) {
res.status(404).json({
success: false,
message: "포장구성을 찾을 수 없거나 권한이 없습니다.",
});
return;
}
logger.info("포장구성 삭제 성공", { companyCode, id });
res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("포장구성 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
};

View File

@ -1,50 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import {
getPkgUnits,
getPkgUnitById,
createPkgUnit,
updatePkgUnit,
deletePkgUnit,
getPkgUnitItems,
createPkgUnitItem,
deletePkgUnitItem,
getLoadingUnits,
getLoadingUnitById,
createLoadingUnit,
updateLoadingUnit,
deleteLoadingUnit,
getLoadingUnitPkgs,
createLoadingUnitPkg,
deleteLoadingUnitPkg,
} from "../controllers/packagingController";
const router = Router(); const router = Router();
router.use(authenticateToken); router.use(authenticateToken);
// 포장단위 CRUD // TODO: 포장/적재정보 관리 API 구현 예정
router.get("/pkg-units", getPkgUnits);
router.get("/pkg-units/:id", getPkgUnitById);
router.post("/pkg-units", createPkgUnit);
router.put("/pkg-units/:id", updatePkgUnit);
router.delete("/pkg-units/:id", deletePkgUnit);
// 포장단위 매칭품목 (N:M)
router.get("/pkg-unit-items", getPkgUnitItems);
router.post("/pkg-unit-items", createPkgUnitItem);
router.delete("/pkg-unit-items/:id", deletePkgUnitItem);
// 적재함 CRUD
router.get("/loading-units", getLoadingUnits);
router.get("/loading-units/:id", getLoadingUnitById);
router.post("/loading-units", createLoadingUnit);
router.put("/loading-units/:id", updateLoadingUnit);
router.delete("/loading-units/:id", deleteLoadingUnit);
// 적재함 포장구성 (N:M)
router.get("/loading-unit-pkgs", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
export default router; export default router;

View File

@ -2346,24 +2346,19 @@ 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.*, sma.company_code AS assign_company_code `SELECT sd.* FROM screen_menu_assignments sma
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 OR sma.company_code = '*') AND sma.company_code = $2
AND sma.is_active = 'Y' AND sma.is_active = 'Y'
ORDER BY ORDER BY sma.display_order ASC`,
CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END,
sma.display_order ASC`,
[menuObjid, companyCode], [menuObjid, companyCode],
); );

View File

@ -1,10 +1,8 @@
"use client"; "use client";
import React, { useMemo, useState, useEffect } from "react"; import React, { useMemo } 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">
@ -12,320 +10,70 @@ 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": d(() => import("@/app/(main)/admin/page")), "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
// 메뉴 관리 // 메뉴 관리
"/admin/menu": d(() => import("@/app/(main)/admin/menu/page")), "/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }),
// 사용자 관리 // 사용자 관리
"/admin/userMng/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")), "/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")), "/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")), "/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/companyList": d(() => import("@/app/(main)/admin/userMng/companyList/page")), "/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
// 화면 관리 // 화면 관리
"/admin/screenMng/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")), "/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")), "/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")), "/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/reportList": d(() => import("@/app/(main)/admin/screenMng/reportList/page")), "/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/barcodeList": d(() => import("@/app/(main)/admin/screenMng/barcodeList/page")),
// 시스템 관리 // 시스템 관리
"/admin/systemMng/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")), "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")), "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")), "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")), "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow": d(() => import("@/app/(main)/admin/systemMng/dataflow/page")), "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow/node-editorList": d(() => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page")),
// 자동화 관리 // 자동화 관리
"/admin/automaticMng/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")), "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")), "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")), "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": d(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page")), "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
// 메일 // 메일
"/admin/automaticMng/mail/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")), "/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")), "/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")), "/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")), "/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")), "/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")), "/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")), "/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")), "/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/bulk-send": d(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page")), "/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
// 배치 관리 // 배치 관리
"/admin/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")), "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")), "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
// 결재 관리 // 기타
"/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")), "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")), "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
"/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")), "/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 }),
// AI 어시스턴트 "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
"/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")), "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")), "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")), "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
"/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">
@ -347,55 +95,15 @@ interface AdminPageRendererProps {
} }
export function AdminPageRenderer({ url }: AdminPageRendererProps) { export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); const PageComponent = useMemo(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [url]);
// 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링 if (!PageComponent) {
// 탭 시스템에서는 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,10 +362,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
return; return;
} }
} catch (err) { } catch {
console.error("할당된 화면 조회 실패:", err); console.warn("할당된 화면 조회 실패");
toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요.");
return;
} }
if (menu.url && menu.url !== "#") { if (menu.url && menu.url !== "#") {