Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
commit
9ef7652946
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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만으로 화면이 완성된다.**
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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이다.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,36 @@
|
|||
import { Router } from "express";
|
||||
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();
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -2355,7 +2355,7 @@ export class ScreenManagementService {
|
|||
companyCode: string,
|
||||
): Promise<ScreenDefinition[]> {
|
||||
const screens = await query<any>(
|
||||
`SELECT sd.*, sma.company_code AS assign_company_code
|
||||
`SELECT sd.*
|
||||
FROM screen_menu_assignments sma
|
||||
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||
WHERE sma.menu_objid = $1
|
||||
|
|
|
|||
|
|
@ -12,84 +12,17 @@ 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);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const numericId = parseInt(screenCode);
|
||||
if (!isNaN(numericId)) {
|
||||
setScreenId(numericId);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolve = async () => {
|
||||
try {
|
||||
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 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];
|
||||
if (target) {
|
||||
setScreenId(target.screenId || target.screen_id);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
if (target) setScreenId(target.screenId || target.screen_id);
|
||||
} catch {
|
||||
setError(true);
|
||||
console.error("스크린 코드 변환 실패:", screenCode);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
resolve();
|
||||
}, [screenCode]);
|
||||
|
||||
if (error) {
|
||||
if (loading) return <LoadingFallback />;
|
||||
if (!screenId) {
|
||||
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>
|
||||
<p className="text-sm text-muted-foreground">화면을 찾을 수 없습니다 (코드: {screenCode})</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenId === null) {
|
||||
return <LoadingFallback />;
|
||||
}
|
||||
|
||||
return <ScreenViewPageWrapper screenIdProp={screenId} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
||||
* 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다.
|
||||
*/
|
||||
const DashboardViewPage = dynamic(
|
||||
() => import("@/app/(main)/dashboard/[dashboardId]/page"),
|
||||
{ ssr: false, loading: LoadingFallback },
|
||||
);
|
||||
|
||||
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")),
|
||||
"/admin/approvalTemplate": dynamic(() => import("@/app/(main)/admin/approvalTemplate/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/approvalBox": dynamic(() => import("@/app/(main)/admin/approvalBox/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/approvalMng": dynamic(() => import("@/app/(main)/admin/approvalMng/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 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/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/aiAssistant": dynamic(() => import("@/app/(main)/admin/aiAssistant/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 기타 관리
|
||||
"/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_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/admin/aiAssistant/dashboard": () => import("@/app/(main)/admin/aiAssistant/dashboard/page"),
|
||||
"/admin/aiAssistant/history": () => import("@/app/(main)/admin/aiAssistant/history/page"),
|
||||
"/admin/aiAssistant/api-keys": () => import("@/app/(main)/admin/aiAssistant/api-keys/page"),
|
||||
"/admin/aiAssistant/api-test": () => import("@/app/(main)/admin/aiAssistant/api-test/page"),
|
||||
"/admin/aiAssistant/usage": () => import("@/app/(main)/admin/aiAssistant/usage/page"),
|
||||
"/admin/aiAssistant/chat": () => import("@/app/(main)/admin/aiAssistant/chat/page"),
|
||||
"/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"),
|
||||
"/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
|
||||
"/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page"),
|
||||
"/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"),
|
||||
};
|
||||
|
||||
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\/([^/]+)$/,
|
||||
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()! }),
|
||||
getImport: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"),
|
||||
extractParams: (m) => ({ id: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"),
|
||||
extractParams: (url) => ({ id: url.split("/").pop()! }),
|
||||
getImport: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"),
|
||||
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 {
|
||||
component: React.ComponentType<any>;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
function DynamicAdminLoader({ url, params }: { url: string; params?: Record<string, string> }) {
|
||||
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
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;
|
||||
useEffect(() => {
|
||||
const staticImport = DYNAMIC_ADMIN_IMPORTS[url];
|
||||
if (staticImport) {
|
||||
staticImport()
|
||||
.then((mod) => setComponent(() => mod.default))
|
||||
.catch(() => setFailed(true));
|
||||
return;
|
||||
}
|
||||
}
|
||||
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 }) {
|
||||
|
|
@ -331,12 +233,8 @@ function AdminPageFallback({ url }: { url: string }) {
|
|||
<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">
|
||||
경로: {url}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
AdminPageRenderer 레지스트리에 이 URL을 추가해주세요.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">경로: {url}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">해당 페이지가 존재하지 않습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -349,53 +247,56 @@ interface AdminPageRendererProps {
|
|||
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
|
||||
// 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링
|
||||
// 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달
|
||||
const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
|
||||
if (screenIdMatch) {
|
||||
const screenId = parseInt(screenIdMatch[1]);
|
||||
return <ScreenViewPageWrapper screenIdProp={screenId} />;
|
||||
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl });
|
||||
|
||||
// 화면 할당: /screens/[id]
|
||||
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
|
||||
if (screensIdMatch) {
|
||||
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\/([^/]+)$/);
|
||||
if (screenCodeMatch) {
|
||||
console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]);
|
||||
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
|
||||
}
|
||||
|
||||
// 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링
|
||||
// Next.js의 params Promise를 우회하여 dashboardId를 직접 전달
|
||||
// 대시보드 할당: /dashboard/[id]
|
||||
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
|
||||
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(() => {
|
||||
// 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;
|
||||
// URL 직접 입력: 레지스트리 매칭
|
||||
const PageComponent = useMemo(() => {
|
||||
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
|
||||
}, [cleanUrl]);
|
||||
|
||||
if (!resolved) {
|
||||
return <AdminPageFallback url={url} />;
|
||||
if (PageComponent) {
|
||||
console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl);
|
||||
return <PageComponent />;
|
||||
}
|
||||
|
||||
const { component: PageComponent, params } = resolved;
|
||||
|
||||
// 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달
|
||||
// Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨
|
||||
if (params) {
|
||||
return <PageComponent params={Promise.resolve(params)} />;
|
||||
// 레지스트리에 없으면 동적 import 시도
|
||||
// 동적 라우트 패턴 매칭 (params 추출)
|
||||
for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
|
||||
const match = cleanUrl.match(pattern);
|
||||
if (match) {
|
||||
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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,12 +202,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
|||
|
||||
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 {
|
||||
id: menuId,
|
||||
objid: menuId,
|
||||
name: displayName,
|
||||
tabTitle,
|
||||
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,
|
||||
hasChildren: children.length > 0,
|
||||
};
|
||||
|
|
@ -341,44 +355,76 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
const handleMenuClick = async (menu: any) => {
|
||||
if (menu.hasChildren) {
|
||||
toggleMenu(menu.id);
|
||||
} else {
|
||||
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("currentMenuName", menuName);
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
const menuObjid = menu.objid || menu.id;
|
||||
console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid);
|
||||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
|
||||
console.log("[handleMenuClick] → 조회 결과:", assignedScreens);
|
||||
if (assignedScreens.length > 0) {
|
||||
const firstScreen = assignedScreens[0];
|
||||
openTab({
|
||||
type: "screen",
|
||||
title: menuName,
|
||||
screenId: firstScreen.screenId,
|
||||
menuObjid: parseInt(menuObjid),
|
||||
});
|
||||
console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId);
|
||||
openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid });
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("할당된 화면 조회 실패:", err);
|
||||
toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.url && menu.url !== "#") {
|
||||
openTab({
|
||||
type: "admin",
|
||||
title: menuName,
|
||||
adminUrl: menu.url,
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
} else {
|
||||
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
||||
console.error("[handleMenuClick] 할당된 화면 조회 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = () => {
|
||||
|
|
|
|||
|
|
@ -238,6 +238,14 @@ function TabPageRenderer({
|
|||
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
|
||||
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) {
|
||||
return (
|
||||
<ScreenViewPageWrapper
|
||||
|
|
@ -256,5 +264,6 @@ function TabPageRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
console.warn("[TabPageRenderer] 렌더링 불가 - 매칭 조건 없음:", tab);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,6 +480,72 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export interface MenuItem {
|
|||
TRANSLATED_DESC?: string;
|
||||
menu_icon?: string;
|
||||
MENU_ICON?: string;
|
||||
screen_code?: string;
|
||||
SCREEN_CODE?: string;
|
||||
}
|
||||
|
||||
export interface MenuFormData {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2InputDefinition } from "./index";
|
||||
import { V2Input } from "@/components/v2/V2Input";
|
||||
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 렌더러
|
||||
|
|
@ -16,41 +84,37 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
|
||||
|
||||
// 컴포넌트 설정 추출
|
||||
const config = component.componentConfig || component.config || {};
|
||||
const columnName = component.columnName;
|
||||
const tableName = component.tableName || this.props.tableName;
|
||||
|
||||
// formData에서 현재 값 가져오기
|
||||
const currentValue = formData?.[columnName] ?? component.value ?? "";
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
|
||||
columnName,
|
||||
value,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
});
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
} else {
|
||||
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
||||
// 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로)
|
||||
const style = component.style || {};
|
||||
const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
|
||||
// labelDisplay: true → 라벨 표시, false → 숨김, 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
|
||||
id={component.id}
|
||||
value={currentValue}
|
||||
|
|
@ -77,10 +141,26 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
{...restProps}
|
||||
label={effectiveLabel}
|
||||
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
|
||||
readonly={config.readonly || component.readonly}
|
||||
readonly={config.readonly || component.readonly || !!dataBinding?.sourceComponentId}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5103,6 +5103,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,9 +118,9 @@ export interface AdditionalTabConfig {
|
|||
// 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
deleteButton?: {
|
||||
|
|
@ -161,9 +161,9 @@ export interface SplitPanelLayoutConfig {
|
|||
// 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
columns?: Array<{
|
||||
|
|
@ -334,10 +334,10 @@ export interface SplitPanelLayoutConfig {
|
|||
|
||||
// 🆕 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean; // 추가 버튼 표시 여부 (기본: true)
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
// 🆕 삭제 버튼 설정
|
||||
|
|
|
|||
|
|
@ -2113,11 +2113,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
|
||||
const handleRowSelection = (rowKey: string, checked: boolean) => {
|
||||
const newSelectedRows = new Set(selectedRows);
|
||||
if (checked) {
|
||||
newSelectedRows.add(rowKey);
|
||||
const isMultiSelect = tableConfig.checkbox?.multiple !== false;
|
||||
let newSelectedRows: Set<string>;
|
||||
|
||||
if (isMultiSelect) {
|
||||
newSelectedRows = new Set(selectedRows);
|
||||
if (checked) {
|
||||
newSelectedRows.add(rowKey);
|
||||
} else {
|
||||
newSelectedRows.delete(rowKey);
|
||||
}
|
||||
} else {
|
||||
newSelectedRows.delete(rowKey);
|
||||
// 단일 선택: 기존 선택 해제 후 새 항목만 선택
|
||||
newSelectedRows = checked ? new Set([rowKey]) : new Set();
|
||||
}
|
||||
setSelectedRows(newSelectedRows);
|
||||
|
||||
|
|
@ -4187,6 +4195,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
const renderCheckboxHeader = () => {
|
||||
if (!tableConfig.checkbox?.selectAll) return null;
|
||||
if (tableConfig.checkbox?.multiple === false) return null;
|
||||
|
||||
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue