Resolve merge conflicts in v2-rack-structure
Made-with: Cursor
This commit is contained in:
commit
31ecf900ce
|
|
@ -51,6 +51,14 @@ 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,5 +1,79 @@
|
|||
# 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. 화면 유형 구분 (절대 규칙!)
|
||||
|
||||
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
|
||||
|
|
@ -7,18 +81,20 @@
|
|||
|
||||
### 관리자 메뉴 (Admin)
|
||||
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
|
||||
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
|
||||
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!**
|
||||
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
|
||||
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
|
||||
- **특징**: 하드코딩된 UI, 관리자만 접근
|
||||
|
||||
### 사용자 메뉴 (User/Screen)
|
||||
### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!!
|
||||
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
|
||||
- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관
|
||||
- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관
|
||||
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
|
||||
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
|
||||
- **대상**: 일반 업무 화면, BOM, 문서 관리 등
|
||||
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
|
||||
- **특징**: 코드 수정 없이 화면 구성 변경 가능
|
||||
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
|
||||
- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업
|
||||
|
||||
### 판단 기준
|
||||
|
||||
|
|
@ -26,8 +102,88 @@
|
|||
|------|-------------|-------------|
|
||||
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
|
||||
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
|
||||
| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 |
|
||||
| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 |
|
||||
| URL 패턴 | `/admin/*` | `/screen/{screen_code}` |
|
||||
| 메뉴 등록 | `menu_info` INSERT | `screen_definitions` + `menu_info` INSERT |
|
||||
| 프론트엔드 코드 | `frontend/app/(main)/admin/` 하위에 page.tsx 작성 | **코드 작성 금지!** DB에 스크린 정의만 등록 |
|
||||
|
||||
### 사용자 메뉴 구현 방법 (반드시 이 방식으로!)
|
||||
|
||||
**절대 규칙: 사용자 메뉴는 React 페이지(.tsx)를 직접 만들지 않는다!**
|
||||
이미 `/screen/[screenCode]/page.tsx` → `/screens/[screenId]/page.tsx` 렌더링 시스템이 존재한다.
|
||||
새 화면이 필요하면 DB에 등록만 하면 자동으로 렌더링된다.
|
||||
|
||||
#### Step 1: screen_definitions에 화면 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active)
|
||||
VALUES ('포장/적재정보 관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y')
|
||||
RETURNING screen_id;
|
||||
```
|
||||
|
||||
- `screen_code`: `{company_code}_{기능약어}` 형식 (예: COMPANY_7_PKG)
|
||||
- `table_name`: 메인 테이블명 (V2 컴포넌트가 이 테이블 기준으로 동작)
|
||||
- `company_code`: 대상 회사 코드
|
||||
|
||||
#### Step 2: screen_layouts_v2에 V2 레이아웃 JSON 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data)
|
||||
VALUES (
|
||||
{screen_id},
|
||||
'COMPANY_7',
|
||||
1,
|
||||
'기본 레이어',
|
||||
'{
|
||||
"version": "2.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp_split_1",
|
||||
"url": "@/lib/registry/components/v2-split-panel-layout",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"size": {"width": 1200, "height": 800},
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"leftTitle": "포장단위 목록",
|
||||
"rightTitle": "상세 정보",
|
||||
"splitRatio": 40,
|
||||
"leftTableName": "pkg_unit",
|
||||
"rightTableName": "pkg_unit",
|
||||
"tabs": [
|
||||
{"id": "basic", "label": "기본정보"},
|
||||
{"id": "items", "label": "매칭품목"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}'::jsonb
|
||||
);
|
||||
```
|
||||
|
||||
- V2 컴포넌트 목록: v2-split-panel-layout, v2-table-list, v2-table-search-widget, v2-repeater, v2-button-primary, v2-tabs-widget 등
|
||||
- 상세 컴포넌트 가이드: `.cursor/rules/component-development-guide.mdc` 참조
|
||||
|
||||
#### Step 3: menu_info에 메뉴 등록
|
||||
|
||||
```sql
|
||||
-- 먼저 부모 메뉴 objid 조회
|
||||
-- SELECT objid, menu_name_kor FROM menu_info WHERE company_code = '{회사코드}' AND menu_name_kor LIKE '%물류%';
|
||||
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, screen_code, company_code, status)
|
||||
VALUES (
|
||||
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
|
||||
2, -- 2 = 메뉴 항목
|
||||
{부모_objid}, -- 상위 메뉴의 objid
|
||||
'포장/적재정보',
|
||||
10, -- 정렬 순서
|
||||
'/screen/COMPANY_7_PKG', -- /screen/{screen_code} 형식 (절대!)
|
||||
'COMPANY_7_PKG', -- screen_definitions.screen_code와 일치
|
||||
'COMPANY_7',
|
||||
'Y'
|
||||
);
|
||||
```
|
||||
|
||||
**핵심**: `menu_url`은 반드시 `/screen/{screen_code}` 형식이어야 한다!
|
||||
프론트엔드가 이 URL을 받아 `screen_definitions`에서 screen_id를 찾고, `screen_layouts_v2`에서 레이아웃을 로드한다.
|
||||
|
||||
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
|
||||
|
||||
|
|
@ -35,11 +191,14 @@
|
|||
|
||||
```sql
|
||||
-- 예시: 결재 템플릿 관리 메뉴 등록
|
||||
INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code)
|
||||
VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드');
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status)
|
||||
VALUES (
|
||||
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
|
||||
2, {부모_objid}, '결재 템플릿', 40, '/admin/approvalTemplate', '대상회사코드', 'Y'
|
||||
);
|
||||
```
|
||||
|
||||
- 기존 메뉴 구조를 먼저 조회해서 parent_id, sort_order 등을 맞춰라
|
||||
- 기존 메뉴 구조를 먼저 조회해서 parent_obj_id, seq 등을 맞춰라
|
||||
- company_code 별로 등록이 필요할 수 있다
|
||||
- menu_auth_group 권한 매핑도 필요하면 추가
|
||||
|
||||
|
|
@ -63,16 +222,26 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr
|
|||
|
||||
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
|
||||
|
||||
### 공통
|
||||
- [ ] DB: 마이그레이션 작성 + 실행 완료
|
||||
- [ ] DB: company_code 컬럼 + 인덱스 존재
|
||||
- [ ] BE: API 엔드포인트 구현 + 라우트 등록
|
||||
- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!)
|
||||
- [ ] BE: company_code 필터링 적용
|
||||
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
|
||||
- [ ] FE: 화면 컴포넌트 구현
|
||||
- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록
|
||||
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
|
||||
|
||||
## 6. 절대 하지 말 것
|
||||
### 관리자 메뉴인 경우
|
||||
- [ ] FE: `frontend/app/(main)/admin/{기능}/page.tsx` 작성
|
||||
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
|
||||
- [ ] DB: `menu_info` INSERT (menu_url = `/admin/{기능}`)
|
||||
|
||||
### 사용자 메뉴인 경우 (코드 작성 금지!)
|
||||
- [ ] DB: `screen_definitions` INSERT (screen_code, table_name, company_code)
|
||||
- [ ] DB: `screen_layouts_v2` INSERT (V2 레이아웃 JSON)
|
||||
- [ ] DB: `menu_info` INSERT (menu_url = `/screen/{screen_code}`)
|
||||
- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만)
|
||||
- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링)
|
||||
|
||||
## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT)
|
||||
|
||||
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
|
||||
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
|
||||
|
|
@ -80,3 +249,39 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr
|
|||
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 경로
|
||||
- 사용자 메뉴는 반드시 `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,6 +8,63 @@ 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!)
|
||||
|
|
@ -49,9 +106,46 @@ export async function getYourData(id: number) {
|
|||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT)
|
||||
|
||||
**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.**
|
||||
사용자 업무 화면(포장관리, 금형관리, 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!
|
||||
```
|
||||
|
||||
## 올바른 패턴 (사용자 메뉴는 DB 등록만으로 완성된다)
|
||||
사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다:
|
||||
1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등)
|
||||
2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등)
|
||||
3. `menu_info` 테이블에 메뉴 등록 (menu_url = `/screen/{screen_code}`)
|
||||
|
||||
이미 존재하는 렌더링 시스템:
|
||||
- `/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 페이지 코딩 가능
|
||||
|
||||
## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT)
|
||||
- `/admin/` 이외 경로에 page.tsx 생성
|
||||
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
|
||||
|
||||
# Your Domain
|
||||
- frontend/components/
|
||||
- frontend/app/
|
||||
- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!)
|
||||
- frontend/lib/
|
||||
- frontend/hooks/
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,43 @@ 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
|
||||
|
|
@ -39,9 +76,24 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
|
|||
- Use cn() for conditional classes
|
||||
- Use lucide-react for ALL icons
|
||||
|
||||
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT)
|
||||
|
||||
사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다.
|
||||
React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지!
|
||||
|
||||
## UI 에이전트가 할 수 있는 것
|
||||
- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`)
|
||||
- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선
|
||||
- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선
|
||||
|
||||
## UI 에이전트가 할 수 없는 것 (위반 시 REJECT)
|
||||
- `/admin/` 이외 경로에 page.tsx 생성 또는 수정
|
||||
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
|
||||
|
||||
# Your Domain
|
||||
- frontend/components/ (UI components)
|
||||
- frontend/app/ (pages)
|
||||
- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**)
|
||||
- frontend/lib/registry/components/v2-*/ (V2 컴포넌트)
|
||||
|
||||
# Output Rules
|
||||
1. TypeScript strict mode
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: pipeline-verifier
|
||||
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
|
||||
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지.
|
||||
model: fast
|
||||
readonly: true
|
||||
---
|
||||
|
|
@ -11,6 +11,29 @@ 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 사용 (클라이언트 입력 아님)
|
||||
|
|
@ -28,6 +51,7 @@ Your job is to verify that work claimed as complete actually works.
|
|||
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
|
||||
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
|
||||
- [ ] Frontend: V2 컴포넌트 규격 준수
|
||||
- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!)
|
||||
- [ ] Backend: logger 사용
|
||||
- [ ] Backend: try/catch 에러 처리
|
||||
|
||||
|
|
@ -39,7 +63,10 @@ Your job is to verify that work claimed as complete actually works.
|
|||
|
||||
# Reporting Format
|
||||
```
|
||||
## 검증 결과: [PASS/FAIL]
|
||||
## 검증 결과: [PASS/FAIL/CRITICAL FAIL]
|
||||
|
||||
### [CRITICAL] 하드코딩 페이지 탐지
|
||||
- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)")
|
||||
|
||||
### 통과 항목
|
||||
- item 1
|
||||
|
|
@ -55,3 +82,4 @@ Your job is to verify that work claimed as complete actually works.
|
|||
```
|
||||
|
||||
Do not accept claims at face value. Check the actual code.
|
||||
하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 FAIL이면 전체 FAIL이다.
|
||||
|
|
|
|||
66
.cursorrules
66
.cursorrules
|
|
@ -1510,3 +1510,69 @@ const query = `
|
|||
|
||||
**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
||||
|
||||
---
|
||||
|
||||
## DB 테이블 생성 필수 규칙
|
||||
|
||||
**상세 가이드**: [table-type-sql-guide.mdc](.cursor/rules/table-type-sql-guide.mdc)
|
||||
|
||||
### 핵심 원칙 (절대 위반 금지)
|
||||
|
||||
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`**: NUMERIC, INTEGER, SERIAL, TEXT 등 DB 타입 직접 지정 금지
|
||||
2. **기본 5개 컬럼 자동 포함** (모든 테이블 필수):
|
||||
```sql
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500)
|
||||
```
|
||||
3. **3개 메타데이터 테이블 등록 필수**:
|
||||
- `table_labels`: 테이블 라벨/설명
|
||||
- `table_type_columns`: 컬럼 input_type, detail_settings (company_code = '*')
|
||||
- `column_labels`: 컬럼 한글 라벨 (레거시 호환)
|
||||
4. **input_type으로 타입 구분**: text, number, date, code, entity, select, checkbox, radio, textarea
|
||||
5. **ON CONFLICT 절 필수**: 중복 시 UPDATE 처리
|
||||
|
||||
### 금지 사항
|
||||
|
||||
- `SERIAL`, `INTEGER`, `NUMERIC`, `BOOLEAN`, `TEXT`, `DATE` 등 DB 타입 직접 사용 금지
|
||||
- `VARCHAR` 길이 변경 금지 (반드시 500)
|
||||
- 기본 5개 컬럼 누락 금지
|
||||
- 메타데이터 테이블 미등록 금지
|
||||
|
||||
---
|
||||
|
||||
## 화면 개발 방식 필수 규칙 (사용자 메뉴 vs 관리자 메뉴)
|
||||
|
||||
**상세 가이드**: [pipeline-common-rules.md](.cursor/agents/pipeline-common-rules.md)
|
||||
|
||||
### 핵심 원칙 (절대 위반 금지)
|
||||
|
||||
1. **사용자 업무 화면은 React 코드(.tsx)로 직접 만들지 않는다!**
|
||||
- 포장관리, 금형관리, BOM, 입출고, 품질 등 일반 업무 화면
|
||||
- DB에 `screen_definitions` + `screen_layouts_v2` + `menu_info` 등록으로 구현
|
||||
- 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재
|
||||
- V2 컴포넌트(v2-split-panel-layout, v2-table-list, v2-repeater 등)로 레이아웃 구성
|
||||
|
||||
2. **관리자 메뉴만 React 코드로 작성 가능**
|
||||
- 사용자 관리, 권한 관리, 시스템 설정 등
|
||||
- `frontend/app/(main)/admin/{기능}/page.tsx`에 작성
|
||||
- `menu_info` 테이블에 메뉴 등록 필수
|
||||
|
||||
### 사용자 메뉴 구현 순서
|
||||
|
||||
```
|
||||
1. DB 테이블 생성 (비즈니스 데이터용)
|
||||
2. screen_definitions INSERT (screen_code, table_name)
|
||||
3. screen_layouts_v2 INSERT (V2 레이아웃 JSON)
|
||||
4. menu_info INSERT (menu_url = '/screen/{screen_code}')
|
||||
5. 필요하면 백엔드 전용 API 추가 (범용 API로 안 되는 경우만)
|
||||
```
|
||||
|
||||
### 금지 사항
|
||||
|
||||
- `frontend/app/(main)/production/*/page.tsx` 같은 사용자 화면 하드코딩 금지
|
||||
- `frontend/app/(main)/warehouse/*/page.tsx` 같은 사용자 화면 하드코딩 금지
|
||||
- 사용자 메뉴의 UI를 React 컴포넌트로 직접 구현하는 것 금지
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관
|
|||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||
import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리
|
||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
|
|
@ -321,6 +322,7 @@ app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
|||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||
app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리
|
||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
|
|
|
|||
|
|
@ -3574,7 +3574,7 @@ export async function getTableSchema(
|
|||
ic.character_maximum_length,
|
||||
ic.numeric_precision,
|
||||
ic.numeric_scale,
|
||||
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
|
||||
COALESCE(NULLIF(ttc_company.column_label, ic.column_name), ttc_common.column_label) AS column_label,
|
||||
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
|
||||
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
|
||||
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AuthService } from "../services/authService";
|
|||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
|
||||
|
||||
export class AuthController {
|
||||
/**
|
||||
|
|
@ -86,13 +87,20 @@ export class AuthController {
|
|||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
}
|
||||
|
||||
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
|
||||
sendSmartFactoryLog({
|
||||
userId: userInfo.userId,
|
||||
remoteAddr,
|
||||
useType: "접속",
|
||||
}).catch(() => {});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
||||
firstMenuPath,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -266,7 +266,6 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
||||
tableName,
|
||||
columnName,
|
||||
columnInputType: columnInputType || "none",
|
||||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
hasFilters: !!filtersParam,
|
||||
|
|
|
|||
|
|
@ -62,24 +62,31 @@ export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Resp
|
|||
*/
|
||||
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
const includeInactive = req.query.includeInactive === "true";
|
||||
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
||||
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
|
||||
|
||||
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
|
||||
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
|
||||
? filterCompanyCode
|
||||
: userCompanyCode;
|
||||
|
||||
logger.info("카테고리 값 조회 요청", {
|
||||
tableName,
|
||||
columnName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
companyCode: effectiveCompanyCode,
|
||||
filterCompanyCode,
|
||||
});
|
||||
|
||||
const values = await tableCategoryValueService.getCategoryValues(
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
effectiveCompanyCode,
|
||||
includeInactive,
|
||||
menuObjid // ← menuObjid 전달
|
||||
menuObjid
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
|
|||
|
|
@ -3105,3 +3105,153 @@ export async function getNumberingColumnsByCompany(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 전 데이터 검증
|
||||
* POST /api/table-management/validate-excel
|
||||
* Body: { tableName, data: Record<string,any>[] }
|
||||
*/
|
||||
export async function validateExcelData(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, data } = req.body as {
|
||||
tableName: string;
|
||||
data: Record<string, any>[];
|
||||
};
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !Array.isArray(data) || data.length === 0) {
|
||||
res.status(400).json({ success: false, message: "tableName과 data 배열이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveCompanyCode =
|
||||
companyCode === "*" && data[0]?.company_code && data[0].company_code !== "*"
|
||||
? data[0].company_code
|
||||
: companyCode;
|
||||
|
||||
let constraintCols = await query<{
|
||||
column_name: string;
|
||||
column_label: string;
|
||||
is_nullable: string;
|
||||
is_unique: string;
|
||||
}>(
|
||||
`SELECT column_name,
|
||||
COALESCE(column_label, column_name) as column_label,
|
||||
COALESCE(is_nullable, 'Y') as is_nullable,
|
||||
COALESCE(is_unique, 'N') as is_unique
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND company_code = $2`,
|
||||
[tableName, effectiveCompanyCode]
|
||||
);
|
||||
|
||||
if (constraintCols.length === 0 && effectiveCompanyCode !== "*") {
|
||||
constraintCols = await query(
|
||||
`SELECT column_name,
|
||||
COALESCE(column_label, column_name) as column_label,
|
||||
COALESCE(is_nullable, 'Y') as is_nullable,
|
||||
COALESCE(is_unique, 'N') as is_unique
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND company_code = '*'`,
|
||||
[tableName]
|
||||
);
|
||||
}
|
||||
|
||||
const autoGenCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||
const notNullCols = constraintCols.filter((c) => c.is_nullable === "N" && !autoGenCols.includes(c.column_name));
|
||||
const uniqueCols = constraintCols.filter((c) => c.is_unique === "Y" && !autoGenCols.includes(c.column_name));
|
||||
|
||||
const notNullErrors: { row: number; column: string; label: string }[] = [];
|
||||
const uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[] = [];
|
||||
const uniqueInDbErrors: { row: number; column: string; label: string; value: string }[] = [];
|
||||
|
||||
// NOT NULL 검증
|
||||
for (const col of notNullCols) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const val = data[i][col.column_name];
|
||||
if (val === null || val === undefined || String(val).trim() === "") {
|
||||
notNullErrors.push({ row: i + 1, column: col.column_name, label: col.column_label });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UNIQUE: 엑셀 내부 중복
|
||||
for (const col of uniqueCols) {
|
||||
const seen = new Map<string, number[]>();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const val = data[i][col.column_name];
|
||||
if (val === null || val === undefined || String(val).trim() === "") continue;
|
||||
const key = String(val).trim();
|
||||
if (!seen.has(key)) seen.set(key, []);
|
||||
seen.get(key)!.push(i + 1);
|
||||
}
|
||||
for (const [value, rows] of seen) {
|
||||
if (rows.length > 1) {
|
||||
uniqueInExcelErrors.push({ rows, column: col.column_name, label: col.column_label, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UNIQUE: DB 기존 데이터와 중복
|
||||
const hasCompanyCode = await query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
for (const col of uniqueCols) {
|
||||
const values = [...new Set(
|
||||
data
|
||||
.map((row) => row[col.column_name])
|
||||
.filter((v) => v !== null && v !== undefined && String(v).trim() !== "")
|
||||
.map((v) => String(v).trim())
|
||||
)];
|
||||
if (values.length === 0) continue;
|
||||
|
||||
let dupQuery: string;
|
||||
let dupParams: any[];
|
||||
const targetCompany = data[0]?.company_code || (effectiveCompanyCode !== "*" ? effectiveCompanyCode : null);
|
||||
|
||||
if (hasCompanyCode.length > 0 && targetCompany) {
|
||||
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1) AND company_code = $2`;
|
||||
dupParams = [values, targetCompany];
|
||||
} else {
|
||||
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1)`;
|
||||
dupParams = [values];
|
||||
}
|
||||
|
||||
const existingRows = await query<Record<string, any>>(dupQuery, dupParams);
|
||||
const existingSet = new Set(existingRows.map((r) => String(r[col.column_name]).trim()));
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const val = data[i][col.column_name];
|
||||
if (val === null || val === undefined || String(val).trim() === "") continue;
|
||||
if (existingSet.has(String(val).trim())) {
|
||||
uniqueInDbErrors.push({ row: i + 1, column: col.column_name, label: col.column_label, value: String(val) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = notNullErrors.length === 0 && uniqueInExcelErrors.length === 0 && uniqueInDbErrors.length === 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isValid,
|
||||
notNullErrors,
|
||||
uniqueInExcelErrors,
|
||||
uniqueInDbErrors,
|
||||
summary: {
|
||||
notNull: notNullErrors.length,
|
||||
uniqueInExcel: uniqueInExcelErrors.length,
|
||||
uniqueInDb: uniqueInDbErrors.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("엑셀 데이터 검증 오류:", error);
|
||||
res.status(500).json({ success: false, message: "데이터 검증 중 오류가 발생했습니다." });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// TODO: 포장/적재정보 관리 API 구현 예정
|
||||
|
||||
export default router;
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
|
||||
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
validateExcelData, // 엑셀 업로드 전 데이터 검증
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
||||
|
|
@ -280,4 +281,9 @@ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
|||
*/
|
||||
router.post("/multi-table-save", multiTableSave);
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 전 데이터 검증
|
||||
*/
|
||||
router.post("/validate-excel", validateExcelData);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1715,8 +1715,8 @@ export class DynamicFormService {
|
|||
`SELECT component_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = $2`,
|
||||
[screenId, "component"]
|
||||
AND component_type IN ('component', 'v2-button-primary')`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
||||
|
|
@ -1747,8 +1747,12 @@ export class DynamicFormService {
|
|||
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||
|
||||
const isButtonComponent =
|
||||
properties?.componentType === "button-primary" ||
|
||||
properties?.componentType === "v2-button-primary";
|
||||
|
||||
if (
|
||||
properties?.componentType === "button-primary" &&
|
||||
isButtonComponent &&
|
||||
isMatchingAction &&
|
||||
properties?.webTypeConfig?.enableDataflowControl === true
|
||||
) {
|
||||
|
|
@ -1877,7 +1881,7 @@ export class DynamicFormService {
|
|||
{
|
||||
sourceData: [savedData],
|
||||
dataSourceType: "formData",
|
||||
buttonId: "save-button",
|
||||
buttonId: `${triggerType}-button`,
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
|
|
|
|||
|
|
@ -972,7 +972,7 @@ class MultiTableExcelService {
|
|||
c.column_name,
|
||||
c.is_nullable AS db_is_nullable,
|
||||
c.column_default,
|
||||
COALESCE(ttc.column_label, cl.column_label) AS column_label,
|
||||
COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label) AS column_label,
|
||||
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
|
||||
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
|
||||
FROM information_schema.columns c
|
||||
|
|
|
|||
|
|
@ -2346,19 +2346,24 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료)
|
||||
* 메뉴별 화면 목록 조회
|
||||
* company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회
|
||||
* 본인 회사 할당이 우선, 없으면 글로벌 할당 사용
|
||||
*/
|
||||
async getScreensByMenu(
|
||||
menuObjid: number,
|
||||
companyCode: string,
|
||||
): Promise<ScreenDefinition[]> {
|
||||
const screens = await query<any>(
|
||||
`SELECT sd.* FROM screen_menu_assignments sma
|
||||
`SELECT sd.*, sma.company_code AS assign_company_code
|
||||
FROM screen_menu_assignments sma
|
||||
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||
WHERE sma.menu_objid = $1
|
||||
AND sma.company_code = $2
|
||||
AND (sma.company_code = $2 OR sma.company_code = '*')
|
||||
AND sma.is_active = 'Y'
|
||||
ORDER BY sma.display_order ASC`,
|
||||
ORDER BY
|
||||
CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END,
|
||||
sma.display_order ASC`,
|
||||
[menuObjid, companyCode],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -217,12 +217,12 @@ class TableCategoryValueService {
|
|||
AND column_name = $2
|
||||
`;
|
||||
|
||||
// category_values 테이블 사용 (menu_objid 없음)
|
||||
// company_code 기반 필터링
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 값 조회
|
||||
query = baseSelect;
|
||||
// 최고 관리자: 공통(*) 카테고리만 조회 (모든 회사 카테고리 혼합 방지)
|
||||
query = baseSelect + ` AND company_code = '*'`;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
|
||||
logger.info("최고 관리자: 공통 카테고리만 조회 (category_values)");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
||||
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export class TableManagementService {
|
|||
? await query<any>(
|
||||
`SELECT
|
||||
c.column_name as "columnName",
|
||||
COALESCE(ttc.column_label, cl.column_label, c.column_name) as "displayName",
|
||||
COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label, c.column_name) as "displayName",
|
||||
c.data_type as "dataType",
|
||||
c.data_type as "dbType",
|
||||
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
// 스마트공장 활용 로그 전송 유틸리티
|
||||
// https://log.smart-factory.kr 에 사용자 접속 로그를 전송
|
||||
|
||||
import axios from "axios";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const SMART_FACTORY_LOG_URL =
|
||||
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do";
|
||||
|
||||
/**
|
||||
* 스마트공장 활용 로그 전송
|
||||
* 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음
|
||||
*/
|
||||
export async function sendSmartFactoryLog(params: {
|
||||
userId: string;
|
||||
remoteAddr: string;
|
||||
useType?: string;
|
||||
}): Promise<void> {
|
||||
const apiKey = process.env.SMART_FACTORY_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn(
|
||||
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const logDt = formatDateTime(now);
|
||||
|
||||
const logData = {
|
||||
crtfcKey: apiKey,
|
||||
logDt,
|
||||
useSe: params.useType || "접속",
|
||||
sysUser: params.userId,
|
||||
conectIp: params.remoteAddr,
|
||||
dataUsgqty: "",
|
||||
};
|
||||
|
||||
const encodedLogData = encodeURIComponent(JSON.stringify(logData));
|
||||
|
||||
const response = await axios.get(SMART_FACTORY_LOG_URL, {
|
||||
params: { logData: encodedLogData },
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
logger.info("스마트공장 로그 전송 완료", {
|
||||
userId: params.userId,
|
||||
status: response.status,
|
||||
});
|
||||
} catch (error) {
|
||||
// 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록
|
||||
logger.error("스마트공장 로그 전송 실패", {
|
||||
userId: params.userId,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd HH:mm:ss.SSS 형식 */
|
||||
function formatDateTime(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const M = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const H = String(date.getHours()).padStart(2, "0");
|
||||
const m = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
||||
return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
FileSpreadsheet,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
Copy,
|
||||
|
|
@ -136,6 +137,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
// 중복 처리 방법 (전역 설정)
|
||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||
|
||||
// 엑셀 데이터 사전 검증 결과
|
||||
const [isDataValidating, setIsDataValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
||||
|
||||
// 카테고리 검증 관련
|
||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||
|
|
@ -874,6 +879,43 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setShowCategoryValidation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
|
||||
setIsDataValidating(true);
|
||||
try {
|
||||
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
|
||||
|
||||
// 매핑된 데이터 구성
|
||||
const mappedForValidation = allData.map((row) => {
|
||||
const mapped: Record<string, any> = {};
|
||||
columnMappings.forEach((m) => {
|
||||
if (m.systemColumn) {
|
||||
let colName = m.systemColumn;
|
||||
if (isMasterDetail && colName.includes(".")) {
|
||||
colName = colName.split(".")[1];
|
||||
}
|
||||
mapped[colName] = row[m.excelColumn];
|
||||
}
|
||||
});
|
||||
return mapped;
|
||||
}).filter((row) => Object.values(row).some((v) => v !== null && v !== undefined && String(v).trim() !== ""));
|
||||
|
||||
if (mappedForValidation.length > 0) {
|
||||
const result = await validateExcel(tableName, mappedForValidation);
|
||||
if (result.success && result.data) {
|
||||
setValidationResult(result.data);
|
||||
} else {
|
||||
setValidationResult(null);
|
||||
}
|
||||
} else {
|
||||
setValidationResult(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("데이터 사전 검증 실패 (무시):", err);
|
||||
setValidationResult(null);
|
||||
} finally {
|
||||
setIsDataValidating(false);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
|
|
@ -1301,6 +1343,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setSystemColumns([]);
|
||||
setColumnMappings([]);
|
||||
setDuplicateAction("skip");
|
||||
// 검증 상태 초기화
|
||||
setValidationResult(null);
|
||||
setIsDataValidating(false);
|
||||
// 카테고리 검증 초기화
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
|
|
@ -1870,6 +1915,100 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 검증 결과 */}
|
||||
{validationResult && !validationResult.isValid && (
|
||||
<div className="space-y-3">
|
||||
{/* NOT NULL 에러 */}
|
||||
{validationResult.notNullErrors.length > 0 && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
||||
<XCircle className="h-4 w-4" />
|
||||
필수값 누락 ({validationResult.notNullErrors.length}건)
|
||||
</h3>
|
||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
||||
{(() => {
|
||||
const grouped = new Map<string, number[]>();
|
||||
for (const err of validationResult.notNullErrors) {
|
||||
const key = err.label;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push(err.row);
|
||||
}
|
||||
return Array.from(grouped).map(([label, rows]) => (
|
||||
<p key={label}>
|
||||
<span className="font-medium">{label}</span>: {rows.length > 5 ? `행 ${rows.slice(0, 5).join(", ")} 외 ${rows.length - 5}건` : `행 ${rows.join(", ")}`}
|
||||
</p>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 엑셀 내부 중복 */}
|
||||
{validationResult.uniqueInExcelErrors.length > 0 && (
|
||||
<div className="rounded-md border border-warning bg-warning/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-warning sm:text-base">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
엑셀 내 중복 ({validationResult.uniqueInExcelErrors.length}건)
|
||||
</h3>
|
||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-warning sm:text-xs">
|
||||
{validationResult.uniqueInExcelErrors.slice(0, 10).map((err, i) => (
|
||||
<p key={i}>
|
||||
<span className="font-medium">{err.label}</span> "{err.value}": 행 {err.rows.join(", ")}
|
||||
</p>
|
||||
))}
|
||||
{validationResult.uniqueInExcelErrors.length > 10 && (
|
||||
<p className="font-medium">...외 {validationResult.uniqueInExcelErrors.length - 10}건</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DB 기존 데이터 중복 */}
|
||||
{validationResult.uniqueInDbErrors.length > 0 && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
||||
<XCircle className="h-4 w-4" />
|
||||
DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건)
|
||||
</h3>
|
||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
||||
{(() => {
|
||||
const grouped = new Map<string, { value: string; rows: number[] }[]>();
|
||||
for (const err of validationResult.uniqueInDbErrors) {
|
||||
const key = err.label;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
const existing = grouped.get(key)!.find((e) => e.value === err.value);
|
||||
if (existing) existing.rows.push(err.row);
|
||||
else grouped.get(key)!.push({ value: err.value, rows: [err.row] });
|
||||
}
|
||||
return Array.from(grouped).map(([label, items]) => (
|
||||
<div key={label}>
|
||||
{items.slice(0, 5).map((item, i) => (
|
||||
<p key={i}>
|
||||
<span className="font-medium">{label}</span> "{item.value}": 행 {item.rows.join(", ")}
|
||||
</p>
|
||||
))}
|
||||
{items.length > 5 && <p className="font-medium">...외 {items.length - 5}건</p>}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult?.isValid && (
|
||||
<div className="rounded-md border border-success bg-success/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-success sm:text-base">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
데이터 검증 통과
|
||||
</h3>
|
||||
<p className="mt-1 text-[10px] text-success sm:text-xs">
|
||||
필수값 및 중복 검사를 통과했습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
||||
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
|
|
@ -1948,10 +2087,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
{currentStep < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||
disabled={isUploading || isCategoryValidating || isDataValidating || (currentStep === 1 && !file)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isCategoryValidating ? (
|
||||
{isCategoryValidating || isDataValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
검증 중...
|
||||
|
|
@ -1964,11 +2103,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={
|
||||
isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
|
||||
isUploading ||
|
||||
columnMappings.filter((m) => m.systemColumn).length === 0 ||
|
||||
(validationResult !== null && !validationResult.isValid)
|
||||
}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isUploading ? "업로드 중..." : "업로드"}
|
||||
{isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -10,70 +12,320 @@ const LoadingFallback = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const d = (loader: () => Promise<any>) =>
|
||||
dynamic(loader, { ssr: false, loading: LoadingFallback });
|
||||
|
||||
/**
|
||||
* /dashboard/[dashboardId] URL을 탭 내에서 직접 렌더링
|
||||
* Next.js params Promise 없이 dashboardId를 직접 전달
|
||||
*/
|
||||
const LazyDashboardViewer = d(() => import("@/components/dashboard/DashboardViewer").then((mod) => ({
|
||||
default: mod.DashboardViewer,
|
||||
})));
|
||||
|
||||
function DashboardTabRenderer({ dashboardId }: { dashboardId: string }) {
|
||||
const [dashboard, setDashboard] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const data = await dashboardApi.getDashboard(dashboardId);
|
||||
setDashboard({ ...data, elements: data.elements || [] });
|
||||
} catch {
|
||||
const saved = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||
const found = saved.find((d: any) => d.id === dashboardId);
|
||||
if (found) {
|
||||
setDashboard(found);
|
||||
} else {
|
||||
setError("대시보드를 찾을 수 없습니다");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [dashboardId]);
|
||||
|
||||
if (isLoading) return <LoadingFallback />;
|
||||
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-foreground">{error || "대시보드를 찾을 수 없습니다"}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">대시보드 ID: {dashboardId}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<LazyDashboardViewer
|
||||
elements={dashboard.elements}
|
||||
dashboardId={dashboard.id}
|
||||
dashboardTitle={dashboard.title}
|
||||
backgroundColor={dashboard.settings?.backgroundColor}
|
||||
resolution={dashboard.settings?.resolution}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* /screen/[screenCode] URL을 screenId로 변환해서 ScreenViewPageWrapper를 렌더링
|
||||
*/
|
||||
function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
|
||||
const [screenId, setScreenId] = useState<number | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const numericId = parseInt(screenCode);
|
||||
if (!isNaN(numericId)) {
|
||||
setScreenId(numericId);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolve = async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/screen-management/screens", {
|
||||
params: { searchTerm: screenCode, size: 50 },
|
||||
});
|
||||
const items = res.data?.data?.data || res.data?.data || [];
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
const exact = arr.find((s: any) => s.screenCode === screenCode || s.screen_code === screenCode);
|
||||
const target = exact || arr[0];
|
||||
if (target) {
|
||||
setScreenId(target.screenId || target.screen_id);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
resolve();
|
||||
}, [screenCode]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-foreground">화면을 찾을 수 없습니다</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
화면 코드: {screenCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenId === null) {
|
||||
return <LoadingFallback />;
|
||||
}
|
||||
|
||||
return <ScreenViewPageWrapper screenIdProp={screenId} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
||||
* 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다.
|
||||
* 매핑되지 않은 URL은 catch-all fallback으로 처리된다.
|
||||
* 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다.
|
||||
*/
|
||||
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
// 관리자 메인
|
||||
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin": d(() => import("@/app/(main)/admin/page")),
|
||||
|
||||
// 메뉴 관리
|
||||
"/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/menu": d(() => import("@/app/(main)/admin/menu/page")),
|
||||
|
||||
// 사용자 관리
|
||||
"/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/userMng/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/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/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/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/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/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/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/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/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/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/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/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")),
|
||||
"/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")),
|
||||
|
||||
// 기타
|
||||
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/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 }),
|
||||
// 결재 관리
|
||||
"/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")),
|
||||
"/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")),
|
||||
"/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")),
|
||||
|
||||
// AI 어시스턴트
|
||||
"/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")),
|
||||
"/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")),
|
||||
"/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")),
|
||||
"/admin/aiAssistant/api-keys": d(() => import("@/app/(main)/admin/aiAssistant/api-keys/page")),
|
||||
"/admin/aiAssistant/dashboard": d(() => import("@/app/(main)/admin/aiAssistant/dashboard/page")),
|
||||
"/admin/aiAssistant/chat": d(() => import("@/app/(main)/admin/aiAssistant/chat/page")),
|
||||
"/admin/aiAssistant/api-test": d(() => import("@/app/(main)/admin/aiAssistant/api-test/page")),
|
||||
|
||||
// 기타 관리
|
||||
"/admin/cascading-management": d(() => import("@/app/(main)/admin/cascading-management/page")),
|
||||
"/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")),
|
||||
"/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")),
|
||||
"/admin/templates": d(() => import("@/app/(main)/admin/templates/page")),
|
||||
"/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")),
|
||||
"/admin/standards": d(() => import("@/app/(main)/admin/standards/page")),
|
||||
"/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")),
|
||||
"/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")),
|
||||
"/admin/system-notices": d(() => import("@/app/(main)/admin/system-notices/page")),
|
||||
"/admin/audit-log": d(() => import("@/app/(main)/admin/audit-log/page")),
|
||||
|
||||
// 개발/테스트
|
||||
"/admin/debug": d(() => import("@/app/(main)/admin/debug/page")),
|
||||
"/admin/debug-simple": d(() => import("@/app/(main)/admin/debug-simple/page")),
|
||||
"/admin/debug-layout": d(() => import("@/app/(main)/admin/debug-layout/page")),
|
||||
"/admin/test": d(() => import("@/app/(main)/admin/test/page")),
|
||||
"/admin/ui-components-demo": d(() => import("@/app/(main)/admin/ui-components-demo/page")),
|
||||
"/admin/validation-demo": d(() => import("@/app/(main)/admin/validation-demo/page")),
|
||||
"/admin/token-test": d(() => import("@/app/(main)/admin/token-test/page")),
|
||||
|
||||
// === 사용자 화면 (admin이 아닌 URL 기반 메뉴) ===
|
||||
"/approval": d(() => import("@/app/(main)/approval/page")),
|
||||
"/dashboard": d(() => import("@/app/(main)/dashboard/page")),
|
||||
"/multilang": d(() => import("@/app/(main)/multilang/page")),
|
||||
"/test-flow": d(() => import("@/app/(main)/test-flow/page")),
|
||||
"/main": d(() => import("@/app/(main)/main/page")),
|
||||
};
|
||||
|
||||
// 매핑되지 않은 URL용 Fallback
|
||||
/**
|
||||
* 동적 라우트 패턴 매칭 (URL 경로에 동적 세그먼트가 포함된 경우)
|
||||
* /admin/screenMng/dashboardList/123 → dashboardList/[id] 페이지에 매핑
|
||||
*
|
||||
* extractParams: URL에서 동적 파라미터를 추출 (use(params)를 쓰는 페이지용)
|
||||
* 추출된 값은 params={Promise.resolve(...)}로 전달되어
|
||||
* Next.js 라우팅 컨텍스트 없이도 use(params)가 정상 동작함
|
||||
*/
|
||||
interface DynamicRouteEntry {
|
||||
pattern: RegExp;
|
||||
loader: () => Promise<any>;
|
||||
extractParams?: (url: string) => Record<string, string>;
|
||||
}
|
||||
|
||||
const DYNAMIC_ROUTE_PATTERNS: DynamicRouteEntry[] = [
|
||||
{
|
||||
pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"),
|
||||
extractParams: (url) => ({ id: url.split("/").pop()! }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
|
||||
loader: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
|
||||
extractParams: (url) => ({ companyCode: url.split("/")[4] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/automaticMng\/batchmngList\/create$/,
|
||||
loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"),
|
||||
extractParams: (url) => ({ id: url.split("/").pop()! }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"),
|
||||
extractParams: (url) => ({ id: url.split("/").pop()! }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/standards\/new$/,
|
||||
loader: () => import("@/app/(main)/admin/standards/new/page"),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
|
||||
loader: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
|
||||
extractParams: (url) => ({ webType: url.split("/")[3] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/standards\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/standards/[webType]/page"),
|
||||
extractParams: (url) => ({ webType: url.split("/").pop()! }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
|
||||
extractParams: (url) => ({ diagramId: url.split("/").pop()! }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"),
|
||||
extractParams: (url) => ({ labelId: url.split("/").pop()! }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"),
|
||||
extractParams: (url) => ({ reportId: url.split("/").pop()! }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/,
|
||||
loader: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"),
|
||||
extractParams: (url) => ({ id: url.split("/").pop()! }),
|
||||
},
|
||||
];
|
||||
|
||||
interface DynamicRouteResult {
|
||||
component: React.ComponentType<any>;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
const dynamicRouteCache = new Map<string, DynamicRouteResult>();
|
||||
|
||||
function resolveDynamicRoute(cleanUrl: string): DynamicRouteResult | null {
|
||||
if (dynamicRouteCache.has(cleanUrl)) {
|
||||
return dynamicRouteCache.get(cleanUrl)!;
|
||||
}
|
||||
|
||||
for (const entry of DYNAMIC_ROUTE_PATTERNS) {
|
||||
if (entry.pattern.test(cleanUrl)) {
|
||||
const comp = d(entry.loader);
|
||||
const params = entry.extractParams?.(cleanUrl);
|
||||
const result: DynamicRouteResult = { component: comp, params };
|
||||
dynamicRouteCache.set(cleanUrl, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function AdminPageFallback({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -95,15 +347,55 @@ interface AdminPageRendererProps {
|
|||
}
|
||||
|
||||
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
const PageComponent = useMemo(() => {
|
||||
// URL에서 쿼리스트링/해시 제거 후 매칭
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
|
||||
}, [url]);
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
|
||||
if (!PageComponent) {
|
||||
// 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링
|
||||
// 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달
|
||||
const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
|
||||
if (screenIdMatch) {
|
||||
const screenId = parseInt(screenIdMatch[1]);
|
||||
return <ScreenViewPageWrapper screenIdProp={screenId} />;
|
||||
}
|
||||
|
||||
// 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링
|
||||
const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/);
|
||||
if (screenCodeMatch) {
|
||||
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
|
||||
}
|
||||
|
||||
// 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링
|
||||
// Next.js의 params Promise를 우회하여 dashboardId를 직접 전달
|
||||
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
|
||||
if (dashboardMatch) {
|
||||
return <DashboardTabRenderer dashboardId={dashboardMatch[1]} />;
|
||||
}
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
// 1) 정적 레지스트리 매칭
|
||||
if (ADMIN_PAGE_REGISTRY[cleanUrl]) {
|
||||
return { component: ADMIN_PAGE_REGISTRY[cleanUrl] } as DynamicRouteResult;
|
||||
}
|
||||
|
||||
// 2) 동적 라우트 패턴 매칭 (/admin/xxx/[id] 등)
|
||||
const dynamicMatch = resolveDynamicRoute(cleanUrl);
|
||||
if (dynamicMatch) {
|
||||
return dynamicMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [cleanUrl]);
|
||||
|
||||
if (!resolved) {
|
||||
return <AdminPageFallback url={url} />;
|
||||
}
|
||||
|
||||
const { component: PageComponent, params } = resolved;
|
||||
|
||||
// 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달
|
||||
// Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨
|
||||
if (params) {
|
||||
return <PageComponent params={Promise.resolve(params)} />;
|
||||
}
|
||||
|
||||
return <PageComponent />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,8 +362,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.warn("할당된 화면 조회 실패");
|
||||
} catch (err) {
|
||||
console.error("할당된 화면 조회 실패:", err);
|
||||
toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.url && menu.url !== "#") {
|
||||
|
|
|
|||
|
|
@ -1850,7 +1850,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
try {
|
||||
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
||||
|
||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
|
||||
|
||||
if (result.success) {
|
||||
alert("삭제되었습니다.");
|
||||
|
|
|
|||
|
|
@ -372,3 +372,30 @@ export const getTableColumns = (tableName: string) => tableManagementApi.getColu
|
|||
export const updateColumnType = (tableName: string, columnName: string, settings: ColumnSettings) =>
|
||||
tableManagementApi.updateColumnSettings(tableName, columnName, settings);
|
||||
export const checkTableExists = (tableName: string) => tableManagementApi.checkTableExists(tableName);
|
||||
|
||||
// 엑셀 업로드 전 데이터 검증 API
|
||||
export interface ExcelValidationResult {
|
||||
isValid: boolean;
|
||||
notNullErrors: { row: number; column: string; label: string }[];
|
||||
uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[];
|
||||
uniqueInDbErrors: { row: number; column: string; label: string; value: string }[];
|
||||
summary: { notNull: number; uniqueInExcel: number; uniqueInDb: number };
|
||||
}
|
||||
|
||||
export async function validateExcelData(
|
||||
tableName: string,
|
||||
data: Record<string, any>[]
|
||||
): Promise<ApiResponse<ExcelValidationResult>> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<ExcelValidationResult>>(
|
||||
"/table-management/validate-excel",
|
||||
{ tableName, data }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "데이터 검증 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
GeneratedLocation,
|
||||
RackStructureContext,
|
||||
} from "./types";
|
||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
|
||||
|
||||
// 기존 위치 데이터 타입
|
||||
interface ExistingLocation {
|
||||
|
|
@ -512,23 +513,27 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
return { totalLocations, totalRows, maxLevel };
|
||||
}, [conditions]);
|
||||
|
||||
// 위치 코드 생성
|
||||
// 위치 코드 생성 (패턴 기반)
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor || "1";
|
||||
const zone = context?.zone || "A";
|
||||
const vars = {
|
||||
warehouse: context?.warehouseCode || "WH001",
|
||||
warehouseName: context?.warehouseName || "",
|
||||
floor: context?.floor || "1",
|
||||
zone: context?.zone || "A",
|
||||
row,
|
||||
level,
|
||||
};
|
||||
|
||||
// 코드 생성 (예: WH001-1층D구역-01-1)
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
|
||||
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
|
||||
|
||||
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
|
||||
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||
|
||||
return { code, name };
|
||||
return {
|
||||
code: applyLocationPattern(codePattern, vars),
|
||||
name: applyLocationPattern(namePattern, vars),
|
||||
};
|
||||
},
|
||||
[context],
|
||||
[context, config.codePattern, config.namePattern],
|
||||
);
|
||||
|
||||
// 미리보기 생성
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -12,6 +12,47 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
|
||||
|
||||
// 패턴 미리보기 서브 컴포넌트
|
||||
const PatternPreview: React.FC<{
|
||||
codePattern?: string;
|
||||
namePattern?: string;
|
||||
}> = ({ codePattern, namePattern }) => {
|
||||
const sampleVars = {
|
||||
warehouse: "WH002",
|
||||
warehouseName: "2창고",
|
||||
floor: "2층",
|
||||
zone: "A구역",
|
||||
row: 1,
|
||||
level: 3,
|
||||
};
|
||||
|
||||
const previewCode = useMemo(
|
||||
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
|
||||
[codePattern],
|
||||
);
|
||||
const previewName = useMemo(
|
||||
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
|
||||
[namePattern],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
|
||||
<div className="mb-1.5 text-[10px] font-medium text-primary">미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">위치코드:</span>
|
||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">위치명:</span>
|
||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RackStructureConfigPanelProps {
|
||||
config: RackStructureComponentConfig;
|
||||
|
|
@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치코드 패턴 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">위치코드/위치명 패턴</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요
|
||||
</p>
|
||||
|
||||
{/* 위치코드 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치코드 패턴</Label>
|
||||
<Input
|
||||
value={config.codePattern || ""}
|
||||
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
|
||||
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 위치명 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치명 패턴</Label>
|
||||
<Input
|
||||
value={config.namePattern || ""}
|
||||
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
|
||||
placeholder="{zone}-{row:02}열-{level}단"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{zone}-{row:02}열-{level}단"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 실시간 미리보기 */}
|
||||
<PatternPreview
|
||||
codePattern={config.codePattern}
|
||||
namePattern={config.namePattern}
|
||||
/>
|
||||
|
||||
{/* 사용 가능한 변수 목록 */}
|
||||
<div className="rounded-md border bg-muted/50 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium text-foreground">사용 가능한 변수</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||||
{PATTERN_VARIABLES.map((v) => (
|
||||
<div key={v.token} className="flex items-center gap-1 text-[10px]">
|
||||
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
|
||||
<span className="text-muted-foreground">{v.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제한 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
// rack-structure는 v2-rack-structure의 patternUtils를 재사용
|
||||
export {
|
||||
applyLocationPattern,
|
||||
DEFAULT_CODE_PATTERN,
|
||||
DEFAULT_NAME_PATTERN,
|
||||
PATTERN_VARIABLES,
|
||||
} from "../v2-rack-structure/patternUtils";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -222,6 +222,61 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치코드 패턴 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">위치코드/위치명 패턴</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요
|
||||
</p>
|
||||
|
||||
{/* 위치코드 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치코드 패턴</Label>
|
||||
<Input
|
||||
value={config.codePattern || ""}
|
||||
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
|
||||
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 위치명 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치명 패턴</Label>
|
||||
<Input
|
||||
value={config.namePattern || ""}
|
||||
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
|
||||
placeholder="{zone}-{row:02}열-{level}단"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{zone}-{row:02}열-{level}단"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 실시간 미리보기 */}
|
||||
<PatternPreview
|
||||
codePattern={config.codePattern}
|
||||
namePattern={config.namePattern}
|
||||
/>
|
||||
|
||||
{/* 사용 가능한 변수 목록 */}
|
||||
<div className="rounded-md border bg-muted/50 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium text-foreground">사용 가능한 변수</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||||
{PATTERN_VARIABLES.map((v) => (
|
||||
<div key={v.token} className="flex items-center gap-1 text-[10px]">
|
||||
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
|
||||
<span className="text-muted-foreground">{v.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제한 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* 위치코드/위치명 패턴 변환 유틸리티
|
||||
*
|
||||
* 사용 가능한 변수:
|
||||
* {warehouse} - 창고 코드 (예: WH002)
|
||||
* {warehouseName} - 창고명 (예: 2창고)
|
||||
* {floor} - 층 (예: 2층)
|
||||
* {zone} - 구역 (예: A구역)
|
||||
* {row} - 열 번호 (예: 1)
|
||||
* {row:02} - 열 번호 2자리 (예: 01)
|
||||
* {row:03} - 열 번호 3자리 (예: 001)
|
||||
* {level} - 단 번호 (예: 1)
|
||||
* {level:02} - 단 번호 2자리 (예: 01)
|
||||
* {level:03} - 단 번호 3자리 (예: 001)
|
||||
*/
|
||||
|
||||
interface PatternVariables {
|
||||
warehouse?: string;
|
||||
warehouseName?: string;
|
||||
floor?: string;
|
||||
zone?: string;
|
||||
row: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// 기본 패턴 (하드코딩 대체)
|
||||
export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}";
|
||||
export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단";
|
||||
|
||||
/**
|
||||
* 패턴 문자열에서 변수를 치환하여 결과 문자열 반환
|
||||
*/
|
||||
export function applyLocationPattern(pattern: string, vars: PatternVariables): string {
|
||||
let result = pattern;
|
||||
|
||||
// zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환
|
||||
const simpleVars: Record<string, string | undefined> = {
|
||||
warehouse: vars.warehouse,
|
||||
warehouseName: vars.warehouseName,
|
||||
floor: vars.floor,
|
||||
zone: vars.zone,
|
||||
};
|
||||
|
||||
// 단순 문자열 변수 치환
|
||||
for (const [key, value] of Object.entries(simpleVars)) {
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || "");
|
||||
}
|
||||
|
||||
// 숫자 변수 (row, level) - zero-pad 지원
|
||||
const numericVars: Record<string, number> = {
|
||||
row: vars.row,
|
||||
level: vars.level,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(numericVars)) {
|
||||
// {row:02}, {level:03} 같은 zero-pad 패턴
|
||||
const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g");
|
||||
result = result.replace(padRegex, (_, padWidth) => {
|
||||
return value.toString().padStart(parseInt(padWidth), "0");
|
||||
});
|
||||
|
||||
// {row}, {level} 같은 단순 패턴
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 패턴에서 사용 가능한 변수 목록
|
||||
export const PATTERN_VARIABLES = [
|
||||
{ token: "{warehouse}", description: "창고 코드", example: "WH002" },
|
||||
{ token: "{warehouseName}", description: "창고명", example: "2창고" },
|
||||
{ token: "{floor}", description: "층", example: "2층" },
|
||||
{ token: "{zone}", description: "구역", example: "A구역" },
|
||||
{ token: "{row}", description: "열 번호", example: "1" },
|
||||
{ token: "{row:02}", description: "열 번호 (2자리)", example: "01" },
|
||||
{ token: "{row:03}", description: "열 번호 (3자리)", example: "001" },
|
||||
{ token: "{level}", description: "단 번호", example: "1" },
|
||||
{ token: "{level:02}", description: "단 번호 (2자리)", example: "01" },
|
||||
{ token: "{level:03}", description: "단 번호 (3자리)", example: "001" },
|
||||
];
|
||||
|
|
@ -729,7 +729,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [categoryMappings, setCategoryMappings] = useState<
|
||||
Record<string, Record<string, { label: string; color?: string }>>
|
||||
>({});
|
||||
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
|
||||
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0);
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
|
|
@ -1064,9 +1064,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const getColumnUniqueValues = async (columnName: string) => {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
// 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링
|
||||
const filterParam = companyCode && companyCode !== "*"
|
||||
? `?filterCompanyCode=${encodeURIComponent(companyCode)}`
|
||||
: "";
|
||||
|
||||
// 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도)
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values${filterParam}`);
|
||||
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||
return response.data.data.map((item: any) => ({
|
||||
value: item.valueCode,
|
||||
|
|
@ -1171,15 +1176,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
tableConfig.selectedTable,
|
||||
tableConfig.columns,
|
||||
columnLabels,
|
||||
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
|
||||
categoryMappings, // 카테고리 매핑 변경 시 재등록 (필터 라벨 변환용)
|
||||
columnMeta,
|
||||
categoryMappings,
|
||||
columnWidths,
|
||||
tableLabel,
|
||||
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
||||
totalItems, // 전체 항목 수가 변경되면 재등록
|
||||
data,
|
||||
totalItems,
|
||||
registerTable,
|
||||
// unregisterTable은 의존성에서 제외 - 무한 루프 방지
|
||||
// unregisterTable 함수는 의존성이 없어 안정적임
|
||||
]);
|
||||
|
||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
|
||||
|
|
@ -1423,7 +1426,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
// 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링
|
||||
const filterCompanyParam = companyCode && companyCode !== "*"
|
||||
? `&filterCompanyCode=${encodeURIComponent(companyCode)}`
|
||||
: "";
|
||||
|
||||
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
|
||||
// valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴)
|
||||
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
|
||||
items.forEach((item: any) => {
|
||||
if (item.valueCode) {
|
||||
|
|
@ -1432,12 +1441,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenTree(item.children, mapping);
|
||||
}
|
||||
|
|
@ -1465,7 +1468,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
|
||||
// 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`);
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true${filterCompanyParam}`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
@ -1548,7 +1551,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// inputType이 category인 경우 카테고리 매핑 로드
|
||||
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`);
|
||||
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true${filterCompanyParam}`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
@ -1618,6 +1621,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
JSON.stringify(categoryColumns),
|
||||
JSON.stringify(tableConfig.columns),
|
||||
columnMeta,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -1508,7 +1508,38 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-primary/80">
|
||||
{isAlreadyAdded && (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
|
||||
? "편집 잠금 (클릭하여 해제)"
|
||||
: "편집 가능 (클릭하여 잠금)"
|
||||
}
|
||||
className={cn(
|
||||
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
||||
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentCol = config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias);
|
||||
if (currentCol) {
|
||||
updateColumn(matchingJoinColumn.joinAlias, {
|
||||
editable: currentCol.editable === false ? undefined : false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false ? (
|
||||
<Lock className="h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className={cn("text-[10px] text-primary/80", !isAlreadyAdded && "ml-auto")}>
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -457,7 +457,38 @@ export const ColumnsConfigPanel: React.FC<ColumnsConfigPanelProps> = ({
|
|||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-primary/80">
|
||||
{isAlreadyAdded && (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
config.columns?.find((c: ColumnConfig) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
|
||||
? "편집 잠금 (클릭하여 해제)"
|
||||
: "편집 가능 (클릭하여 잠금)"
|
||||
}
|
||||
className={cn(
|
||||
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
||||
config.columns?.find((c: ColumnConfig) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentCol = config.columns?.find((c: ColumnConfig) => c.columnName === matchingJoinColumn.joinAlias);
|
||||
if (currentCol) {
|
||||
onUpdateColumn(matchingJoinColumn.joinAlias, {
|
||||
editable: currentCol.editable === false ? undefined : false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.columns?.find((c: ColumnConfig) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false ? (
|
||||
<Lock className="h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className={cn("text-[10px] text-primary/80", !isAlreadyAdded && "ml-auto")}>
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue