diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md index 6b4ff99c..9f7ef180 100644 --- a/.cursor/agents/pipeline-backend.md +++ b/.cursor/agents/pipeline-backend.md @@ -51,6 +51,14 @@ export const getList = async (req: Request, res: Response) => { - backend-node/src/routes/index.ts에 import 추가 필수 - authenticateToken 미들웨어 적용 필수 +# CRITICAL: 사용자 메뉴 화면은 프론트엔드 페이지로 만들지 않는다! + +백엔드 에이전트는 프론트엔드 page.tsx를 직접 생성하지 않지만, +다른 에이전트에게 "프론트엔드 페이지를 만들어달라"고 요청하거나 제안해서도 안 된다. + +사용자 메뉴 화면은 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다. +백엔드 에이전트가 할 일은 API 엔드포인트(controller/routes)와 DB 마이그레이션까지다. + # Your Domain - backend-node/src/controllers/ - backend-node/src/services/ diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md index 5baa0e97..575f355f 100644 --- a/.cursor/agents/pipeline-common-rules.md +++ b/.cursor/agents/pipeline-common-rules.md @@ -1,5 +1,79 @@ # WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) +--- + +# !!!! STOP - 작업 시작 전 필수 게이트 (이것을 건너뛰면 모든 작업이 REJECT 된다) !!!! + +## PRE-CHECK GATE: 파일 생성/수정 전 반드시 확인 + +**어떤 에이전트든 파일을 생성하거나 수정하기 전에 반드시 이 게이트를 통과해야 한다.** +**이 게이트를 건너뛰거나 무시한 작업은 전부 REJECT + ROLLBACK 대상이다.** + +### GATE 1: 이 파일을 만들어도 되는가? + +아래 경로에 `.tsx` 페이지 파일을 **절대 생성하지 마라**: +``` +frontend/app/(main)/production/** ← 금지! 사용자 메뉴! +frontend/app/(main)/warehouse/** ← 금지! 사용자 메뉴! +frontend/app/(main)/quality/** ← 금지! 사용자 메뉴! +frontend/app/(main)/logistics/** ← 금지! 사용자 메뉴! +frontend/app/(main)/inventory/** ← 금지! 사용자 메뉴! +frontend/app/(main)/purchase/** ← 금지! 사용자 메뉴! +frontend/app/(main)/sales/** ← 금지! 사용자 메뉴! +frontend/app/(main)/bom/** ← 금지! 사용자 메뉴! +frontend/app/(main)/mold/** ← 금지! 사용자 메뉴! +frontend/app/(main)/packaging/** ← 금지! 사용자 메뉴! +frontend/app/(main)/document/** ← 금지! 사용자 메뉴! +frontend/app/(main)/work/** ← 금지! 사용자 메뉴! +frontend/app/(main)/order/** ← 금지! 사용자 메뉴! +frontend/app/(main)/material/** ← 금지! 사용자 메뉴! +frontend/app/(main)/equipment/** ← 금지! 사용자 메뉴! +frontend/app/(main)/inspection/** ← 금지! 사용자 메뉴! +``` + +**유일하게 React 페이지(.tsx)를 만들 수 있는 경로:** +``` +frontend/app/(main)/admin/** ← 허용! 관리자 메뉴만! +``` + +**판단 로직 (의사코드):** +``` +IF 생성하려는 파일 경로가 "frontend/app/(main)/admin/" 하위가 아니다 + AND 파일이 page.tsx 또는 layout.tsx 또는 React 컴포넌트다 +THEN + !!!! 즉시 중단 !!!! + → 이것은 사용자 메뉴다 + → React 페이지를 만들면 안 된다 + → DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 전환하라 + → pipeline-common-rules.md의 "사용자 메뉴 구현 방법" 섹션을 따르라 +END IF +``` + +### GATE 2: 사용자 메뉴인데 코드로 만들려고 하는가? + +아래 키워드가 요구사항에 포함되어 있으면 **사용자 메뉴**일 가능성이 높다: +- 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비 +- "목록 + 상세" 구조, "좌측 테이블 + 우측 폼" 구조 +- 일반 업무 화면, CRUD 화면 + +**사용자 메뉴라면:** +- .tsx 페이지 파일 생성 → 금지 +- screen_definitions + screen_layouts_v2 + menu_info INSERT → 올바른 방법 +- 백엔드 API(controller/routes)는 필요하면 코드로 작성 가능 +- 프론트엔드 API 클라이언트(lib/api/)도 필요하면 코드로 작성 가능 +- 하지만 **프론트엔드 화면 UI 자체**는 절대 코드로 만들지 않는다! + +### GATE 3: 관리자 메뉴가 맞는가? + +관리자 메뉴는 다음 조건을 **전부** 만족해야 한다: +- 시스템 관리자만 사용하는 기능 (사용자 관리, 권한 관리, 시스템 설정 등) +- URL이 `/admin/*` 패턴 +- `frontend/app/(main)/admin/` 하위에만 page.tsx 생성 + +**이 3가지 게이트를 모두 통과한 후에만 작업을 시작하라.** + +--- + ## 1. 화면 유형 구분 (절대 규칙!) 이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. @@ -7,18 +81,20 @@ ### 관리자 메뉴 (Admin) - **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) -- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` +- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!** - **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) - **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 - **특징**: 하드코딩된 UI, 관리자만 접근 -### 사용자 메뉴 (User/Screen) +### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!! - **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장) -- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관 +- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관 - **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성 - **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리 -- **대상**: 일반 업무 화면, BOM, 문서 관리 등 +- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **특징**: 코드 수정 없이 화면 구성 변경 가능 +- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! +- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업 ### 판단 기준 @@ -26,8 +102,88 @@ |------|-------------|-------------| | 누가 쓰나? | 시스템 관리자 | 일반 사용자 | | 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) | -| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 | -| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 | +| URL 패턴 | `/admin/*` | `/screen/{screen_code}` | +| 메뉴 등록 | `menu_info` INSERT | `screen_definitions` + `menu_info` INSERT | +| 프론트엔드 코드 | `frontend/app/(main)/admin/` 하위에 page.tsx 작성 | **코드 작성 금지!** DB에 스크린 정의만 등록 | + +### 사용자 메뉴 구현 방법 (반드시 이 방식으로!) + +**절대 규칙: 사용자 메뉴는 React 페이지(.tsx)를 직접 만들지 않는다!** +이미 `/screen/[screenCode]/page.tsx` → `/screens/[screenId]/page.tsx` 렌더링 시스템이 존재한다. +새 화면이 필요하면 DB에 등록만 하면 자동으로 렌더링된다. + +#### Step 1: screen_definitions에 화면 등록 + +```sql +INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) +VALUES ('포장/적재정보 관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y') +RETURNING screen_id; +``` + +- `screen_code`: `{company_code}_{기능약어}` 형식 (예: COMPANY_7_PKG) +- `table_name`: 메인 테이블명 (V2 컴포넌트가 이 테이블 기준으로 동작) +- `company_code`: 대상 회사 코드 + +#### Step 2: screen_layouts_v2에 V2 레이아웃 JSON 등록 + +```sql +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) +VALUES ( + {screen_id}, + 'COMPANY_7', + 1, + '기본 레이어', + '{ + "version": "2.0", + "components": [ + { + "id": "comp_split_1", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": {"x": 0, "y": 0}, + "size": {"width": 1200, "height": 800}, + "displayOrder": 0, + "overrides": { + "leftTitle": "포장단위 목록", + "rightTitle": "상세 정보", + "splitRatio": 40, + "leftTableName": "pkg_unit", + "rightTableName": "pkg_unit", + "tabs": [ + {"id": "basic", "label": "기본정보"}, + {"id": "items", "label": "매칭품목"} + ] + } + } + ] + }'::jsonb +); +``` + +- V2 컴포넌트 목록: v2-split-panel-layout, v2-table-list, v2-table-search-widget, v2-repeater, v2-button-primary, v2-tabs-widget 등 +- 상세 컴포넌트 가이드: `.cursor/rules/component-development-guide.mdc` 참조 + +#### Step 3: menu_info에 메뉴 등록 + +```sql +-- 먼저 부모 메뉴 objid 조회 +-- SELECT objid, menu_name_kor FROM menu_info WHERE company_code = '{회사코드}' AND menu_name_kor LIKE '%물류%'; + +INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, screen_code, company_code, status) +VALUES ( + (SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info), + 2, -- 2 = 메뉴 항목 + {부모_objid}, -- 상위 메뉴의 objid + '포장/적재정보', + 10, -- 정렬 순서 + '/screen/COMPANY_7_PKG', -- /screen/{screen_code} 형식 (절대!) + 'COMPANY_7_PKG', -- screen_definitions.screen_code와 일치 + 'COMPANY_7', + 'Y' +); +``` + +**핵심**: `menu_url`은 반드시 `/screen/{screen_code}` 형식이어야 한다! +프론트엔드가 이 URL을 받아 `screen_definitions`에서 screen_id를 찾고, `screen_layouts_v2`에서 레이아웃을 로드한다. ## 2. 관리자 메뉴 등록 (코드 구현 후 필수!) @@ -35,11 +191,14 @@ ```sql -- 예시: 결재 템플릿 관리 메뉴 등록 -INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code) -VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드'); +INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status) +VALUES ( + (SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info), + 2, {부모_objid}, '결재 템플릿', 40, '/admin/approvalTemplate', '대상회사코드', 'Y' +); ``` -- 기존 메뉴 구조를 먼저 조회해서 parent_id, sort_order 등을 맞춰라 +- 기존 메뉴 구조를 먼저 조회해서 parent_obj_id, seq 등을 맞춰라 - company_code 별로 등록이 필요할 수 있다 - menu_auth_group 권한 매핑도 필요하면 추가 @@ -63,16 +222,26 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr 기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다: +### 공통 - [ ] DB: 마이그레이션 작성 + 실행 완료 - [ ] DB: company_code 컬럼 + 인덱스 존재 -- [ ] BE: API 엔드포인트 구현 + 라우트 등록 +- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!) - [ ] BE: company_code 필터링 적용 -- [ ] FE: API 클라이언트 함수 작성 (lib/api/) -- [ ] FE: 화면 컴포넌트 구현 -- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록 - [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc -## 6. 절대 하지 말 것 +### 관리자 메뉴인 경우 +- [ ] FE: `frontend/app/(main)/admin/{기능}/page.tsx` 작성 +- [ ] FE: API 클라이언트 함수 작성 (lib/api/) +- [ ] DB: `menu_info` INSERT (menu_url = `/admin/{기능}`) + +### 사용자 메뉴인 경우 (코드 작성 금지!) +- [ ] DB: `screen_definitions` INSERT (screen_code, table_name, company_code) +- [ ] DB: `screen_layouts_v2` INSERT (V2 레이아웃 JSON) +- [ ] DB: `menu_info` INSERT (menu_url = `/screen/{screen_code}`) +- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) +- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) + +## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT) 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) 2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) @@ -80,3 +249,39 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr 4. 하드코딩 색상/URL/사용자ID 사용 5. Card 안에 Card 중첩 (중첩 박스 금지) 6. 백엔드 재실행하기 (nodemon이 자동 재시작) +7. **[최우선 금지] 사용자 메뉴를 React 하드코딩(.tsx)으로 만들기** + - `frontend/app/(main)/` 하위에서 `/admin/` 이외의 경로에 page.tsx를 만드는 것은 절대 금지 + - 구체적 금지 경로: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ 및 기타 모든 비-admin 경로 + - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 + - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 + - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 + - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 + - **위반 발견 시: 해당 라운드 전체 FAIL 처리, 생성된 파일 즉시 삭제, DB 등록 방식으로 처음부터 재작업** + +## 7. 위반 사례 및 올바른 대응 + +### 위반 사례 (실제 발생한 문제) +``` +# 이런 파일을 만들면 절대 안 된다! +frontend/app/(main)/production/packaging/page.tsx ← REJECT! +frontend/app/(main)/warehouse/inventory/page.tsx ← REJECT! +frontend/app/(main)/quality/inspection/page.tsx ← REJECT! +frontend/app/(main)/mold/management/page.tsx ← REJECT! +``` + +### 올바른 대응 +```sql +-- 1. screen_definitions에 등록 +INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) +VALUES ('포장관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y'); + +-- 2. screen_layouts_v2에 V2 레이아웃 JSON 등록 +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) +VALUES ({screen_id}, 'COMPANY_7', 1, '기본 레이어', '{...V2 JSON...}'::jsonb); + +-- 3. menu_info에 메뉴 등록 +INSERT INTO menu_info (..., menu_url, screen_code, ...) +VALUES (..., '/screen/COMPANY_7_PKG', 'COMPANY_7_PKG', ...); +``` + +**React 페이지(.tsx) 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md index 0eef5611..7c8f5a31 100644 --- a/.cursor/agents/pipeline-frontend.md +++ b/.cursor/agents/pipeline-frontend.md @@ -8,6 +8,63 @@ model: inherit You are a Frontend specialist for ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. +--- + +# !!!! STOP - 파일 생성 전 필수 게이트 (반드시 읽고 확인하라) !!!! + +## 파일을 생성하거나 수정하기 전에 반드시 이 체크를 수행하라: + +### CHECK 1: page.tsx를 만들려고 하는가? + +``` +IF 파일 경로가 "frontend/app/(main)/" 하위이다 + AND 파일명이 page.tsx 또는 layout.tsx이다 + AND 경로에 "/admin/"이 포함되어 있지 않다 +THEN + !!!! 즉시 중단 !!!! 이것은 사용자 메뉴다! + → React 페이지를 만들면 안 된다 + → DB 등록 방식으로 전환하라 (screen_definitions + screen_layouts_v2 + menu_info) + → 이 파일의 "올바른 패턴" 섹션을 참조하라 +END IF +``` + +### 금지 경로 목록 (이 경로에 page.tsx 생성 시 즉시 REJECT): +``` +frontend/app/(main)/production/** ← 금지! +frontend/app/(main)/warehouse/** ← 금지! +frontend/app/(main)/quality/** ← 금지! +frontend/app/(main)/logistics/** ← 금지! +frontend/app/(main)/inventory/** ← 금지! +frontend/app/(main)/purchase/** ← 금지! +frontend/app/(main)/sales/** ← 금지! +frontend/app/(main)/bom/** ← 금지! +frontend/app/(main)/mold/** ← 금지! +frontend/app/(main)/packaging/** ← 금지! +frontend/app/(main)/document/** ← 금지! +frontend/app/(main)/work/** ← 금지! +frontend/app/(main)/order/** ← 금지! +frontend/app/(main)/material/** ← 금지! +frontend/app/(main)/equipment/** ← 금지! +frontend/app/(main)/inspection/** ← 금지! +(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지 대상이다!) +``` + +### 유일하게 허용되는 page.tsx 생성 경로: +``` +frontend/app/(main)/admin/** ← 유일하게 허용! +``` + +### CHECK 2: 사용자 메뉴 키워드 감지 + +요구사항에 아래 키워드가 포함되면 사용자 메뉴일 가능성이 높다: +> 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비, 목록+상세, 좌측 테이블+우측 폼, CRUD 화면 + +사용자 메뉴라면 **page.tsx 생성을 절대 하지 말고** DB 등록으로 전환하라. + +**이 게이트를 통과하지 않은 파일 생성은 전부 REJECT 된다.** + +--- + # CRITICAL PROJECT RULES ## 1. API Client (ABSOLUTE RULE!) @@ -49,9 +106,46 @@ export async function getYourData(id: number) { } ``` +--- + +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) + +**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** +사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! + +## 금지 패턴 (이 파일을 만드는 순간 작업 전체 REJECT) +``` +frontend/app/(main)/production/packaging/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/warehouse/something/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/quality/inspection/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/mold/management/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT! +``` + +## 올바른 패턴 (사용자 메뉴는 DB 등록만으로 완성된다) +사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다: +1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) +2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) +3. `menu_info` 테이블에 메뉴 등록 (menu_url = `/screen/{screen_code}`) + +이미 존재하는 렌더링 시스템: +- `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환 +- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 + +**React 페이지 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** + +## 프론트엔드 에이전트가 할 수 있는 것 +- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) +- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) +- 관리자 메뉴(`/admin/*`)만 React 페이지 코딩 가능 + +## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT) +- `/admin/` 이외 경로에 page.tsx 생성 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩 + # Your Domain - frontend/components/ -- frontend/app/ +- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!) - frontend/lib/ - frontend/hooks/ diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md index 44cf2daa..3717b455 100644 --- a/.cursor/agents/pipeline-ui.md +++ b/.cursor/agents/pipeline-ui.md @@ -8,6 +8,43 @@ model: inherit You are a UI/UX Design specialist for the ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. +--- + +# !!!! STOP - 파일 생성/수정 전 필수 게이트 !!!! + +## 파일을 만들거나 수정하기 전에 반드시 확인하라: + +**page.tsx를 생성하려는 경로가 `frontend/app/(main)/admin/` 하위인가?** +- YES → 진행 가능 +- NO → **즉시 중단!** 사용자 메뉴는 React 페이지로 만들지 않는다! + +**금지 경로 (이 경로에 page.tsx 생성 시 즉시 REJECT):** +``` +frontend/app/(main)/production/** ← 금지! +frontend/app/(main)/warehouse/** ← 금지! +frontend/app/(main)/quality/** ← 금지! +frontend/app/(main)/logistics/** ← 금지! +frontend/app/(main)/inventory/** ← 금지! +frontend/app/(main)/purchase/** ← 금지! +frontend/app/(main)/sales/** ← 금지! +frontend/app/(main)/bom/** ← 금지! +frontend/app/(main)/mold/** ← 금지! +frontend/app/(main)/packaging/** ← 금지! +frontend/app/(main)/document/** ← 금지! +frontend/app/(main)/work/** ← 금지! +frontend/app/(main)/order/** ← 금지! +frontend/app/(main)/material/** ← 금지! +frontend/app/(main)/equipment/** ← 금지! +frontend/app/(main)/inspection/** ← 금지! +(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지!) +``` + +**사용자 메뉴 화면은 DB 등록(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.** + +**이 게이트를 무시하면 작업 전체 REJECT + 파일 삭제 + 재작업 대상이다.** + +--- + # Design Philosophy - Apple-level polish with enterprise functionality - Consistent spacing, typography, color usage @@ -39,9 +76,24 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black - Use cn() for conditional classes - Use lucide-react for ALL icons +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) + +사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. +React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! + +## UI 에이전트가 할 수 있는 것 +- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) +- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 +- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 + +## UI 에이전트가 할 수 없는 것 (위반 시 REJECT) +- `/admin/` 이외 경로에 page.tsx 생성 또는 수정 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩 + # Your Domain - frontend/components/ (UI components) -- frontend/app/ (pages) +- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**) +- frontend/lib/registry/components/v2-*/ (V2 컴포넌트) # Output Rules 1. TypeScript strict mode diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md index a4f4186d..4030eb93 100644 --- a/.cursor/agents/pipeline-verifier.md +++ b/.cursor/agents/pipeline-verifier.md @@ -1,6 +1,6 @@ --- name: pipeline-verifier -description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지. model: fast readonly: true --- @@ -11,6 +11,29 @@ Your job is to verify that work claimed as complete actually works. # Verification Checklist +## 0. 하드코딩 페이지 탐지 (최최우선! 이것부터 먼저 확인!) + +**이 프로젝트에서 가장 심각한 위반은 사용자 메뉴를 React 페이지(.tsx)로 하드코딩하는 것이다.** +검증 시 반드시 아래를 제일 먼저 확인하라: + +- [ ] `frontend/app/(main)/` 하위에 `/admin/` 이외의 경로에 새로운 page.tsx가 생성되지 않았는가? +- [ ] 구체적 금지 경로 확인: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ +- [ ] 위 경로뿐 아니라 `/admin/` 이외의 **모든** 경로에 page.tsx가 새로 생성되었는지 확인 +- [ ] 사용자 메뉴 화면이 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 구현되었는가? + +**검증 방법:** +```bash +# 이 라운드에서 새로 생성된 파일 중 금지 경로의 page.tsx가 있는지 확인 +git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep -v "/admin/" +# 결과가 있으면 → 즉시 FAIL! +``` + +**위반 발견 시:** +- 검증 결과: **CRITICAL FAIL** +- 해당 파일 삭제 필수 +- DB 등록 방식으로 재작업 지시 +- 이 위반이 있으면 다른 항목 전부 PASS여도 최종 결과는 FAIL + ## 1. Multi-tenancy (최우선) - [ ] 모든 SQL에 company_code 필터 존재 - [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) @@ -28,6 +51,7 @@ Your job is to verify that work claimed as complete actually works. - [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) - [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) - [ ] Frontend: V2 컴포넌트 규격 준수 +- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!) - [ ] Backend: logger 사용 - [ ] Backend: try/catch 에러 처리 @@ -39,7 +63,10 @@ Your job is to verify that work claimed as complete actually works. # Reporting Format ``` -## 검증 결과: [PASS/FAIL] +## 검증 결과: [PASS/FAIL/CRITICAL FAIL] + +### [CRITICAL] 하드코딩 페이지 탐지 +- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)") ### 통과 항목 - item 1 @@ -55,3 +82,4 @@ Your job is to verify that work claimed as complete actually works. ``` Do not accept claims at face value. Check the actual code. +하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 FAIL이면 전체 FAIL이다. diff --git a/.cursorrules b/.cursorrules index 77180695..0019badc 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1510,3 +1510,69 @@ const query = ` **company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!** +--- + +## DB 테이블 생성 필수 규칙 + +**상세 가이드**: [table-type-sql-guide.mdc](.cursor/rules/table-type-sql-guide.mdc) + +### 핵심 원칙 (절대 위반 금지) + +1. **모든 비즈니스 컬럼은 `VARCHAR(500)`**: NUMERIC, INTEGER, SERIAL, TEXT 등 DB 타입 직접 지정 금지 +2. **기본 5개 컬럼 자동 포함** (모든 테이블 필수): + ```sql + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500) + ``` +3. **3개 메타데이터 테이블 등록 필수**: + - `table_labels`: 테이블 라벨/설명 + - `table_type_columns`: 컬럼 input_type, detail_settings (company_code = '*') + - `column_labels`: 컬럼 한글 라벨 (레거시 호환) +4. **input_type으로 타입 구분**: text, number, date, code, entity, select, checkbox, radio, textarea +5. **ON CONFLICT 절 필수**: 중복 시 UPDATE 처리 + +### 금지 사항 + +- `SERIAL`, `INTEGER`, `NUMERIC`, `BOOLEAN`, `TEXT`, `DATE` 등 DB 타입 직접 사용 금지 +- `VARCHAR` 길이 변경 금지 (반드시 500) +- 기본 5개 컬럼 누락 금지 +- 메타데이터 테이블 미등록 금지 + +--- + +## 화면 개발 방식 필수 규칙 (사용자 메뉴 vs 관리자 메뉴) + +**상세 가이드**: [pipeline-common-rules.md](.cursor/agents/pipeline-common-rules.md) + +### 핵심 원칙 (절대 위반 금지) + +1. **사용자 업무 화면은 React 코드(.tsx)로 직접 만들지 않는다!** + - 포장관리, 금형관리, BOM, 입출고, 품질 등 일반 업무 화면 + - DB에 `screen_definitions` + `screen_layouts_v2` + `menu_info` 등록으로 구현 + - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재 + - V2 컴포넌트(v2-split-panel-layout, v2-table-list, v2-repeater 등)로 레이아웃 구성 + +2. **관리자 메뉴만 React 코드로 작성 가능** + - 사용자 관리, 권한 관리, 시스템 설정 등 + - `frontend/app/(main)/admin/{기능}/page.tsx`에 작성 + - `menu_info` 테이블에 메뉴 등록 필수 + +### 사용자 메뉴 구현 순서 + +``` +1. DB 테이블 생성 (비즈니스 데이터용) +2. screen_definitions INSERT (screen_code, table_name) +3. screen_layouts_v2 INSERT (V2 레이아웃 JSON) +4. menu_info INSERT (menu_url = '/screen/{screen_code}') +5. 필요하면 백엔드 전용 API 추가 (범용 API로 안 되는 경우만) +``` + +### 금지 사항 + +- `frontend/app/(main)/production/*/page.tsx` 같은 사용자 화면 하드코딩 금지 +- `frontend/app/(main)/warehouse/*/page.tsx` 같은 사용자 화면 하드코딩 금지 +- 사용자 메뉴의 UI를 React 컴포넌트로 직접 구현하는 것 금지 + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 914f608c..f45a88cd 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -131,6 +131,7 @@ import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 +import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 @@ -321,6 +322,7 @@ app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리 app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 +app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 9dea63b8..6f0997a9 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3574,7 +3574,7 @@ export async function getTableSchema( ic.character_maximum_length, ic.numeric_precision, ic.numeric_scale, - COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label, + COALESCE(NULLIF(ttc_company.column_label, ic.column_name), ttc_common.column_label) AS column_label, COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order, COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable, COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index ebf3e8f5..2bb72876 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -6,6 +6,7 @@ import { AuthService } from "../services/authService"; import { JwtUtils } from "../utils/jwtUtils"; import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; +import { sendSmartFactoryLog } from "../utils/smartFactoryLog"; export class AuthController { /** @@ -86,13 +87,20 @@ export class AuthController { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); } + // 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함) + sendSmartFactoryLog({ + userId: userInfo.userId, + remoteAddr, + useType: "접속", + }).catch(() => {}); + res.status(200).json({ success: true, message: "로그인 성공", data: { userInfo, token: loginResult.token, - firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가 + firstMenuPath, }, }); } else { diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index c0c4c36d..fa70de66 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -266,7 +266,6 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re logger.info("컬럼 DISTINCT 값 조회 성공", { tableName, columnName, - columnInputType: columnInputType || "none", labelColumn: effectiveLabelColumn, companyCode, hasFilters: !!filtersParam, diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 60a0af08..49fe6e72 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -62,24 +62,31 @@ export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Resp */ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; + const userCompanyCode = req.user!.companyCode; const { tableName, columnName } = req.params; const includeInactive = req.query.includeInactive === "true"; const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined; + const filterCompanyCode = req.query.filterCompanyCode as string | undefined; + + // 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용 + const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode) + ? filterCompanyCode + : userCompanyCode; logger.info("카테고리 값 조회 요청", { tableName, columnName, menuObjid, - companyCode, + companyCode: effectiveCompanyCode, + filterCompanyCode, }); const values = await tableCategoryValueService.getCategoryValues( tableName, columnName, - companyCode, + effectiveCompanyCode, includeInactive, - menuObjid // ← menuObjid 전달 + menuObjid ); return res.json({ diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5087a1c9..0ab73e09 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -3105,3 +3105,153 @@ export async function getNumberingColumnsByCompany( }); } } + +/** + * 엑셀 업로드 전 데이터 검증 + * POST /api/table-management/validate-excel + * Body: { tableName, data: Record[] } + */ +export async function validateExcelData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, data } = req.body as { + tableName: string; + data: Record[]; + }; + 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(); + 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>(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: "데이터 검증 중 오류가 발생했습니다." }); + } +} diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts new file mode 100644 index 00000000..f501269e --- /dev/null +++ b/backend-node/src/routes/packagingRoutes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +router.use(authenticateToken); + +// TODO: 포장/적재정보 관리 API 구현 예정 + +export default router; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 92449cf6..6a4a8ce8 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -27,6 +27,7 @@ import { getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 getNumberingColumnsByCompany, // 채번 타입 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + validateExcelData, // 엑셀 업로드 전 데이터 검증 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 getTableConstraints, // 🆕 PK/인덱스 상태 조회 @@ -280,4 +281,9 @@ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); */ router.post("/multi-table-save", multiTableSave); +/** + * 엑셀 업로드 전 데이터 검증 + */ +router.post("/validate-excel", validateExcelData); + export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 1b183074..604405c3 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1715,8 +1715,8 @@ export class DynamicFormService { `SELECT component_id, properties FROM screen_layouts WHERE screen_id = $1 - AND component_type = $2`, - [screenId, "component"] + AND component_type IN ('component', 'v2-button-primary')`, + [screenId] ); console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length); @@ -1747,8 +1747,12 @@ export class DynamicFormService { (triggerType === "delete" && buttonActionType === "delete") || ((triggerType === "insert" || triggerType === "update") && buttonActionType === "save"); + const isButtonComponent = + properties?.componentType === "button-primary" || + properties?.componentType === "v2-button-primary"; + if ( - properties?.componentType === "button-primary" && + isButtonComponent && isMatchingAction && properties?.webTypeConfig?.enableDataflowControl === true ) { @@ -1877,7 +1881,7 @@ export class DynamicFormService { { sourceData: [savedData], dataSourceType: "formData", - buttonId: "save-button", + buttonId: `${triggerType}-button`, screenId: screenId, userId: userId, companyCode: companyCode, diff --git a/backend-node/src/services/multiTableExcelService.ts b/backend-node/src/services/multiTableExcelService.ts index 7f9de79d..03e5db4c 100644 --- a/backend-node/src/services/multiTableExcelService.ts +++ b/backend-node/src/services/multiTableExcelService.ts @@ -972,7 +972,7 @@ class MultiTableExcelService { c.column_name, c.is_nullable AS db_is_nullable, c.column_default, - COALESCE(ttc.column_label, cl.column_label) AS column_label, + COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label) AS column_label, COALESCE(ttc.reference_table, cl.reference_table) AS reference_table, COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable FROM information_schema.columns c diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4c5bdc57..64b1dff0 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,19 +2346,24 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) + * 메뉴별 화면 목록 조회 + * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 + * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.* FROM screen_menu_assignments sma + `SELECT sd.*, sma.company_code AS assign_company_code + FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND sma.company_code = $2 + AND (sma.company_code = $2 OR sma.company_code = '*') AND sma.is_active = 'Y' - ORDER BY sma.display_order ASC`, + ORDER BY + CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, + sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 96efdfbb..a8b12605 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -217,12 +217,12 @@ class TableCategoryValueService { AND column_name = $2 `; - // category_values 테이블 사용 (menu_objid 없음) + // company_code 기반 필터링 if (companyCode === "*") { - // 최고 관리자: 모든 값 조회 - query = baseSelect; + // 최고 관리자: 공통(*) 카테고리만 조회 (모든 회사 카테고리 혼합 방지) + query = baseSelect + ` AND company_code = '*'`; params = [tableName, columnName]; - logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)"); + logger.info("최고 관리자: 공통 카테고리만 조회 (category_values)"); } else { // 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회 query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6d994f93..d727a96e 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -190,7 +190,7 @@ export class TableManagementService { ? await query( `SELECT c.column_name as "columnName", - COALESCE(ttc.column_label, cl.column_label, c.column_name) as "displayName", + COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label, c.column_name) as "displayName", c.data_type as "dataType", c.data_type as "dbType", COALESCE(ttc.input_type, cl.input_type, 'text') as "webType", diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts new file mode 100644 index 00000000..ea8d9aec --- /dev/null +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -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 { + 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}`; +} diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index da749392..d3a4b187 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -24,6 +24,7 @@ import { FileSpreadsheet, AlertCircle, CheckCircle2, + XCircle, ArrowRight, Zap, Copy, @@ -136,6 +137,10 @@ export const ExcelUploadModal: React.FC = ({ // 중복 처리 방법 (전역 설정) const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip"); + // 엑셀 데이터 사전 검증 결과 + const [isDataValidating, setIsDataValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + // 카테고리 검증 관련 const [showCategoryValidation, setShowCategoryValidation] = useState(false); const [isCategoryValidating, setIsCategoryValidating] = useState(false); @@ -874,6 +879,43 @@ export const ExcelUploadModal: React.FC = ({ setShowCategoryValidation(true); return; } + + // 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복) + setIsDataValidating(true); + try { + const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement"); + + // 매핑된 데이터 구성 + const mappedForValidation = allData.map((row) => { + const mapped: Record = {}; + columnMappings.forEach((m) => { + if (m.systemColumn) { + let colName = m.systemColumn; + if (isMasterDetail && colName.includes(".")) { + colName = colName.split(".")[1]; + } + mapped[colName] = row[m.excelColumn]; + } + }); + return mapped; + }).filter((row) => Object.values(row).some((v) => v !== null && v !== undefined && String(v).trim() !== "")); + + if (mappedForValidation.length > 0) { + const result = await validateExcel(tableName, mappedForValidation); + if (result.success && result.data) { + setValidationResult(result.data); + } else { + setValidationResult(null); + } + } else { + setValidationResult(null); + } + } catch (err) { + console.warn("데이터 사전 검증 실패 (무시):", err); + setValidationResult(null); + } finally { + setIsDataValidating(false); + } } setCurrentStep((prev) => Math.min(prev + 1, 3)); @@ -1301,6 +1343,9 @@ export const ExcelUploadModal: React.FC = ({ setSystemColumns([]); setColumnMappings([]); setDuplicateAction("skip"); + // 검증 상태 초기화 + setValidationResult(null); + setIsDataValidating(false); // 카테고리 검증 초기화 setShowCategoryValidation(false); setCategoryMismatches({}); @@ -1870,6 +1915,100 @@ export const ExcelUploadModal: React.FC = ({ + {/* 데이터 검증 결과 */} + {validationResult && !validationResult.isValid && ( +
+ {/* NOT NULL 에러 */} + {validationResult.notNullErrors.length > 0 && ( +
+

+ + 필수값 누락 ({validationResult.notNullErrors.length}건) +

+
+ {(() => { + const grouped = new Map(); + 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]) => ( +

+ {label}: {rows.length > 5 ? `행 ${rows.slice(0, 5).join(", ")} 외 ${rows.length - 5}건` : `행 ${rows.join(", ")}`} +

+ )); + })()} +
+
+ )} + + {/* 엑셀 내부 중복 */} + {validationResult.uniqueInExcelErrors.length > 0 && ( +
+

+ + 엑셀 내 중복 ({validationResult.uniqueInExcelErrors.length}건) +

+
+ {validationResult.uniqueInExcelErrors.slice(0, 10).map((err, i) => ( +

+ {err.label} "{err.value}": 행 {err.rows.join(", ")} +

+ ))} + {validationResult.uniqueInExcelErrors.length > 10 && ( +

...외 {validationResult.uniqueInExcelErrors.length - 10}건

+ )} +
+
+ )} + + {/* DB 기존 데이터 중복 */} + {validationResult.uniqueInDbErrors.length > 0 && ( +
+

+ + DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) +

+
+ {(() => { + const grouped = new Map(); + 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]) => ( +
+ {items.slice(0, 5).map((item, i) => ( +

+ {label} "{item.value}": 행 {item.rows.join(", ")} +

+ ))} + {items.length > 5 &&

...외 {items.length - 5}건

} +
+ )); + })()} +
+
+ )} +
+ )} + + {validationResult?.isValid && ( +
+

+ + 데이터 검증 통과 +

+

+ 필수값 및 중복 검사를 통과했습니다. +

+
+ )} +

컬럼 매핑

@@ -1948,10 +2087,10 @@ export const ExcelUploadModal: React.FC = ({ {currentStep < 3 ? ( )} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 20175b5e..62280f9d 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; +import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; +import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -10,70 +12,320 @@ const LoadingFallback = () => (
); +const d = (loader: () => Promise) => + 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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ; + + if (error || !dashboard) { + return ( +
+
+

{error || "대시보드를 찾을 수 없습니다"}

+

대시보드 ID: {dashboardId}

+
+
+ ); + } + + return ( +
+ +
+ ); +} + +/** + * /screen/[screenCode] URL을 screenId로 변환해서 ScreenViewPageWrapper를 렌더링 + */ +function ScreenCodeResolver({ screenCode }: { screenCode: string }) { + const [screenId, setScreenId] = useState(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 ( +
+
+

화면을 찾을 수 없습니다

+

+ 화면 코드: {screenCode} +

+
+
+ ); + } + + if (screenId === null) { + return ; + } + + return ; +} + /** * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. - * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. + * 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다. */ const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 - "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), + "/admin": d(() => import("@/app/(main)/admin/page")), // 메뉴 관리 - "/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }), + "/admin/menu": d(() => import("@/app/(main)/admin/menu/page")), // 사용자 관리 - "/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")), + "/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")), + "/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")), + "/admin/userMng/companyList": d(() => import("@/app/(main)/admin/userMng/companyList/page")), // 화면 관리 - "/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")), + "/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")), + "/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")), + "/admin/screenMng/reportList": d(() => import("@/app/(main)/admin/screenMng/reportList/page")), + "/admin/screenMng/barcodeList": d(() => import("@/app/(main)/admin/screenMng/barcodeList/page")), // 시스템 관리 - "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")), + "/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")), + "/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")), + "/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")), + "/admin/systemMng/dataflow": d(() => import("@/app/(main)/admin/systemMng/dataflow/page")), + "/admin/systemMng/dataflow/node-editorList": d(() => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page")), // 자동화 관리 - "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")), + "/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")), + "/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")), + "/admin/automaticMng/exCallConfList": d(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page")), // 메일 - "/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")), + "/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")), + "/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")), + "/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")), + "/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")), + "/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")), + "/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")), + "/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")), + "/admin/automaticMng/mail/bulk-send": d(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page")), // 배치 관리 - "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), + "/admin/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")), + "/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")), - // 기타 - "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), - "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), - "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), - "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), - "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), - "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), + // 결재 관리 + "/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")), + "/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")), + "/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")), + + // AI 어시스턴트 + "/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")), + "/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")), + "/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")), + "/admin/aiAssistant/api-keys": d(() => import("@/app/(main)/admin/aiAssistant/api-keys/page")), + "/admin/aiAssistant/dashboard": d(() => import("@/app/(main)/admin/aiAssistant/dashboard/page")), + "/admin/aiAssistant/chat": d(() => import("@/app/(main)/admin/aiAssistant/chat/page")), + "/admin/aiAssistant/api-test": d(() => import("@/app/(main)/admin/aiAssistant/api-test/page")), + + // 기타 관리 + "/admin/cascading-management": d(() => import("@/app/(main)/admin/cascading-management/page")), + "/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")), + "/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")), + "/admin/templates": d(() => import("@/app/(main)/admin/templates/page")), + "/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")), + "/admin/standards": d(() => import("@/app/(main)/admin/standards/page")), + "/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")), + "/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")), + "/admin/system-notices": d(() => import("@/app/(main)/admin/system-notices/page")), + "/admin/audit-log": d(() => import("@/app/(main)/admin/audit-log/page")), + + // 개발/테스트 + "/admin/debug": d(() => import("@/app/(main)/admin/debug/page")), + "/admin/debug-simple": d(() => import("@/app/(main)/admin/debug-simple/page")), + "/admin/debug-layout": d(() => import("@/app/(main)/admin/debug-layout/page")), + "/admin/test": d(() => import("@/app/(main)/admin/test/page")), + "/admin/ui-components-demo": d(() => import("@/app/(main)/admin/ui-components-demo/page")), + "/admin/validation-demo": d(() => import("@/app/(main)/admin/validation-demo/page")), + "/admin/token-test": d(() => import("@/app/(main)/admin/token-test/page")), + + // === 사용자 화면 (admin이 아닌 URL 기반 메뉴) === + "/approval": d(() => import("@/app/(main)/approval/page")), + "/dashboard": d(() => import("@/app/(main)/dashboard/page")), + "/multilang": d(() => import("@/app/(main)/multilang/page")), + "/test-flow": d(() => import("@/app/(main)/test-flow/page")), + "/main": d(() => import("@/app/(main)/main/page")), }; -// 매핑되지 않은 URL용 Fallback +/** + * 동적 라우트 패턴 매칭 (URL 경로에 동적 세그먼트가 포함된 경우) + * /admin/screenMng/dashboardList/123 → dashboardList/[id] 페이지에 매핑 + * + * extractParams: URL에서 동적 파라미터를 추출 (use(params)를 쓰는 페이지용) + * 추출된 값은 params={Promise.resolve(...)}로 전달되어 + * Next.js 라우팅 컨텍스트 없이도 use(params)가 정상 동작함 + */ +interface DynamicRouteEntry { + pattern: RegExp; + loader: () => Promise; + extractParams?: (url: string) => Record; +} + +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; + params?: Record; +} + +const dynamicRouteCache = new Map(); + +function resolveDynamicRoute(cleanUrl: string): DynamicRouteResult | null { + if (dynamicRouteCache.has(cleanUrl)) { + return dynamicRouteCache.get(cleanUrl)!; + } + + for (const entry of DYNAMIC_ROUTE_PATTERNS) { + if (entry.pattern.test(cleanUrl)) { + const comp = d(entry.loader); + const params = entry.extractParams?.(cleanUrl); + const result: DynamicRouteResult = { component: comp, params }; + dynamicRouteCache.set(cleanUrl, result); + return result; + } + } + return null; +} + function AdminPageFallback({ url }: { url: string }) { return (
@@ -95,15 +347,55 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const PageComponent = useMemo(() => { - // URL에서 쿼리스트링/해시 제거 후 매칭 - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - return ADMIN_PAGE_REGISTRY[cleanUrl] || null; - }, [url]); + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - if (!PageComponent) { + // 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링 + // 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달 + const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); + if (screenIdMatch) { + const screenId = parseInt(screenIdMatch[1]); + return ; + } + + // 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링 + const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); + if (screenCodeMatch) { + return ; + } + + // 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링 + // Next.js의 params Promise를 우회하여 dashboardId를 직접 전달 + const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); + if (dashboardMatch) { + return ; + } + + 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 ; } + const { component: PageComponent, params } = resolved; + + // 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달 + // Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨 + if (params) { + return ; + } + return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index ad9a6aaf..2fe934a4 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -362,8 +362,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { if (isMobile) setSidebarOpen(false); return; } - } catch { - console.warn("할당된 화면 조회 실패"); + } catch (err) { + console.error("할당된 화면 조회 실패:", err); + toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요."); + return; } if (menu.url && menu.url !== "#") { diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 49f0f586..17fd7616 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1850,7 +1850,7 @@ export const InteractiveScreenViewer: React.FC = ( try { // console.log("🗑️ 삭제 실행:", { recordId, tableName, formData }); - const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName); + const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id); if (result.success) { alert("삭제되었습니다."); diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 24ef25a0..50824d7b 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -372,3 +372,30 @@ export const getTableColumns = (tableName: string) => tableManagementApi.getColu export const updateColumnType = (tableName: string, columnName: string, settings: ColumnSettings) => tableManagementApi.updateColumnSettings(tableName, columnName, settings); export const checkTableExists = (tableName: string) => tableManagementApi.checkTableExists(tableName); + +// 엑셀 업로드 전 데이터 검증 API +export interface ExcelValidationResult { + isValid: boolean; + notNullErrors: { row: number; column: string; label: string }[]; + uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[]; + uniqueInDbErrors: { row: number; column: string; label: string; value: string }[]; + summary: { notNull: number; uniqueInExcel: number; uniqueInDb: number }; +} + +export async function validateExcelData( + tableName: string, + data: Record[] +): Promise> { + try { + const response = await apiClient.post>( + "/table-management/validate-excel", + { tableName, data } + ); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "데이터 검증 실패", + }; + } +} diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index 63a4288a..4c3d3112 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -20,6 +20,7 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -512,23 +513,27 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 + // 위치 코드 생성 (패턴 기반) const generateLocationCode = useCallback( (row: number, level: number): { code: string; name: string } => { - const warehouseCode = context?.warehouseCode || "WH001"; - const floor = context?.floor || "1"; - const zone = context?.zone || "A"; + const vars = { + warehouse: context?.warehouseCode || "WH001", + warehouseName: context?.warehouseName || "", + floor: context?.floor || "1", + zone: context?.zone || "A", + row, + level, + }; - // 코드 생성 (예: WH001-1층D구역-01-1) - const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + const codePattern = config.codePattern || DEFAULT_CODE_PATTERN; + const namePattern = config.namePattern || DEFAULT_NAME_PATTERN; - // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 - const zoneName = zone.includes("구역") ? zone : `${zone}구역`; - const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; - - return { code, name }; + return { + code: applyLocationPattern(codePattern, vars), + name: applyLocationPattern(namePattern, vars), + }; }, - [context], + [context, config.codePattern, config.namePattern], ); // 미리보기 생성 diff --git a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx index 17e1a781..ddaebfa2 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -12,6 +12,47 @@ import { SelectValue, } from "@/components/ui/select"; import { RackStructureComponentConfig, FieldMapping } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils"; + +// 패턴 미리보기 서브 컴포넌트 +const PatternPreview: React.FC<{ + codePattern?: string; + namePattern?: string; +}> = ({ codePattern, namePattern }) => { + const sampleVars = { + warehouse: "WH002", + warehouseName: "2창고", + floor: "2층", + zone: "A구역", + row: 1, + level: 3, + }; + + const previewCode = useMemo( + () => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars), + [codePattern], + ); + const previewName = useMemo( + () => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars), + [namePattern], + ); + + return ( +
+
미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)
+
+
+ 위치코드: + {previewCode} +
+
+ 위치명: + {previewName} +
+
+
+ ); +}; interface RackStructureConfigPanelProps { config: RackStructureComponentConfig; @@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC =
+ {/* 위치코드 패턴 설정 */} +
+
위치코드/위치명 패턴
+

+ 변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요 +

+ + {/* 위치코드 패턴 */} +
+ + handleChange("codePattern", e.target.value || undefined)} + placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"} +

+
+ + {/* 위치명 패턴 */} +
+ + handleChange("namePattern", e.target.value || undefined)} + placeholder="{zone}-{row:02}열-{level}단" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{zone}-{row:02}열-{level}단"} +

+
+ + {/* 실시간 미리보기 */} + + + {/* 사용 가능한 변수 목록 */} +
+
사용 가능한 변수
+
+ {PATTERN_VARIABLES.map((v) => ( +
+ {v.token} + {v.description} +
+ ))} +
+
+
+ {/* 제한 설정 */}
제한 설정
diff --git a/frontend/lib/registry/components/rack-structure/patternUtils.ts b/frontend/lib/registry/components/rack-structure/patternUtils.ts new file mode 100644 index 00000000..b5139c0b --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/patternUtils.ts @@ -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"; diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx index 67587110..ee73fa07 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -222,6 +222,61 @@ export const RackStructureConfigPanel: React.FC =
+ {/* 위치코드 패턴 설정 */} +
+
위치코드/위치명 패턴
+

+ 변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요 +

+ + {/* 위치코드 패턴 */} +
+ + handleChange("codePattern", e.target.value || undefined)} + placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"} +

+
+ + {/* 위치명 패턴 */} +
+ + handleChange("namePattern", e.target.value || undefined)} + placeholder="{zone}-{row:02}열-{level}단" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{zone}-{row:02}열-{level}단"} +

+
+ + {/* 실시간 미리보기 */} + + + {/* 사용 가능한 변수 목록 */} +
+
사용 가능한 변수
+
+ {PATTERN_VARIABLES.map((v) => ( +
+ {v.token} + {v.description} +
+ ))} +
+
+
+ {/* 제한 설정 */}
제한 설정
diff --git a/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts b/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts new file mode 100644 index 00000000..d226db82 --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts @@ -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 = { + 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 = { + 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" }, +]; diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index f040992b..19228d6d 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -729,7 +729,7 @@ export const TableListComponent: React.FC = ({ const [categoryMappings, setCategoryMappings] = useState< Record> >({}); - const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용 + const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); const [searchValues, setSearchValues] = useState>({}); const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); @@ -1064,9 +1064,14 @@ export const TableListComponent: React.FC = ({ const getColumnUniqueValues = async (columnName: string) => { const { apiClient } = await import("@/lib/api/client"); + // 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링 + const filterParam = companyCode && companyCode !== "*" + ? `?filterCompanyCode=${encodeURIComponent(companyCode)}` + : ""; + // 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도) try { - const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values${filterParam}`); if (response.data.success && response.data.data && response.data.data.length > 0) { return response.data.data.map((item: any) => ({ value: item.valueCode, @@ -1171,15 +1176,13 @@ export const TableListComponent: React.FC = ({ tableConfig.selectedTable, tableConfig.columns, columnLabels, - columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요) - categoryMappings, // 카테고리 매핑 변경 시 재등록 (필터 라벨 변환용) + columnMeta, + categoryMappings, columnWidths, tableLabel, - data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) - totalItems, // 전체 항목 수가 변경되면 재등록 + data, + totalItems, registerTable, - // unregisterTable은 의존성에서 제외 - 무한 루프 방지 - // unregisterTable 함수는 의존성이 없어 안정적임 ]); // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용) @@ -1423,7 +1426,13 @@ export const TableListComponent: React.FC = ({ const mappings: Record> = {}; const apiClient = (await import("@/lib/api/client")).apiClient; + // 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링 + const filterCompanyParam = companyCode && companyCode !== "*" + ? `&filterCompanyCode=${encodeURIComponent(companyCode)}` + : ""; + // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) + // valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴) const flattenTree = (items: any[], mapping: Record) => { items.forEach((item: any) => { if (item.valueCode) { @@ -1432,12 +1441,6 @@ export const TableListComponent: React.FC = ({ color: item.color, }; } - if (item.valueId !== undefined && item.valueId !== null) { - mapping[String(item.valueId)] = { - label: item.valueLabel, - color: item.color, - }; - } if (item.children && Array.isArray(item.children) && item.children.length > 0) { flattenTree(item.children, mapping); } @@ -1465,7 +1468,7 @@ export const TableListComponent: React.FC = ({ } // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`); + const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true${filterCompanyParam}`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1548,7 +1551,7 @@ export const TableListComponent: React.FC = ({ // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { - const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`); + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true${filterCompanyParam}`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1618,6 +1621,7 @@ export const TableListComponent: React.FC = ({ JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns), columnMeta, + companyCode, ]); // ======================================== diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index 759c57dd..c5f1ad54 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -1508,7 +1508,38 @@ export const TableListConfigPanel: React.FC = ({ /> {column.columnLabel} - + {isAlreadyAdded && ( + + )} + {column.inputType || column.dataType}
diff --git a/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx index 22817ee0..d8905f76 100644 --- a/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx @@ -457,7 +457,38 @@ export const ColumnsConfigPanel: React.FC = ({ /> {column.columnLabel} - + {isAlreadyAdded && ( + + )} + {column.inputType || column.dataType}