Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node

This commit is contained in:
kmh 2026-03-12 01:18:18 +09:00
commit 9ef7652946
17 changed files with 988 additions and 614 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

@ -0,0 +1,478 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
import { getPool } from "../database/db";
// ──────────────────────────────────────────────
// 포장단위 (pkg_unit) CRUD
// ──────────────────────────────────────────────
export async function getPkgUnits(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM pkg_unit ORDER BY company_code, created_date DESC`;
params = [];
} else {
sql = `SELECT * FROM pkg_unit WHERE company_code = $1 ORDER BY created_date DESC`;
params = [companyCode];
}
const result = await pool.query(sql, params);
logger.info("포장단위 목록 조회", { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("포장단위 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createPkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
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: "포장코드와 포장명은 필수입니다." });
return;
}
const dup = await pool.query(
`SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`,
[pkg_code, companyCode]
);
if (dup.rowCount && dup.rowCount > 0) {
res.status(409).json({ success: false, message: "이미 존재하는 포장코드입니다." });
return;
}
const result = await pool.query(
`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 *`,
[companyCode, pkg_code, pkg_name, pkg_type, status || "ACTIVE",
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks,
req.user!.userId]
);
logger.info("포장단위 등록", { companyCode, pkg_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("포장단위 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updatePkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const {
pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
} = req.body;
const result = await pool.query(
`UPDATE pkg_unit SET
pkg_name=$1, pkg_type=$2, status=$3,
width_mm=$4, length_mm=$5, height_mm=$6,
self_weight_kg=$7, max_load_kg=$8, volume_l=$9, remarks=$10,
updated_date=NOW(), writer=$11
WHERE id=$12 AND company_code=$13
RETURNING *`,
[pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
req.user!.userId, id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("포장단위 수정", { companyCode, id });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("포장단위 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deletePkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await client.query("BEGIN");
await client.query(
`DELETE FROM pkg_unit_item WHERE pkg_code = (SELECT pkg_code FROM pkg_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
[id, companyCode]
);
const result = await client.query(
`DELETE FROM pkg_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
await client.query("COMMIT");
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("포장단위 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("포장단위 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ──────────────────────────────────────────────
// 포장단위 매칭품목 (pkg_unit_item) CRUD
// ──────────────────────────────────────────────
export async function getPkgUnitItems(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { pkgCode } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
[pkgCode, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("매칭품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createPkgUnitItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const { pkg_code, item_number, pkg_qty } = req.body;
if (!pkg_code || !item_number) {
res.status(400).json({ success: false, message: "포장코드와 품번은 필수입니다." });
return;
}
const result = await pool.query(
`INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer)
VALUES ($1,$2,$3,$4,$5)
RETURNING *`,
[companyCode, pkg_code, item_number, pkg_qty, req.user!.userId]
);
logger.info("매칭품목 추가", { companyCode, pkg_code, item_number });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("매칭품목 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deletePkgUnitItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("매칭품목 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
logger.error("매칭품목 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 적재함 (loading_unit) CRUD
// ──────────────────────────────────────────────
export async function getLoadingUnits(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM loading_unit ORDER BY company_code, created_date DESC`;
params = [];
} else {
sql = `SELECT * FROM loading_unit WHERE company_code = $1 ORDER BY created_date DESC`;
params = [companyCode];
}
const result = await pool.query(sql, params);
logger.info("적재함 목록 조회", { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("적재함 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
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: "적재함코드와 적재함명은 필수입니다." });
return;
}
const dup = await pool.query(
`SELECT id FROM loading_unit WHERE loading_code=$1 AND company_code=$2`,
[loading_code, companyCode]
);
if (dup.rowCount && dup.rowCount > 0) {
res.status(409).json({ success: false, message: "이미 존재하는 적재함코드입니다." });
return;
}
const result = await pool.query(
`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 *`,
[companyCode, loading_code, loading_name, loading_type, status || "ACTIVE",
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks,
req.user!.userId]
);
logger.info("적재함 등록", { companyCode, loading_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재함 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const {
loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
} = req.body;
const result = await pool.query(
`UPDATE loading_unit SET
loading_name=$1, loading_type=$2, status=$3,
width_mm=$4, length_mm=$5, height_mm=$6,
self_weight_kg=$7, max_load_kg=$8, max_stack=$9, remarks=$10,
updated_date=NOW(), writer=$11
WHERE id=$12 AND company_code=$13
RETURNING *`,
[loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
req.user!.userId, id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재함 수정", { companyCode, id });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재함 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await client.query("BEGIN");
await client.query(
`DELETE FROM loading_unit_pkg WHERE loading_code = (SELECT loading_code FROM loading_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
[id, companyCode]
);
const result = await client.query(
`DELETE FROM loading_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
await client.query("COMMIT");
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재함 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("적재함 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ──────────────────────────────────────────────
// 적재함 포장구성 (loading_unit_pkg) CRUD
// ──────────────────────────────────────────────
export async function getLoadingUnitPkgs(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { loadingCode } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
[loadingCode, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("적재구성 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createLoadingUnitPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const { loading_code, pkg_code, max_load_qty, load_method } = req.body;
if (!loading_code || !pkg_code) {
res.status(400).json({ success: false, message: "적재함코드와 포장코드는 필수입니다." });
return;
}
const result = await pool.query(
`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 *`,
[companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId]
);
logger.info("적재구성 추가", { companyCode, loading_code, pkg_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재구성 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteLoadingUnitPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재구성 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
logger.error("적재구성 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

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

View File

@ -2355,7 +2355,7 @@ export class ScreenManagementService {
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

View File

@ -12,84 +12,17 @@ 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 }) { function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
const [screenId, setScreenId] = useState<number | null>(null); const [screenId, setScreenId] = useState<number | null>(null);
const [error, setError] = useState(false); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const numericId = parseInt(screenCode); const numericId = parseInt(screenCode);
if (!isNaN(numericId)) { if (!isNaN(numericId)) {
setScreenId(numericId); setScreenId(numericId);
setLoading(false);
return; return;
} }
const resolve = async () => { const resolve = async () => {
try { try {
const res = await apiClient.get("/screen-management/screens", { const res = await apiClient.get("/screen-management/screens", {
@ -97,233 +30,202 @@ function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
}); });
const items = res.data?.data?.data || res.data?.data || []; const items = res.data?.data?.data || res.data?.data || [];
const arr = Array.isArray(items) ? items : []; const arr = Array.isArray(items) ? items : [];
const exact = arr.find((s: any) => s.screenCode === screenCode || s.screen_code === screenCode); const exact = arr.find((s: any) => s.screenCode === screenCode);
const target = exact || arr[0]; const target = exact || arr[0];
if (target) { if (target) setScreenId(target.screenId || target.screen_id);
setScreenId(target.screenId || target.screen_id);
} else {
setError(true);
}
} catch { } catch {
setError(true); console.error("스크린 코드 변환 실패:", screenCode);
} finally {
setLoading(false);
} }
}; };
resolve(); resolve();
}, [screenCode]); }, [screenCode]);
if (error) { if (loading) return <LoadingFallback />;
if (!screenId) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <p className="text-sm text-muted-foreground"> (: {screenCode})</p>
<p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground">
: {screenCode}
</p>
</div>
</div> </div>
); );
} }
if (screenId === null) {
return <LoadingFallback />;
}
return <ScreenViewPageWrapper screenIdProp={screenId} />; return <ScreenViewPageWrapper screenIdProp={screenId} />;
} }
/** const DashboardViewPage = dynamic(
* URL . () => import("@/app/(main)/dashboard/[dashboardId]/page"),
* URL은 . { ssr: false, loading: LoadingFallback },
*/ );
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/approvalTemplate": dynamic(() => import("@/app/(main)/admin/approvalTemplate/page"), { ssr: false, loading: LoadingFallback }),
"/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")), "/admin/approvalBox": dynamic(() => import("@/app/(main)/admin/approvalBox/page"), { ssr: false, loading: LoadingFallback }),
"/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")), "/admin/approvalMng": dynamic(() => import("@/app/(main)/admin/approvalMng/page"), { ssr: false, loading: LoadingFallback }),
// AI 어시스턴트 // 시스템
"/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")), "/admin/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }),
"/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")), "/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }),
"/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")), "/admin/aiAssistant": dynamic(() => import("@/app/(main)/admin/aiAssistant/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-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")), "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
"/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")), "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/templates": d(() => import("@/app/(main)/admin/templates/page")), "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")), "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
"/admin/standards": d(() => import("@/app/(main)/admin/standards/page")), "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")), "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")), "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
"/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")),
}; };
/** const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
* (URL ) "/admin/aiAssistant/dashboard": () => import("@/app/(main)/admin/aiAssistant/dashboard/page"),
* /admin/screenMng/dashboardList/123 dashboardList/[id] "/admin/aiAssistant/history": () => import("@/app/(main)/admin/aiAssistant/history/page"),
* "/admin/aiAssistant/api-keys": () => import("@/app/(main)/admin/aiAssistant/api-keys/page"),
* extractParams: URL에서 (use(params) ) "/admin/aiAssistant/api-test": () => import("@/app/(main)/admin/aiAssistant/api-test/page"),
* params={Promise.resolve(...)} "/admin/aiAssistant/usage": () => import("@/app/(main)/admin/aiAssistant/usage/page"),
* Next.js use(params) "/admin/aiAssistant/chat": () => import("@/app/(main)/admin/aiAssistant/chat/page"),
*/ "/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"),
interface DynamicRouteEntry { "/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
pattern: RegExp; "/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page"),
loader: () => Promise<any>; "/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"),
extractParams?: (url: string) => Record<string, string>; };
}
const DYNAMIC_ROUTE_PATTERNS: DynamicRouteEntry[] = [ const DYNAMIC_ADMIN_PATTERNS: Array<{
pattern: RegExp;
getImport: (match: RegExpMatchArray) => Promise<any>;
extractParams: (match: RegExpMatchArray) => Record<string, string>;
}> = [
{ {
pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/, pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/,
loader: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"), getImport: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"),
extractParams: (url) => ({ id: url.split("/").pop()! }), extractParams: (m) => ({ id: m[1] }),
},
{
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\/([^/]+)$/, pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/,
loader: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"), getImport: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"),
extractParams: (url) => ({ id: url.split("/").pop()! }), extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"),
extractParams: (m) => ({ labelId: m[1] }),
},
{
pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"),
extractParams: (m) => ({ reportId: m[1] }),
},
{
pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
extractParams: (m) => ({ diagramId: m[1] }),
},
{
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
extractParams: (m) => ({ companyCode: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
extractParams: (m) => ({ webType: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/page"),
extractParams: (m) => ({ webType: m[1] }),
}, },
]; ];
interface DynamicRouteResult { function DynamicAdminLoader({ url, params }: { url: string; params?: Record<string, string> }) {
component: React.ComponentType<any>; const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
params?: Record<string, string>; const [failed, setFailed] = useState(false);
}
const dynamicRouteCache = new Map<string, DynamicRouteResult>(); useEffect(() => {
const staticImport = DYNAMIC_ADMIN_IMPORTS[url];
function resolveDynamicRoute(cleanUrl: string): DynamicRouteResult | null { if (staticImport) {
if (dynamicRouteCache.has(cleanUrl)) { staticImport()
return dynamicRouteCache.get(cleanUrl)!; .then((mod) => setComponent(() => mod.default))
} .catch(() => setFailed(true));
return;
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; for (const { pattern, getImport, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
const match = url.match(pattern);
if (match) {
getImport()
.then((mod) => setComponent(() => mod.default))
.catch(() => setFailed(true));
return;
}
}
setFailed(true);
}, [url]);
if (failed) return <AdminPageFallback url={url} />;
if (!Component) return <LoadingFallback />;
if (params) return <Component params={Promise.resolve(params)} />;
return <Component />;
} }
function AdminPageFallback({ url }: { url: string }) { function AdminPageFallback({ url }: { url: string }) {
@ -331,12 +233,8 @@ function AdminPageFallback({ url }: { url: string }) {
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
<p className="text-lg font-semibold text-foreground"> </p> <p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">: {url}</p>
: {url} <p className="mt-2 text-xs text-muted-foreground"> .</p>
</p>
<p className="mt-2 text-xs text-muted-foreground">
AdminPageRenderer URL을 .
</p>
</div> </div>
</div> </div>
); );
@ -349,53 +247,56 @@ interface AdminPageRendererProps {
export function AdminPageRenderer({ url }: AdminPageRendererProps) { export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
// 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링 console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl });
// 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달
const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); // 화면 할당: /screens/[id]
if (screenIdMatch) { const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
const screenId = parseInt(screenIdMatch[1]); if (screensIdMatch) {
return <ScreenViewPageWrapper screenIdProp={screenId} />; console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]);
return <ScreenViewPageWrapper screenIdProp={parseInt(screensIdMatch[1])} />;
} }
// 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링 // 화면 할당: /screen/[code] (구 형식)
const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/);
if (screenCodeMatch) { if (screenCodeMatch) {
console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]);
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />; return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
} }
// 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링 // 대시보드 할당: /dashboard/[id]
// Next.js의 params Promise를 우회하여 dashboardId를 직접 전달
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
if (dashboardMatch) { if (dashboardMatch) {
return <DashboardTabRenderer dashboardId={dashboardMatch[1]} />; console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]);
return <DashboardViewPage params={Promise.resolve({ dashboardId: dashboardMatch[1] })} />;
} }
const resolved = useMemo(() => { // URL 직접 입력: 레지스트리 매칭
// 1) 정적 레지스트리 매칭 const PageComponent = useMemo(() => {
if (ADMIN_PAGE_REGISTRY[cleanUrl]) { return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
return { component: ADMIN_PAGE_REGISTRY[cleanUrl] } as DynamicRouteResult;
}
// 2) 동적 라우트 패턴 매칭 (/admin/xxx/[id] 등)
const dynamicMatch = resolveDynamicRoute(cleanUrl);
if (dynamicMatch) {
return dynamicMatch;
}
return null;
}, [cleanUrl]); }, [cleanUrl]);
if (!resolved) { if (PageComponent) {
return <AdminPageFallback url={url} />; console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl);
return <PageComponent />;
} }
const { component: PageComponent, params } = resolved; // 레지스트리에 없으면 동적 import 시도
// 동적 라우트 패턴 매칭 (params 추출)
// 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달 for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
// Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨 const match = cleanUrl.match(pattern);
if (params) { if (match) {
return <PageComponent params={Promise.resolve(params)} />; const params = extractParams(match);
console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params);
return <DynamicAdminLoader url={cleanUrl} params={params} />;
}
} }
return <PageComponent />; // 정적 동적 import 목록에 있으면
if (DYNAMIC_ADMIN_IMPORTS[cleanUrl]) {
console.log("[AdminPageRenderer] → 동적 import:", cleanUrl);
return <DynamicAdminLoader url={cleanUrl} />;
}
console.error("[AdminPageRenderer] 미등록 URL:", cleanUrl);
return <AdminPageFallback url={url} />;
} }

View File

@ -202,12 +202,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle); const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
const menuUrl = menu.menu_url || menu.MENU_URL || "#";
const screenCode = menu.screen_code || menu.SCREEN_CODE || null;
const menuType = String(menu.menu_type ?? menu.MENU_TYPE ?? "");
let screenId: number | null = null;
const screensMatch = menuUrl.match(/^\/screens\/(\d+)/);
if (screensMatch) {
screenId = parseInt(screensMatch[1]);
}
return { return {
id: menuId, id: menuId,
objid: menuId,
name: displayName, name: displayName,
tabTitle, tabTitle,
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
url: menu.menu_url || menu.MENU_URL || "#", url: menuUrl,
screenCode,
screenId,
menuType,
children: children.length > 0 ? children : undefined, children: children.length > 0 ? children : undefined,
hasChildren: children.length > 0, hasChildren: children.length > 0,
}; };
@ -341,44 +355,76 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const handleMenuClick = async (menu: any) => { const handleMenuClick = async (menu: any) => {
if (menu.hasChildren) { if (menu.hasChildren) {
toggleMenu(menu.id); toggleMenu(menu.id);
} else { return;
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; }
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName); const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0");
const isAdminMenu = menu.menuType === "0";
console.log("[handleMenuClick] 메뉴 클릭:", {
menuName,
menuObjid,
menuType: menu.menuType,
isAdminMenu,
screenId: menu.screenId,
screenCode: menu.screenCode,
url: menu.url,
fullMenu: menu,
});
// 관리자 메뉴 (menu_type = 0): URL 직접 입력 → admin 탭
if (isAdminMenu) {
if (menu.url && menu.url !== "#") {
console.log("[handleMenuClick] → admin 탭:", menu.url);
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
if (isMobile) setSidebarOpen(false);
} else {
toast.warning("이 메뉴에는 연결된 페이지가 없습니다.");
} }
return;
}
// 사용자 메뉴 (menu_type = 1, 2): 화면/대시보드 할당
// 1) screenId가 메뉴 URL에서 추출된 경우 바로 screen 탭
if (menu.screenId) {
console.log("[handleMenuClick] → screen 탭 (URL에서 screenId 추출):", menu.screenId);
openTab({ type: "screen", title: menuName, screenId: menu.screenId, menuObjid });
if (isMobile) setSidebarOpen(false);
return;
}
// 2) screen_menu_assignments 테이블 조회
if (menuObjid) {
try { try {
const menuObjid = menu.objid || menu.id; console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid);
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
console.log("[handleMenuClick] → 조회 결과:", assignedScreens);
if (assignedScreens.length > 0) { if (assignedScreens.length > 0) {
const firstScreen = assignedScreens[0]; console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId);
openTab({ openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid });
type: "screen",
title: menuName,
screenId: firstScreen.screenId,
menuObjid: parseInt(menuObjid),
});
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
return; return;
} }
} catch (err) { } catch (err) {
console.error("할당된 화면 조회 실패:", err); console.error("[handleMenuClick] 할당된 화면 조회 실패:", err);
toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요.");
return;
}
if (menu.url && menu.url !== "#") {
openTab({
type: "admin",
title: menuName,
adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false);
} else {
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
} }
} }
// 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리)
if (menu.url && menu.url.startsWith("/dashboard/")) {
console.log("[handleMenuClick] → 대시보드 탭:", menu.url);
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
if (isMobile) setSidebarOpen(false);
return;
}
console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId });
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
}; };
const handleModeSwitch = () => { const handleModeSwitch = () => {

View File

@ -238,6 +238,14 @@ function TabPageRenderer({
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string }; tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
refreshKey: number; refreshKey: number;
}) { }) {
console.log("[TabPageRenderer] 탭 렌더링:", {
tabId: tab.id,
type: tab.type,
screenId: tab.screenId,
adminUrl: tab.adminUrl,
menuObjid: tab.menuObjid,
});
if (tab.type === "screen" && tab.screenId != null) { if (tab.type === "screen" && tab.screenId != null) {
return ( return (
<ScreenViewPageWrapper <ScreenViewPageWrapper
@ -256,5 +264,6 @@ function TabPageRenderer({
); );
} }
console.warn("[TabPageRenderer] 렌더링 불가 - 매칭 조건 없음:", tab);
return null; return null;
} }

View File

@ -480,6 +480,72 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
</div> </div>
</> </>
)} )}
{/* 데이터 바인딩 설정 */}
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="dataBindingEnabled"
checked={!!config.dataBinding?.sourceComponentId}
onCheckedChange={(checked) => {
if (checked) {
updateConfig("dataBinding", {
sourceComponentId: config.dataBinding?.sourceComponentId || "",
sourceColumn: config.dataBinding?.sourceColumn || "",
});
} else {
updateConfig("dataBinding", undefined);
}
}}
/>
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
</Label>
</div>
{config.dataBinding && (
<div className="space-y-2 rounded border p-2">
<p className="text-[10px] text-muted-foreground">
v2-table-list에서
</p>
<div className="space-y-1">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={config.dataBinding?.sourceComponentId || ""}
onChange={(e) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceComponentId: e.target.value,
});
}}
placeholder="예: tbl_items"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
v2-table-list ID
</p>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.dataBinding?.sourceColumn || ""}
onChange={(e) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceColumn: e.target.value,
});
}}
placeholder="예: item_number"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
)}
</div>
</div> </div>
); );
}; };

View File

@ -42,6 +42,8 @@ export interface MenuItem {
TRANSLATED_DESC?: string; TRANSLATED_DESC?: string;
menu_icon?: string; menu_icon?: string;
MENU_ICON?: string; MENU_ICON?: string;
screen_code?: string;
SCREEN_CODE?: string;
} }
export interface MenuFormData { export interface MenuFormData {

View File

@ -1,10 +1,78 @@
"use client"; "use client";
import React from "react"; import React, { useEffect, useRef } from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2InputDefinition } from "./index"; import { V2InputDefinition } from "./index";
import { V2Input } from "@/components/v2/V2Input"; import { V2Input } from "@/components/v2/V2Input";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
/**
* dataBinding이 v2-input을 wrapper
* v2-table-list의 TABLE_DATA_CHANGE
* formData에
*/
function DataBindingWrapper({
dataBinding,
columnName,
onFormDataChange,
isInteractive,
children,
}: {
dataBinding: { sourceComponentId: string; sourceColumn: string };
columnName: string;
onFormDataChange?: (field: string, value: any) => void;
isInteractive?: boolean;
children: React.ReactNode;
}) {
const lastBoundValueRef = useRef<any>(null);
useEffect(() => {
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
console.log("[DataBinding] 구독 시작:", {
sourceComponentId: dataBinding.sourceComponentId,
sourceColumn: dataBinding.sourceColumn,
targetColumn: columnName,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => {
console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", {
payloadSource: payload.source,
expectedSource: dataBinding.sourceComponentId,
dataLength: payload.data?.length,
match: payload.source === dataBinding.sourceComponentId,
});
if (payload.source !== dataBinding.sourceComponentId) return;
const selectedData = payload.data;
if (selectedData && selectedData.length > 0) {
const value = selectedData[0][dataBinding.sourceColumn];
console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName });
if (value !== lastBoundValueRef.current) {
lastBoundValueRef.current = value;
if (onFormDataChange && columnName) {
onFormDataChange(columnName, value ?? "");
}
}
} else {
if (lastBoundValueRef.current !== null) {
lastBoundValueRef.current = null;
if (onFormDataChange && columnName) {
onFormDataChange(columnName, "");
}
}
}
});
return () => unsubscribe();
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]);
return <>{children}</>;
}
/** /**
* V2Input * V2Input
@ -16,41 +84,37 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
render(): React.ReactElement { render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {}; const config = component.componentConfig || component.config || {};
const columnName = component.columnName; const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName; const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기
const currentValue = formData?.[columnName] ?? component.value ?? ""; const currentValue = formData?.[columnName] ?? component.value ?? "";
// 값 변경 핸들러
const handleChange = (value: any) => { const handleChange = (value: any) => {
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
columnName,
value,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
if (isInteractive && onFormDataChange && columnName) { if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value); onFormDataChange(columnName, value);
} else {
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
columnName,
});
} }
}; };
// 라벨: style.labelText 우선, 없으면 component.label 사용
// 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로)
const style = component.style || {}; const style = component.style || {};
const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
// labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김)
const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined; const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined;
return ( const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) {
console.log("[V2InputRenderer] dataBinding 탐색:", {
componentId: component.id,
columnName,
configKeys: Object.keys(config),
configDataBinding: config.dataBinding,
componentDataBinding: (component as any).dataBinding,
nestedDataBinding: config.componentConfig?.dataBinding,
finalDataBinding: dataBinding,
});
}
const inputElement = (
<V2Input <V2Input
id={component.id} id={component.id}
value={currentValue} value={currentValue}
@ -77,10 +141,26 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
{...restProps} {...restProps}
label={effectiveLabel} label={effectiveLabel}
required={component.required || isColumnRequiredByMeta(tableName, columnName)} required={component.required || isColumnRequiredByMeta(tableName, columnName)}
readonly={config.readonly || component.readonly} readonly={config.readonly || component.readonly || !!dataBinding?.sourceComponentId}
disabled={config.disabled || component.disabled} disabled={config.disabled || component.disabled}
/> />
); );
// dataBinding이 있으면 wrapper로 감싸서 이벤트 구독
if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) {
return (
<DataBindingWrapper
dataBinding={dataBinding}
columnName={columnName}
onFormDataChange={onFormDataChange}
isInteractive={isInteractive}
>
{inputElement}
</DataBindingWrapper>
);
}
return inputElement;
} }
} }

View File

@ -5103,6 +5103,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}} }}
/> />
)} )}
</div> </div>
); );
}; };

View File

@ -118,9 +118,9 @@ export interface AdditionalTabConfig {
// 추가 버튼 설정 (모달 화면 연결 지원) // 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: { addButton?: {
enabled: boolean; enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 mode: "auto" | "modal";
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) modalScreenId?: number;
buttonLabel?: string; // 버튼 라벨 (기본: "추가") buttonLabel?: string;
}; };
deleteButton?: { deleteButton?: {
@ -161,9 +161,9 @@ export interface SplitPanelLayoutConfig {
// 추가 버튼 설정 (모달 화면 연결 지원) // 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: { addButton?: {
enabled: boolean; enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 mode: "auto" | "modal";
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) modalScreenId?: number;
buttonLabel?: string; // 버튼 라벨 (기본: "추가") buttonLabel?: string;
}; };
columns?: Array<{ columns?: Array<{
@ -334,10 +334,10 @@ export interface SplitPanelLayoutConfig {
// 🆕 추가 버튼 설정 (모달 화면 연결 지원) // 🆕 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: { addButton?: {
enabled: boolean; // 추가 버튼 표시 여부 (기본: true) enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 mode: "auto" | "modal";
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) modalScreenId?: number;
buttonLabel?: string; // 버튼 라벨 (기본: "추가") buttonLabel?: string;
}; };
// 🆕 삭제 버튼 설정 // 🆕 삭제 버튼 설정

View File

@ -2113,11 +2113,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
const handleRowSelection = (rowKey: string, checked: boolean) => { const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows); const isMultiSelect = tableConfig.checkbox?.multiple !== false;
if (checked) { let newSelectedRows: Set<string>;
newSelectedRows.add(rowKey);
if (isMultiSelect) {
newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
} else { } else {
newSelectedRows.delete(rowKey); // 단일 선택: 기존 선택 해제 후 새 항목만 선택
newSelectedRows = checked ? new Set([rowKey]) : new Set();
} }
setSelectedRows(newSelectedRows); setSelectedRows(newSelectedRows);
@ -4187,6 +4195,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const renderCheckboxHeader = () => { const renderCheckboxHeader = () => {
if (!tableConfig.checkbox?.selectAll) return null; if (!tableConfig.checkbox?.selectAll) return null;
if (tableConfig.checkbox?.multiple === false) return null;
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />; return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
}; };