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

View File

@ -1,79 +1,5 @@
# 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. 화면 유형 구분 (절대 규칙!)
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
@ -81,7 +7,7 @@ END IF
### 관리자 메뉴 (Admin)
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!**
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
- **특징**: 하드코딩된 UI, 관리자만 접근
@ -94,7 +20,6 @@ END IF
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
- **특징**: 코드 수정 없이 화면 구성 변경 가능
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업
### 판단 기준
@ -241,7 +166,7 @@ VALUES (
- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만)
- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링)
## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT)
## 6. 절대 하지 말 것
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
@ -249,39 +174,9 @@ VALUES (
4. 하드코딩 색상/URL/사용자ID 사용
5. Card 안에 Card 중첩 (중첩 박스 금지)
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
7. **[최우선 금지] 사용자 메뉴를 React 하드코딩(.tsx)으로 만들기**
- `frontend/app/(main)/` 하위에서 `/admin/` 이외의 경로에 page.tsx를 만드는 것은 절대 금지
- 구체적 금지 경로: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ 및 기타 모든 비-admin 경로
7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)**
- `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지
- 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현
- 이미 `/screen/[screenCode]``/screens/[screenId]` 렌더링 시스템이 존재함
- 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능
- 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성
- **위반 발견 시: 해당 라운드 전체 FAIL 처리, 생성된 파일 즉시 삭제, DB 등록 방식으로 처음부터 재작업**
## 7. 위반 사례 및 올바른 대응
### 위반 사례 (실제 발생한 문제)
```
# 이런 파일을 만들면 절대 안 된다!
frontend/app/(main)/production/packaging/page.tsx ← REJECT!
frontend/app/(main)/warehouse/inventory/page.tsx ← REJECT!
frontend/app/(main)/quality/inspection/page.tsx ← REJECT!
frontend/app/(main)/mold/management/page.tsx ← REJECT!
```
### 올바른 대응
```sql
-- 1. screen_definitions에 등록
INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active)
VALUES ('포장관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y');
-- 2. screen_layouts_v2에 V2 레이아웃 JSON 등록
INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data)
VALUES ({screen_id}, 'COMPANY_7', 1, '기본 레이어', '{...V2 JSON...}'::jsonb);
-- 3. menu_info에 메뉴 등록
INSERT INTO menu_info (..., menu_url, screen_code, ...)
VALUES (..., '/screen/COMPANY_7_PKG', 'COMPANY_7_PKG', ...);
```
**React 페이지(.tsx) 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.**

View File

@ -8,63 +8,6 @@ model: inherit
You are a Frontend specialist for ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
---
# !!!! STOP - 파일 생성 전 필수 게이트 (반드시 읽고 확인하라) !!!!
## 파일을 생성하거나 수정하기 전에 반드시 이 체크를 수행하라:
### CHECK 1: page.tsx를 만들려고 하는가?
```
IF 파일 경로가 "frontend/app/(main)/" 하위이다
AND 파일명이 page.tsx 또는 layout.tsx이다
AND 경로에 "/admin/"이 포함되어 있지 않다
THEN
!!!! 즉시 중단 !!!! 이것은 사용자 메뉴다!
→ React 페이지를 만들면 안 된다
→ DB 등록 방식으로 전환하라 (screen_definitions + screen_layouts_v2 + menu_info)
→ 이 파일의 "올바른 패턴" 섹션을 참조하라
END IF
```
### 금지 경로 목록 (이 경로에 page.tsx 생성 시 즉시 REJECT):
```
frontend/app/(main)/production/** ← 금지!
frontend/app/(main)/warehouse/** ← 금지!
frontend/app/(main)/quality/** ← 금지!
frontend/app/(main)/logistics/** ← 금지!
frontend/app/(main)/inventory/** ← 금지!
frontend/app/(main)/purchase/** ← 금지!
frontend/app/(main)/sales/** ← 금지!
frontend/app/(main)/bom/** ← 금지!
frontend/app/(main)/mold/** ← 금지!
frontend/app/(main)/packaging/** ← 금지!
frontend/app/(main)/document/** ← 금지!
frontend/app/(main)/work/** ← 금지!
frontend/app/(main)/order/** ← 금지!
frontend/app/(main)/material/** ← 금지!
frontend/app/(main)/equipment/** ← 금지!
frontend/app/(main)/inspection/** ← 금지!
(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지 대상이다!)
```
### 유일하게 허용되는 page.tsx 생성 경로:
```
frontend/app/(main)/admin/** ← 유일하게 허용!
```
### CHECK 2: 사용자 메뉴 키워드 감지
요구사항에 아래 키워드가 포함되면 사용자 메뉴일 가능성이 높다:
> 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비, 목록+상세, 좌측 테이블+우측 폼, CRUD 화면
사용자 메뉴라면 **page.tsx 생성을 절대 하지 말고** DB 등록으로 전환하라.
**이 게이트를 통과하지 않은 파일 생성은 전부 REJECT 된다.**
---
# CRITICAL PROJECT RULES
## 1. API Client (ABSOLUTE RULE!)
@ -106,23 +49,18 @@ export async function getYourData(id: number) {
}
```
---
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT)
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.**
사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다!
## 금지 패턴 (이 파일을 만드는 순간 작업 전체 REJECT)
## 금지 패턴 (절대 하지 말 것)
```
frontend/app/(main)/production/packaging/page.tsx ← REJECT! 삭제 대상!
frontend/app/(main)/warehouse/something/page.tsx ← REJECT! 삭제 대상!
frontend/app/(main)/quality/inspection/page.tsx ← REJECT! 삭제 대상!
frontend/app/(main)/mold/management/page.tsx ← REJECT! 삭제 대상!
frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT!
frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라!
frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라!
```
## 올바른 패턴 (사용자 메뉴는 DB 등록만으로 완성된다)
## 올바른 패턴
사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다:
1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등)
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로 변환
- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링
**React 페이지 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.**
## 프론트엔드 에이전트가 할 수 있는 것
- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신)
- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`) React 페이지 코딩 가능
- 관리자 메뉴(`/admin/*`) React 페이지 코딩 가능
## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT)
- `/admin/` 이외 경로에 page.tsx 생성
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
## 프론트엔드 에이전트가 할 수 없는 것
- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것
# Your Domain
- frontend/components/
- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!)
- frontend/app/
- frontend/lib/
- frontend/hooks/

View File

@ -8,43 +8,6 @@ model: inherit
You are a UI/UX Design specialist for the ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
---
# !!!! STOP - 파일 생성/수정 전 필수 게이트 !!!!
## 파일을 만들거나 수정하기 전에 반드시 확인하라:
**page.tsx를 생성하려는 경로가 `frontend/app/(main)/admin/` 하위인가?**
- YES → 진행 가능
- NO → **즉시 중단!** 사용자 메뉴는 React 페이지로 만들지 않는다!
**금지 경로 (이 경로에 page.tsx 생성 시 즉시 REJECT):**
```
frontend/app/(main)/production/** ← 금지!
frontend/app/(main)/warehouse/** ← 금지!
frontend/app/(main)/quality/** ← 금지!
frontend/app/(main)/logistics/** ← 금지!
frontend/app/(main)/inventory/** ← 금지!
frontend/app/(main)/purchase/** ← 금지!
frontend/app/(main)/sales/** ← 금지!
frontend/app/(main)/bom/** ← 금지!
frontend/app/(main)/mold/** ← 금지!
frontend/app/(main)/packaging/** ← 금지!
frontend/app/(main)/document/** ← 금지!
frontend/app/(main)/work/** ← 금지!
frontend/app/(main)/order/** ← 금지!
frontend/app/(main)/material/** ← 금지!
frontend/app/(main)/equipment/** ← 금지!
frontend/app/(main)/inspection/** ← 금지!
(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지!)
```
**사용자 메뉴 화면은 DB 등록(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.**
**이 게이트를 무시하면 작업 전체 REJECT + 파일 삭제 + 재작업 대상이다.**
---
# Design Philosophy
- Apple-level polish with enterprise functionality
- Consistent spacing, typography, color usage
@ -76,23 +39,22 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
- Use cn() for conditional classes
- Use lucide-react for ALL icons
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT)
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다.
React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지!
## UI 에이전트가 할 수 있는 것
UI 에이전트가 할 수 있는 것:
- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선
- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선
## UI 에이전트가 할 수 없는 것 (위반 시 REJECT)
- `/admin/` 이외 경로에 page.tsx 생성 또는 수정
UI 에이전트가 할 수 없는 것:
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain
- frontend/components/ (UI components)
- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**)
- frontend/app/ (pages - 관리자 메뉴만)
- frontend/lib/registry/components/v2-*/ (V2 컴포넌트)
# Output Rules

View File

@ -1,6 +1,6 @@
---
name: pipeline-verifier
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지.
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
model: fast
readonly: true
---
@ -11,29 +11,6 @@ Your job is to verify that work claimed as complete actually works.
# Verification Checklist
## 0. 하드코딩 페이지 탐지 (최최우선! 이것부터 먼저 확인!)
**이 프로젝트에서 가장 심각한 위반은 사용자 메뉴를 React 페이지(.tsx)로 하드코딩하는 것이다.**
검증 시 반드시 아래를 제일 먼저 확인하라:
- [ ] `frontend/app/(main)/` 하위에 `/admin/` 이외의 경로에 새로운 page.tsx가 생성되지 않았는가?
- [ ] 구체적 금지 경로 확인: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/
- [ ] 위 경로뿐 아니라 `/admin/` 이외의 **모든** 경로에 page.tsx가 새로 생성되었는지 확인
- [ ] 사용자 메뉴 화면이 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 구현되었는가?
**검증 방법:**
```bash
# 이 라운드에서 새로 생성된 파일 중 금지 경로의 page.tsx가 있는지 확인
git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep -v "/admin/"
# 결과가 있으면 → 즉시 FAIL!
```
**위반 발견 시:**
- 검증 결과: **CRITICAL FAIL**
- 해당 파일 삭제 필수
- DB 등록 방식으로 재작업 지시
- 이 위반이 있으면 다른 항목 전부 PASS여도 최종 결과는 FAIL
## 1. Multi-tenancy (최우선)
- [ ] 모든 SQL에 company_code 필터 존재
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
@ -51,7 +28,6 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
- [ ] Frontend: V2 컴포넌트 규격 준수
- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!)
- [ ] Backend: logger 사용
- [ ] Backend: try/catch 에러 처리
@ -63,10 +39,7 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep
# Reporting Format
```
## 검증 결과: [PASS/FAIL/CRITICAL FAIL]
### [CRITICAL] 하드코딩 페이지 탐지
- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)")
## 검증 결과: [PASS/FAIL]
### 통과 항목
- 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.
하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 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 { 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();
router.use(authenticateToken);
// 포장단위 CRUD
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);
// TODO: 포장/적재정보 관리 API 구현 예정
export default router;

View File

@ -2346,24 +2346,19 @@ export class ScreenManagementService {
}
/**
*
* company_code 매칭: 본인 + SUPER_ADMIN ('*')
* ,
* ( Raw Query )
*/
async getScreensByMenu(
menuObjid: number,
companyCode: string,
): Promise<ScreenDefinition[]> {
const screens = await query<any>(
`SELECT sd.*, sma.company_code AS assign_company_code
FROM screen_menu_assignments sma
`SELECT sd.* FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = $1
AND (sma.company_code = $2 OR sma.company_code = '*')
AND sma.company_code = $2
AND sma.is_active = 'Y'
ORDER BY
CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END,
sma.display_order ASC`,
ORDER BY sma.display_order ASC`,
[menuObjid, companyCode],
);

View File

@ -1,10 +1,8 @@
"use client";
import React, { useMemo, useState, useEffect } from "react";
import React, { useMemo } from "react";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { apiClient } from "@/lib/api/client";
const LoadingFallback = () => (
<div className="flex h-full items-center justify-center">
@ -12,320 +10,70 @@ const LoadingFallback = () => (
</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은 catch-all fallback으로 .
*/
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/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")),
"/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")),
"/admin/userMng/companyList": d(() => import("@/app/(main)/admin/userMng/companyList/page")),
"/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
// 화면 관리
"/admin/screenMng/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")),
"/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")),
"/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")),
"/admin/screenMng/reportList": d(() => import("@/app/(main)/admin/screenMng/reportList/page")),
"/admin/screenMng/barcodeList": d(() => import("@/app/(main)/admin/screenMng/barcodeList/page")),
"/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }),
// 시스템 관리
"/admin/systemMng/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")),
"/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")),
"/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")),
"/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")),
"/admin/systemMng/dataflow": d(() => import("@/app/(main)/admin/systemMng/dataflow/page")),
"/admin/systemMng/dataflow/node-editorList": d(() => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page")),
"/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
// 자동화 관리
"/admin/automaticMng/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")),
"/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")),
"/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")),
"/admin/automaticMng/exCallConfList": d(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page")),
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
// 메일
"/admin/automaticMng/mail/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")),
"/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")),
"/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")),
"/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")),
"/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")),
"/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")),
"/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")),
"/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")),
"/admin/automaticMng/mail/bulk-send": d(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page")),
"/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
// 배치 관리
"/admin/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")),
"/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")),
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
// 결재 관리
"/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")),
"/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")),
"/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")),
// AI 어시스턴트
"/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")),
"/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")),
"/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")),
"/admin/aiAssistant/api-keys": d(() => import("@/app/(main)/admin/aiAssistant/api-keys/page")),
"/admin/aiAssistant/dashboard": d(() => import("@/app/(main)/admin/aiAssistant/dashboard/page")),
"/admin/aiAssistant/chat": d(() => import("@/app/(main)/admin/aiAssistant/chat/page")),
"/admin/aiAssistant/api-test": d(() => import("@/app/(main)/admin/aiAssistant/api-test/page")),
// 기타 관리
"/admin/cascading-management": d(() => import("@/app/(main)/admin/cascading-management/page")),
"/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")),
"/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")),
"/admin/templates": d(() => import("@/app/(main)/admin/templates/page")),
"/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")),
"/admin/standards": d(() => import("@/app/(main)/admin/standards/page")),
"/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")),
"/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")),
"/admin/system-notices": d(() => import("@/app/(main)/admin/system-notices/page")),
"/admin/audit-log": d(() => import("@/app/(main)/admin/audit-log/page")),
// 개발/테스트
"/admin/debug": d(() => import("@/app/(main)/admin/debug/page")),
"/admin/debug-simple": d(() => import("@/app/(main)/admin/debug-simple/page")),
"/admin/debug-layout": d(() => import("@/app/(main)/admin/debug-layout/page")),
"/admin/test": d(() => import("@/app/(main)/admin/test/page")),
"/admin/ui-components-demo": d(() => import("@/app/(main)/admin/ui-components-demo/page")),
"/admin/validation-demo": d(() => import("@/app/(main)/admin/validation-demo/page")),
"/admin/token-test": d(() => import("@/app/(main)/admin/token-test/page")),
// === 사용자 화면 (admin이 아닌 URL 기반 메뉴) ===
"/approval": d(() => import("@/app/(main)/approval/page")),
"/dashboard": d(() => import("@/app/(main)/dashboard/page")),
"/multilang": d(() => import("@/app/(main)/multilang/page")),
"/test-flow": d(() => import("@/app/(main)/test-flow/page")),
"/main": d(() => import("@/app/(main)/main/page")),
// 기타
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
};
/**
* (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;
}
// 매핑되지 않은 URL용 Fallback
function AdminPageFallback({ url }: { url: string }) {
return (
<div className="flex h-full items-center justify-center">
@ -347,55 +95,15 @@ interface 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 직접 렌더링
// 탭 시스템에서는 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) {
if (!PageComponent) {
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 />;
}

View File

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