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 추가 필수
|
- backend-node/src/routes/index.ts에 import 추가 필수
|
||||||
- authenticateToken 미들웨어 적용 필수
|
- authenticateToken 미들웨어 적용 필수
|
||||||
|
|
||||||
|
# CRITICAL: 사용자 메뉴 화면은 프론트엔드 페이지로 만들지 않는다!
|
||||||
|
|
||||||
|
백엔드 에이전트는 프론트엔드 page.tsx를 직접 생성하지 않지만,
|
||||||
|
다른 에이전트에게 "프론트엔드 페이지를 만들어달라"고 요청하거나 제안해서도 안 된다.
|
||||||
|
|
||||||
|
사용자 메뉴 화면은 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.
|
||||||
|
백엔드 에이전트가 할 일은 API 엔드포인트(controller/routes)와 DB 마이그레이션까지다.
|
||||||
|
|
||||||
# Your Domain
|
# Your Domain
|
||||||
- backend-node/src/controllers/
|
- backend-node/src/controllers/
|
||||||
- backend-node/src/services/
|
- backend-node/src/services/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,79 @@
|
||||||
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
|
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# !!!! STOP - 작업 시작 전 필수 게이트 (이것을 건너뛰면 모든 작업이 REJECT 된다) !!!!
|
||||||
|
|
||||||
|
## PRE-CHECK GATE: 파일 생성/수정 전 반드시 확인
|
||||||
|
|
||||||
|
**어떤 에이전트든 파일을 생성하거나 수정하기 전에 반드시 이 게이트를 통과해야 한다.**
|
||||||
|
**이 게이트를 건너뛰거나 무시한 작업은 전부 REJECT + ROLLBACK 대상이다.**
|
||||||
|
|
||||||
|
### GATE 1: 이 파일을 만들어도 되는가?
|
||||||
|
|
||||||
|
아래 경로에 `.tsx` 페이지 파일을 **절대 생성하지 마라**:
|
||||||
|
```
|
||||||
|
frontend/app/(main)/production/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/warehouse/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/quality/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/logistics/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/inventory/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/purchase/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/sales/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/bom/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/mold/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/packaging/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/document/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/work/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/order/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/material/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/equipment/** ← 금지! 사용자 메뉴!
|
||||||
|
frontend/app/(main)/inspection/** ← 금지! 사용자 메뉴!
|
||||||
|
```
|
||||||
|
|
||||||
|
**유일하게 React 페이지(.tsx)를 만들 수 있는 경로:**
|
||||||
|
```
|
||||||
|
frontend/app/(main)/admin/** ← 허용! 관리자 메뉴만!
|
||||||
|
```
|
||||||
|
|
||||||
|
**판단 로직 (의사코드):**
|
||||||
|
```
|
||||||
|
IF 생성하려는 파일 경로가 "frontend/app/(main)/admin/" 하위가 아니다
|
||||||
|
AND 파일이 page.tsx 또는 layout.tsx 또는 React 컴포넌트다
|
||||||
|
THEN
|
||||||
|
!!!! 즉시 중단 !!!!
|
||||||
|
→ 이것은 사용자 메뉴다
|
||||||
|
→ React 페이지를 만들면 안 된다
|
||||||
|
→ DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 전환하라
|
||||||
|
→ pipeline-common-rules.md의 "사용자 메뉴 구현 방법" 섹션을 따르라
|
||||||
|
END IF
|
||||||
|
```
|
||||||
|
|
||||||
|
### GATE 2: 사용자 메뉴인데 코드로 만들려고 하는가?
|
||||||
|
|
||||||
|
아래 키워드가 요구사항에 포함되어 있으면 **사용자 메뉴**일 가능성이 높다:
|
||||||
|
- 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비
|
||||||
|
- "목록 + 상세" 구조, "좌측 테이블 + 우측 폼" 구조
|
||||||
|
- 일반 업무 화면, CRUD 화면
|
||||||
|
|
||||||
|
**사용자 메뉴라면:**
|
||||||
|
- .tsx 페이지 파일 생성 → 금지
|
||||||
|
- screen_definitions + screen_layouts_v2 + menu_info INSERT → 올바른 방법
|
||||||
|
- 백엔드 API(controller/routes)는 필요하면 코드로 작성 가능
|
||||||
|
- 프론트엔드 API 클라이언트(lib/api/)도 필요하면 코드로 작성 가능
|
||||||
|
- 하지만 **프론트엔드 화면 UI 자체**는 절대 코드로 만들지 않는다!
|
||||||
|
|
||||||
|
### GATE 3: 관리자 메뉴가 맞는가?
|
||||||
|
|
||||||
|
관리자 메뉴는 다음 조건을 **전부** 만족해야 한다:
|
||||||
|
- 시스템 관리자만 사용하는 기능 (사용자 관리, 권한 관리, 시스템 설정 등)
|
||||||
|
- URL이 `/admin/*` 패턴
|
||||||
|
- `frontend/app/(main)/admin/` 하위에만 page.tsx 생성
|
||||||
|
|
||||||
|
**이 3가지 게이트를 모두 통과한 후에만 작업을 시작하라.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 화면 유형 구분 (절대 규칙!)
|
## 1. 화면 유형 구분 (절대 규칙!)
|
||||||
|
|
||||||
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
|
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
|
||||||
|
|
@ -7,18 +81,20 @@
|
||||||
|
|
||||||
### 관리자 메뉴 (Admin)
|
### 관리자 메뉴 (Admin)
|
||||||
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
|
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
|
||||||
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
|
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!**
|
||||||
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
|
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
|
||||||
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
|
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
|
||||||
- **특징**: 하드코딩된 UI, 관리자만 접근
|
- **특징**: 하드코딩된 UI, 관리자만 접근
|
||||||
|
|
||||||
### 사용자 메뉴 (User/Screen)
|
### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!!
|
||||||
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
|
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
|
||||||
- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관
|
- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관
|
||||||
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
|
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
|
||||||
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
|
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
|
||||||
- **대상**: 일반 업무 화면, BOM, 문서 관리 등
|
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
|
||||||
- **특징**: 코드 수정 없이 화면 구성 변경 가능
|
- **특징**: 코드 수정 없이 화면 구성 변경 가능
|
||||||
|
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
|
||||||
|
- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업
|
||||||
|
|
||||||
### 판단 기준
|
### 판단 기준
|
||||||
|
|
||||||
|
|
@ -26,8 +102,88 @@
|
||||||
|------|-------------|-------------|
|
|------|-------------|-------------|
|
||||||
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
|
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
|
||||||
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
|
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
|
||||||
| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 |
|
| URL 패턴 | `/admin/*` | `/screen/{screen_code}` |
|
||||||
| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 |
|
| 메뉴 등록 | `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. 관리자 메뉴 등록 (코드 구현 후 필수!)
|
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
|
||||||
|
|
||||||
|
|
@ -35,11 +191,14 @@
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 예시: 결재 템플릿 관리 메뉴 등록
|
-- 예시: 결재 템플릿 관리 메뉴 등록
|
||||||
INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code)
|
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status)
|
||||||
VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드');
|
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 별로 등록이 필요할 수 있다
|
- company_code 별로 등록이 필요할 수 있다
|
||||||
- menu_auth_group 권한 매핑도 필요하면 추가
|
- menu_auth_group 권한 매핑도 필요하면 추가
|
||||||
|
|
||||||
|
|
@ -63,16 +222,26 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr
|
||||||
|
|
||||||
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
|
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
|
||||||
|
|
||||||
|
### 공통
|
||||||
- [ ] DB: 마이그레이션 작성 + 실행 완료
|
- [ ] DB: 마이그레이션 작성 + 실행 완료
|
||||||
- [ ] DB: company_code 컬럼 + 인덱스 존재
|
- [ ] DB: company_code 컬럼 + 인덱스 존재
|
||||||
- [ ] BE: API 엔드포인트 구현 + 라우트 등록
|
- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!)
|
||||||
- [ ] BE: company_code 필터링 적용
|
- [ ] BE: company_code 필터링 적용
|
||||||
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
|
|
||||||
- [ ] FE: 화면 컴포넌트 구현
|
|
||||||
- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록
|
|
||||||
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
|
- [ ] 빌드 통과: 백엔드 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. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
|
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
|
||||||
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
|
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
|
||||||
|
|
@ -80,3 +249,39 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr
|
||||||
4. 하드코딩 색상/URL/사용자ID 사용
|
4. 하드코딩 색상/URL/사용자ID 사용
|
||||||
5. Card 안에 Card 중첩 (중첩 박스 금지)
|
5. Card 안에 Card 중첩 (중첩 박스 금지)
|
||||||
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
|
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.
|
You are a Frontend specialist for ERP-node project.
|
||||||
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
|
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# !!!! STOP - 파일 생성 전 필수 게이트 (반드시 읽고 확인하라) !!!!
|
||||||
|
|
||||||
|
## 파일을 생성하거나 수정하기 전에 반드시 이 체크를 수행하라:
|
||||||
|
|
||||||
|
### CHECK 1: page.tsx를 만들려고 하는가?
|
||||||
|
|
||||||
|
```
|
||||||
|
IF 파일 경로가 "frontend/app/(main)/" 하위이다
|
||||||
|
AND 파일명이 page.tsx 또는 layout.tsx이다
|
||||||
|
AND 경로에 "/admin/"이 포함되어 있지 않다
|
||||||
|
THEN
|
||||||
|
!!!! 즉시 중단 !!!! 이것은 사용자 메뉴다!
|
||||||
|
→ React 페이지를 만들면 안 된다
|
||||||
|
→ DB 등록 방식으로 전환하라 (screen_definitions + screen_layouts_v2 + menu_info)
|
||||||
|
→ 이 파일의 "올바른 패턴" 섹션을 참조하라
|
||||||
|
END IF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 금지 경로 목록 (이 경로에 page.tsx 생성 시 즉시 REJECT):
|
||||||
|
```
|
||||||
|
frontend/app/(main)/production/** ← 금지!
|
||||||
|
frontend/app/(main)/warehouse/** ← 금지!
|
||||||
|
frontend/app/(main)/quality/** ← 금지!
|
||||||
|
frontend/app/(main)/logistics/** ← 금지!
|
||||||
|
frontend/app/(main)/inventory/** ← 금지!
|
||||||
|
frontend/app/(main)/purchase/** ← 금지!
|
||||||
|
frontend/app/(main)/sales/** ← 금지!
|
||||||
|
frontend/app/(main)/bom/** ← 금지!
|
||||||
|
frontend/app/(main)/mold/** ← 금지!
|
||||||
|
frontend/app/(main)/packaging/** ← 금지!
|
||||||
|
frontend/app/(main)/document/** ← 금지!
|
||||||
|
frontend/app/(main)/work/** ← 금지!
|
||||||
|
frontend/app/(main)/order/** ← 금지!
|
||||||
|
frontend/app/(main)/material/** ← 금지!
|
||||||
|
frontend/app/(main)/equipment/** ← 금지!
|
||||||
|
frontend/app/(main)/inspection/** ← 금지!
|
||||||
|
(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지 대상이다!)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 유일하게 허용되는 page.tsx 생성 경로:
|
||||||
|
```
|
||||||
|
frontend/app/(main)/admin/** ← 유일하게 허용!
|
||||||
|
```
|
||||||
|
|
||||||
|
### CHECK 2: 사용자 메뉴 키워드 감지
|
||||||
|
|
||||||
|
요구사항에 아래 키워드가 포함되면 사용자 메뉴일 가능성이 높다:
|
||||||
|
> 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비, 목록+상세, 좌측 테이블+우측 폼, CRUD 화면
|
||||||
|
|
||||||
|
사용자 메뉴라면 **page.tsx 생성을 절대 하지 말고** DB 등록으로 전환하라.
|
||||||
|
|
||||||
|
**이 게이트를 통과하지 않은 파일 생성은 전부 REJECT 된다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# CRITICAL PROJECT RULES
|
# CRITICAL PROJECT RULES
|
||||||
|
|
||||||
## 1. API Client (ABSOLUTE RULE!)
|
## 1. API Client (ABSOLUTE RULE!)
|
||||||
|
|
@ -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
|
# Your Domain
|
||||||
- frontend/components/
|
- frontend/components/
|
||||||
- frontend/app/
|
- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!)
|
||||||
- frontend/lib/
|
- frontend/lib/
|
||||||
- frontend/hooks/
|
- frontend/hooks/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,43 @@ model: inherit
|
||||||
You are a UI/UX Design specialist for the ERP-node project.
|
You are a UI/UX Design specialist for the ERP-node project.
|
||||||
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
|
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# !!!! STOP - 파일 생성/수정 전 필수 게이트 !!!!
|
||||||
|
|
||||||
|
## 파일을 만들거나 수정하기 전에 반드시 확인하라:
|
||||||
|
|
||||||
|
**page.tsx를 생성하려는 경로가 `frontend/app/(main)/admin/` 하위인가?**
|
||||||
|
- YES → 진행 가능
|
||||||
|
- NO → **즉시 중단!** 사용자 메뉴는 React 페이지로 만들지 않는다!
|
||||||
|
|
||||||
|
**금지 경로 (이 경로에 page.tsx 생성 시 즉시 REJECT):**
|
||||||
|
```
|
||||||
|
frontend/app/(main)/production/** ← 금지!
|
||||||
|
frontend/app/(main)/warehouse/** ← 금지!
|
||||||
|
frontend/app/(main)/quality/** ← 금지!
|
||||||
|
frontend/app/(main)/logistics/** ← 금지!
|
||||||
|
frontend/app/(main)/inventory/** ← 금지!
|
||||||
|
frontend/app/(main)/purchase/** ← 금지!
|
||||||
|
frontend/app/(main)/sales/** ← 금지!
|
||||||
|
frontend/app/(main)/bom/** ← 금지!
|
||||||
|
frontend/app/(main)/mold/** ← 금지!
|
||||||
|
frontend/app/(main)/packaging/** ← 금지!
|
||||||
|
frontend/app/(main)/document/** ← 금지!
|
||||||
|
frontend/app/(main)/work/** ← 금지!
|
||||||
|
frontend/app/(main)/order/** ← 금지!
|
||||||
|
frontend/app/(main)/material/** ← 금지!
|
||||||
|
frontend/app/(main)/equipment/** ← 금지!
|
||||||
|
frontend/app/(main)/inspection/** ← 금지!
|
||||||
|
(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지!)
|
||||||
|
```
|
||||||
|
|
||||||
|
**사용자 메뉴 화면은 DB 등록(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.**
|
||||||
|
|
||||||
|
**이 게이트를 무시하면 작업 전체 REJECT + 파일 삭제 + 재작업 대상이다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Design Philosophy
|
# Design Philosophy
|
||||||
- Apple-level polish with enterprise functionality
|
- Apple-level polish with enterprise functionality
|
||||||
- Consistent spacing, typography, color usage
|
- Consistent spacing, typography, color usage
|
||||||
|
|
@ -39,9 +76,24 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
|
||||||
- Use cn() for conditional classes
|
- Use cn() for conditional classes
|
||||||
- Use lucide-react for ALL icons
|
- Use lucide-react for ALL icons
|
||||||
|
|
||||||
|
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT)
|
||||||
|
|
||||||
|
사용자 업무 화면(포장관리, 금형관리, 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
|
# Your Domain
|
||||||
- frontend/components/ (UI components)
|
- frontend/components/ (UI components)
|
||||||
- frontend/app/ (pages)
|
- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**)
|
||||||
|
- frontend/lib/registry/components/v2-*/ (V2 컴포넌트)
|
||||||
|
|
||||||
# Output Rules
|
# Output Rules
|
||||||
1. TypeScript strict mode
|
1. TypeScript strict mode
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: pipeline-verifier
|
name: pipeline-verifier
|
||||||
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
|
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지.
|
||||||
model: fast
|
model: fast
|
||||||
readonly: true
|
readonly: true
|
||||||
---
|
---
|
||||||
|
|
@ -11,6 +11,29 @@ Your job is to verify that work claimed as complete actually works.
|
||||||
|
|
||||||
# Verification Checklist
|
# Verification Checklist
|
||||||
|
|
||||||
|
## 0. 하드코딩 페이지 탐지 (최최우선! 이것부터 먼저 확인!)
|
||||||
|
|
||||||
|
**이 프로젝트에서 가장 심각한 위반은 사용자 메뉴를 React 페이지(.tsx)로 하드코딩하는 것이다.**
|
||||||
|
검증 시 반드시 아래를 제일 먼저 확인하라:
|
||||||
|
|
||||||
|
- [ ] `frontend/app/(main)/` 하위에 `/admin/` 이외의 경로에 새로운 page.tsx가 생성되지 않았는가?
|
||||||
|
- [ ] 구체적 금지 경로 확인: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/
|
||||||
|
- [ ] 위 경로뿐 아니라 `/admin/` 이외의 **모든** 경로에 page.tsx가 새로 생성되었는지 확인
|
||||||
|
- [ ] 사용자 메뉴 화면이 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 구현되었는가?
|
||||||
|
|
||||||
|
**검증 방법:**
|
||||||
|
```bash
|
||||||
|
# 이 라운드에서 새로 생성된 파일 중 금지 경로의 page.tsx가 있는지 확인
|
||||||
|
git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep -v "/admin/"
|
||||||
|
# 결과가 있으면 → 즉시 FAIL!
|
||||||
|
```
|
||||||
|
|
||||||
|
**위반 발견 시:**
|
||||||
|
- 검증 결과: **CRITICAL FAIL**
|
||||||
|
- 해당 파일 삭제 필수
|
||||||
|
- DB 등록 방식으로 재작업 지시
|
||||||
|
- 이 위반이 있으면 다른 항목 전부 PASS여도 최종 결과는 FAIL
|
||||||
|
|
||||||
## 1. Multi-tenancy (최우선)
|
## 1. Multi-tenancy (최우선)
|
||||||
- [ ] 모든 SQL에 company_code 필터 존재
|
- [ ] 모든 SQL에 company_code 필터 존재
|
||||||
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
|
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
|
||||||
|
|
@ -28,6 +51,7 @@ Your job is to verify that work claimed as complete actually works.
|
||||||
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
|
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
|
||||||
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
|
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
|
||||||
- [ ] Frontend: V2 컴포넌트 규격 준수
|
- [ ] Frontend: V2 컴포넌트 규격 준수
|
||||||
|
- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!)
|
||||||
- [ ] Backend: logger 사용
|
- [ ] Backend: logger 사용
|
||||||
- [ ] Backend: try/catch 에러 처리
|
- [ ] Backend: try/catch 에러 처리
|
||||||
|
|
||||||
|
|
@ -39,7 +63,10 @@ Your job is to verify that work claimed as complete actually works.
|
||||||
|
|
||||||
# Reporting Format
|
# Reporting Format
|
||||||
```
|
```
|
||||||
## 검증 결과: [PASS/FAIL]
|
## 검증 결과: [PASS/FAIL/CRITICAL FAIL]
|
||||||
|
|
||||||
|
### [CRITICAL] 하드코딩 페이지 탐지
|
||||||
|
- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)")
|
||||||
|
|
||||||
### 통과 항목
|
### 통과 항목
|
||||||
- item 1
|
- 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.
|
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 = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
**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 cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||||
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||||
|
import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리
|
||||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
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-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||||
|
app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리
|
||||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||||
|
|
|
||||||
|
|
@ -3574,7 +3574,7 @@ export async function getTableSchema(
|
||||||
ic.character_maximum_length,
|
ic.character_maximum_length,
|
||||||
ic.numeric_precision,
|
ic.numeric_precision,
|
||||||
ic.numeric_scale,
|
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.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_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
|
||||||
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
|
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 { JwtUtils } from "../utils/jwtUtils";
|
||||||
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
|
||||||
|
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
/**
|
/**
|
||||||
|
|
@ -86,13 +87,20 @@ export class AuthController {
|
||||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
|
||||||
|
sendSmartFactoryLog({
|
||||||
|
userId: userInfo.userId,
|
||||||
|
remoteAddr,
|
||||||
|
useType: "접속",
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "로그인 성공",
|
message: "로그인 성공",
|
||||||
data: {
|
data: {
|
||||||
userInfo,
|
userInfo,
|
||||||
token: loginResult.token,
|
token: loginResult.token,
|
||||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
firstMenuPath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,6 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
||||||
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
columnInputType: columnInputType || "none",
|
|
||||||
labelColumn: effectiveLabelColumn,
|
labelColumn: effectiveLabelColumn,
|
||||||
companyCode,
|
companyCode,
|
||||||
hasFilters: !!filtersParam,
|
hasFilters: !!filtersParam,
|
||||||
|
|
|
||||||
|
|
@ -62,24 +62,31 @@ export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Resp
|
||||||
*/
|
*/
|
||||||
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const userCompanyCode = req.user!.companyCode;
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const includeInactive = req.query.includeInactive === "true";
|
const includeInactive = req.query.includeInactive === "true";
|
||||||
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
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("카테고리 값 조회 요청", {
|
logger.info("카테고리 값 조회 요청", {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
companyCode,
|
companyCode: effectiveCompanyCode,
|
||||||
|
filterCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const values = await tableCategoryValueService.getCategoryValues(
|
const values = await tableCategoryValueService.getCategoryValues(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
companyCode,
|
effectiveCompanyCode,
|
||||||
includeInactive,
|
includeInactive,
|
||||||
menuObjid // ← menuObjid 전달
|
menuObjid
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({
|
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, // 🆕 회사별 카테고리 컬럼 조회
|
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
|
||||||
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
||||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||||
|
validateExcelData, // 엑셀 업로드 전 데이터 검증
|
||||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||||
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
||||||
|
|
@ -280,4 +281,9 @@ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
||||||
*/
|
*/
|
||||||
router.post("/multi-table-save", multiTableSave);
|
router.post("/multi-table-save", multiTableSave);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 업로드 전 데이터 검증
|
||||||
|
*/
|
||||||
|
router.post("/validate-excel", validateExcelData);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1715,8 +1715,8 @@ export class DynamicFormService {
|
||||||
`SELECT component_id, properties
|
`SELECT component_id, properties
|
||||||
FROM screen_layouts
|
FROM screen_layouts
|
||||||
WHERE screen_id = $1
|
WHERE screen_id = $1
|
||||||
AND component_type = $2`,
|
AND component_type IN ('component', 'v2-button-primary')`,
|
||||||
[screenId, "component"]
|
[screenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
||||||
|
|
@ -1747,8 +1747,12 @@ export class DynamicFormService {
|
||||||
(triggerType === "delete" && buttonActionType === "delete") ||
|
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||||
|
|
||||||
|
const isButtonComponent =
|
||||||
|
properties?.componentType === "button-primary" ||
|
||||||
|
properties?.componentType === "v2-button-primary";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
isButtonComponent &&
|
||||||
isMatchingAction &&
|
isMatchingAction &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
|
|
@ -1877,7 +1881,7 @@ export class DynamicFormService {
|
||||||
{
|
{
|
||||||
sourceData: [savedData],
|
sourceData: [savedData],
|
||||||
dataSourceType: "formData",
|
dataSourceType: "formData",
|
||||||
buttonId: "save-button",
|
buttonId: `${triggerType}-button`,
|
||||||
screenId: screenId,
|
screenId: screenId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
companyCode: companyCode,
|
companyCode: companyCode,
|
||||||
|
|
|
||||||
|
|
@ -972,7 +972,7 @@ class MultiTableExcelService {
|
||||||
c.column_name,
|
c.column_name,
|
||||||
c.is_nullable AS db_is_nullable,
|
c.is_nullable AS db_is_nullable,
|
||||||
c.column_default,
|
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.reference_table, cl.reference_table) AS reference_table,
|
||||||
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
|
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
|
||||||
FROM information_schema.columns c
|
FROM information_schema.columns c
|
||||||
|
|
|
||||||
|
|
@ -2346,19 +2346,24 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료)
|
* 메뉴별 화면 목록 조회
|
||||||
|
* company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회
|
||||||
|
* 본인 회사 할당이 우선, 없으면 글로벌 할당 사용
|
||||||
*/
|
*/
|
||||||
async getScreensByMenu(
|
async getScreensByMenu(
|
||||||
menuObjid: number,
|
menuObjid: number,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<ScreenDefinition[]> {
|
): Promise<ScreenDefinition[]> {
|
||||||
const screens = await query<any>(
|
const screens = await query<any>(
|
||||||
`SELECT sd.* 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
|
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||||
WHERE sma.menu_objid = $1
|
WHERE sma.menu_objid = $1
|
||||||
AND sma.company_code = $2
|
AND (sma.company_code = $2 OR sma.company_code = '*')
|
||||||
AND sma.is_active = 'Y'
|
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],
|
[menuObjid, companyCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -217,12 +217,12 @@ class TableCategoryValueService {
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// category_values 테이블 사용 (menu_objid 없음)
|
// company_code 기반 필터링
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
// 최고 관리자: 모든 값 조회
|
// 최고 관리자: 공통(*) 카테고리만 조회 (모든 회사 카테고리 혼합 방지)
|
||||||
query = baseSelect;
|
query = baseSelect + ` AND company_code = '*'`;
|
||||||
params = [tableName, columnName];
|
params = [tableName, columnName];
|
||||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
|
logger.info("최고 관리자: 공통 카테고리만 조회 (category_values)");
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
||||||
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export class TableManagementService {
|
||||||
? await query<any>(
|
? await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
c.column_name as "columnName",
|
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 "dataType",
|
||||||
c.data_type as "dbType",
|
c.data_type as "dbType",
|
||||||
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType",
|
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,
|
FileSpreadsheet,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
Zap,
|
||||||
Copy,
|
Copy,
|
||||||
|
|
@ -136,6 +137,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 중복 처리 방법 (전역 설정)
|
// 중복 처리 방법 (전역 설정)
|
||||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
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 [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||||
|
|
@ -874,6 +879,43 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setShowCategoryValidation(true);
|
setShowCategoryValidation(true);
|
||||||
return;
|
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));
|
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||||
|
|
@ -1301,6 +1343,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
setDuplicateAction("skip");
|
setDuplicateAction("skip");
|
||||||
|
// 검증 상태 초기화
|
||||||
|
setValidationResult(null);
|
||||||
|
setIsDataValidating(false);
|
||||||
// 카테고리 검증 초기화
|
// 카테고리 검증 초기화
|
||||||
setShowCategoryValidation(false);
|
setShowCategoryValidation(false);
|
||||||
setCategoryMismatches({});
|
setCategoryMismatches({});
|
||||||
|
|
@ -1870,6 +1915,100 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||||
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
<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">
|
<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 ? (
|
{currentStep < 3 ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
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"
|
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" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
검증 중...
|
검증 중...
|
||||||
|
|
@ -1964,11 +2103,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={
|
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"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{isUploading ? "업로드 중..." : "업로드"}
|
{isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
@ -10,70 +12,320 @@ const LoadingFallback = () => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const d = (loader: () => Promise<any>) =>
|
||||||
|
dynamic(loader, { ssr: false, loading: LoadingFallback });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /dashboard/[dashboardId] URL을 탭 내에서 직접 렌더링
|
||||||
|
* Next.js params Promise 없이 dashboardId를 직접 전달
|
||||||
|
*/
|
||||||
|
const LazyDashboardViewer = d(() => import("@/components/dashboard/DashboardViewer").then((mod) => ({
|
||||||
|
default: mod.DashboardViewer,
|
||||||
|
})));
|
||||||
|
|
||||||
|
function DashboardTabRenderer({ dashboardId }: { dashboardId: string }) {
|
||||||
|
const [dashboard, setDashboard] = useState<any>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const data = await dashboardApi.getDashboard(dashboardId);
|
||||||
|
setDashboard({ ...data, elements: data.elements || [] });
|
||||||
|
} catch {
|
||||||
|
const saved = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||||
|
const found = saved.find((d: any) => d.id === dashboardId);
|
||||||
|
if (found) {
|
||||||
|
setDashboard(found);
|
||||||
|
} else {
|
||||||
|
setError("대시보드를 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [dashboardId]);
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingFallback />;
|
||||||
|
|
||||||
|
if (error || !dashboard) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-semibold text-foreground">{error || "대시보드를 찾을 수 없습니다"}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">대시보드 ID: {dashboardId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<LazyDashboardViewer
|
||||||
|
elements={dashboard.elements}
|
||||||
|
dashboardId={dashboard.id}
|
||||||
|
dashboardTitle={dashboard.title}
|
||||||
|
backgroundColor={dashboard.settings?.backgroundColor}
|
||||||
|
resolution={dashboard.settings?.resolution}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /screen/[screenCode] URL을 screenId로 변환해서 ScreenViewPageWrapper를 렌더링
|
||||||
|
*/
|
||||||
|
function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
|
||||||
|
const [screenId, setScreenId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const numericId = parseInt(screenCode);
|
||||||
|
if (!isNaN(numericId)) {
|
||||||
|
setScreenId(numericId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolve = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/screen-management/screens", {
|
||||||
|
params: { searchTerm: screenCode, size: 50 },
|
||||||
|
});
|
||||||
|
const items = res.data?.data?.data || res.data?.data || [];
|
||||||
|
const arr = Array.isArray(items) ? items : [];
|
||||||
|
const exact = arr.find((s: any) => s.screenCode === screenCode || s.screen_code === screenCode);
|
||||||
|
const target = exact || arr[0];
|
||||||
|
if (target) {
|
||||||
|
setScreenId(target.screenId || target.screen_id);
|
||||||
|
} else {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
resolve();
|
||||||
|
}, [screenCode]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-semibold text-foreground">화면을 찾을 수 없습니다</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
화면 코드: {screenCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenId === null) {
|
||||||
|
return <LoadingFallback />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ScreenViewPageWrapper screenIdProp={screenId} />;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
||||||
* 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다.
|
* 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다.
|
||||||
* 매핑되지 않은 URL은 catch-all fallback으로 처리된다.
|
|
||||||
*/
|
*/
|
||||||
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||||
// 관리자 메인
|
// 관리자 메인
|
||||||
"/admin": 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/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")),
|
||||||
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")),
|
||||||
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")),
|
||||||
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
|
"/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/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")),
|
||||||
"/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")),
|
||||||
"/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")),
|
||||||
"/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }),
|
"/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/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")),
|
||||||
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")),
|
||||||
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")),
|
||||||
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")),
|
||||||
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
|
"/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/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")),
|
||||||
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")),
|
||||||
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")),
|
||||||
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
|
"/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/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")),
|
||||||
"/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")),
|
||||||
"/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")),
|
||||||
"/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")),
|
||||||
"/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")),
|
||||||
"/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")),
|
||||||
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")),
|
||||||
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")),
|
||||||
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
|
"/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": d(() => import("@/app/(main)/admin/batch-management/page")),
|
||||||
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
|
"/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/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")),
|
||||||
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")),
|
||||||
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")),
|
||||||
"/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 }),
|
// AI 어시스턴트
|
||||||
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")),
|
||||||
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
|
"/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")),
|
||||||
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
|
"/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 }) {
|
function AdminPageFallback({ url }: { url: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
@ -95,15 +347,55 @@ interface AdminPageRendererProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||||
const PageComponent = useMemo(() => {
|
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||||
// URL에서 쿼리스트링/해시 제거 후 매칭
|
|
||||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
|
||||||
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
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} />;
|
return <AdminPageFallback url={url} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { component: PageComponent, params } = resolved;
|
||||||
|
|
||||||
|
// 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달
|
||||||
|
// Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨
|
||||||
|
if (params) {
|
||||||
|
return <PageComponent params={Promise.resolve(params)} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <PageComponent />;
|
return <PageComponent />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -362,8 +362,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("할당된 화면 조회 실패");
|
console.error("할당된 화면 조회 실패:", err);
|
||||||
|
toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (menu.url && menu.url !== "#") {
|
if (menu.url && menu.url !== "#") {
|
||||||
|
|
|
||||||
|
|
@ -1850,7 +1850,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
try {
|
try {
|
||||||
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
||||||
|
|
||||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert("삭제되었습니다.");
|
alert("삭제되었습니다.");
|
||||||
|
|
|
||||||
|
|
@ -372,3 +372,30 @@ export const getTableColumns = (tableName: string) => tableManagementApi.getColu
|
||||||
export const updateColumnType = (tableName: string, columnName: string, settings: ColumnSettings) =>
|
export const updateColumnType = (tableName: string, columnName: string, settings: ColumnSettings) =>
|
||||||
tableManagementApi.updateColumnSettings(tableName, columnName, settings);
|
tableManagementApi.updateColumnSettings(tableName, columnName, settings);
|
||||||
export const checkTableExists = (tableName: string) => tableManagementApi.checkTableExists(tableName);
|
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,
|
GeneratedLocation,
|
||||||
RackStructureContext,
|
RackStructureContext,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
|
||||||
|
|
||||||
// 기존 위치 데이터 타입
|
// 기존 위치 데이터 타입
|
||||||
interface ExistingLocation {
|
interface ExistingLocation {
|
||||||
|
|
@ -512,23 +513,27 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
return { totalLocations, totalRows, maxLevel };
|
return { totalLocations, totalRows, maxLevel };
|
||||||
}, [conditions]);
|
}, [conditions]);
|
||||||
|
|
||||||
// 위치 코드 생성
|
// 위치 코드 생성 (패턴 기반)
|
||||||
const generateLocationCode = useCallback(
|
const generateLocationCode = useCallback(
|
||||||
(row: number, level: number): { code: string; name: string } => {
|
(row: number, level: number): { code: string; name: string } => {
|
||||||
const warehouseCode = context?.warehouseCode || "WH001";
|
const vars = {
|
||||||
const floor = context?.floor || "1";
|
warehouse: context?.warehouseCode || "WH001",
|
||||||
const zone = context?.zone || "A";
|
warehouseName: context?.warehouseName || "",
|
||||||
|
floor: context?.floor || "1",
|
||||||
|
zone: context?.zone || "A",
|
||||||
|
row,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
|
||||||
// 코드 생성 (예: WH001-1층D구역-01-1)
|
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
|
||||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
|
||||||
|
|
||||||
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
|
return {
|
||||||
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
code: applyLocationPattern(codePattern, vars),
|
||||||
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
name: applyLocationPattern(namePattern, vars),
|
||||||
|
};
|
||||||
return { code, name };
|
|
||||||
},
|
},
|
||||||
[context],
|
[context, config.codePattern, config.namePattern],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 미리보기 생성
|
// 미리보기 생성
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
@ -12,6 +12,47 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
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 {
|
interface RackStructureConfigPanelProps {
|
||||||
config: RackStructureComponentConfig;
|
config: RackStructureComponentConfig;
|
||||||
|
|
@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
</div>
|
</div>
|
||||||
</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="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
<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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
@ -222,6 +222,61 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
</div>
|
</div>
|
||||||
</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="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
<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<
|
const [categoryMappings, setCategoryMappings] = useState<
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
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 [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
|
@ -1064,9 +1064,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const getColumnUniqueValues = async (columnName: string) => {
|
const getColumnUniqueValues = async (columnName: string) => {
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
// 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링
|
||||||
|
const filterParam = companyCode && companyCode !== "*"
|
||||||
|
? `?filterCompanyCode=${encodeURIComponent(companyCode)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
// 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도)
|
// 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도)
|
||||||
try {
|
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) {
|
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||||
return response.data.data.map((item: any) => ({
|
return response.data.data.map((item: any) => ({
|
||||||
value: item.valueCode,
|
value: item.valueCode,
|
||||||
|
|
@ -1171,15 +1176,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
tableConfig.columns,
|
tableConfig.columns,
|
||||||
columnLabels,
|
columnLabels,
|
||||||
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
|
columnMeta,
|
||||||
categoryMappings, // 카테고리 매핑 변경 시 재등록 (필터 라벨 변환용)
|
categoryMappings,
|
||||||
columnWidths,
|
columnWidths,
|
||||||
tableLabel,
|
tableLabel,
|
||||||
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
data,
|
||||||
totalItems, // 전체 항목 수가 변경되면 재등록
|
totalItems,
|
||||||
registerTable,
|
registerTable,
|
||||||
// unregisterTable은 의존성에서 제외 - 무한 루프 방지
|
|
||||||
// unregisterTable 함수는 의존성이 없어 안정적임
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
|
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
|
||||||
|
|
@ -1423,7 +1426,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
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 }>) => {
|
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
|
||||||
items.forEach((item: any) => {
|
items.forEach((item: any) => {
|
||||||
if (item.valueCode) {
|
if (item.valueCode) {
|
||||||
|
|
@ -1432,12 +1441,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
color: item.color,
|
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) {
|
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||||
flattenTree(item.children, mapping);
|
flattenTree(item.children, mapping);
|
||||||
}
|
}
|
||||||
|
|
@ -1465,7 +1468,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true
|
// 비활성화된 카테고리도 라벨로 표시하기 위해 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)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||||
|
|
@ -1548,7 +1551,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// inputType이 category인 경우 카테고리 매핑 로드
|
// inputType이 category인 경우 카테고리 매핑 로드
|
||||||
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
|
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
|
||||||
try {
|
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)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||||
|
|
@ -1618,6 +1621,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
JSON.stringify(categoryColumns),
|
JSON.stringify(categoryColumns),
|
||||||
JSON.stringify(tableConfig.columns),
|
JSON.stringify(tableConfig.columns),
|
||||||
columnMeta,
|
columnMeta,
|
||||||
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -1508,7 +1508,38 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
<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}
|
{column.inputType || column.dataType}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,38 @@ export const ColumnsConfigPanel: React.FC<ColumnsConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
<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}
|
{column.inputType || column.dataType}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue