Resolve merge conflicts in v2-rack-structure

Made-with: Cursor
This commit is contained in:
kmh 2026-03-12 01:04:32 +09:00
commit 31ecf900ce
33 changed files with 1614 additions and 127 deletions

View File

@ -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/

View File

@ -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만으로 화면이 완성된다.**

View File

@ -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/

View File

@ -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

View File

@ -1,6 +1,6 @@
--- ---
name: pipeline-verifier name: pipeline-verifier
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지.
model: fast model: fast
readonly: true readonly: true
--- ---
@ -11,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이다.

View File

@ -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 컴포넌트로 직접 구현하는 것 금지

View File

@ -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); // 카테고리 값 연쇄관계

View File

@ -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

View File

@ -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 {

View File

@ -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,

View File

@ -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({

View File

@ -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: "데이터 검증 중 오류가 발생했습니다." });
}
}

View File

@ -0,0 +1,10 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
router.use(authenticateToken);
// TODO: 포장/적재정보 관리 API 구현 예정
export default router;

View File

@ -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;

View File

@ -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,

View File

@ -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

View File

@ -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],
); );

View File

@ -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 = '*')`;

View File

@ -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",

View File

@ -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}`;
}

View File

@ -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>

View File

@ -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(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); 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 />;
} }

View File

@ -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 !== "#") {

View File

@ -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("삭제되었습니다.");

View File

@ -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 || "데이터 검증 실패",
};
}
}

View File

@ -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],
); );
// 미리보기 생성 // 미리보기 생성

View File

@ -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>

View File

@ -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";

View File

@ -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>

View File

@ -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" },
];

View File

@ -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,
]); ]);
// ======================================== // ========================================

View File

@ -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>

View File

@ -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>