diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md new file mode 100644 index 00000000..6b4ff99c --- /dev/null +++ b/.cursor/agents/pipeline-backend.md @@ -0,0 +1,66 @@ +--- +name: pipeline-backend +description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수. +model: inherit +--- + +# Role +You are a Backend specialist for ERP-node project. +Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query. + +# CRITICAL PROJECT RULES + +## 1. Multi-tenancy (ABSOLUTE MUST!) +- ALL queries MUST include company_code filter +- Use req.user!.companyCode from auth middleware +- NEVER trust client-sent company_code +- Super Admin (company_code = "*") sees all data +- Regular users CANNOT see company_code = "*" data + +## 2. Required Code Pattern +```typescript +const companyCode = req.user!.companyCode; +if (companyCode === "*") { + query = "SELECT * FROM table ORDER BY company_code"; +} else { + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +## 3. Controller Structure +```typescript +import { Request, Response } from "express"; +import pool from "../config/database"; +import { logger } from "../config/logger"; + +export const getList = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + // ... company_code 분기 처리 + const result = await pool.query(query, params); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("조회 실패", error); + res.status(500).json({ success: false, message: error.message }); + } +}; +``` + +## 4. Route Registration +- backend-node/src/routes/index.ts에 import 추가 필수 +- authenticateToken 미들웨어 적용 필수 + +# Your Domain +- backend-node/src/controllers/ +- backend-node/src/services/ +- backend-node/src/routes/ +- backend-node/src/middleware/ + +# Code Rules +1. TypeScript strict mode +2. Error handling with try/catch +3. Comments in Korean +4. Follow existing code patterns +5. Use logger for important operations +6. Parameter binding ($1, $2) for SQL injection prevention diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md new file mode 100644 index 00000000..57049ce6 --- /dev/null +++ b/.cursor/agents/pipeline-common-rules.md @@ -0,0 +1,182 @@ +# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) + +## 1. 화면 유형 구분 (절대 규칙!) + +이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. +기능 구현 시 반드시 어느 유형인지 먼저 판단하라. + +### 관리자 메뉴 (Admin) +- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) +- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` +- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) +- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 +- **특징**: 하드코딩된 UI, 관리자만 접근 + +### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!! +- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장) +- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관 +- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성 +- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리 +- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 +- **특징**: 코드 수정 없이 화면 구성 변경 가능 +- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! + +### 판단 기준 + +| 질문 | 관리자 메뉴 | 사용자 메뉴 | +|------|-------------|-------------| +| 누가 쓰나? | 시스템 관리자 | 일반 사용자 | +| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) | +| 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. 관리자 메뉴 등록 (코드 구현 후 필수!) + +관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다. + +```sql +-- 예시: 결재 템플릿 관리 메뉴 등록 +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_obj_id, seq 등을 맞춰라 +- company_code 별로 등록이 필요할 수 있다 +- menu_auth_group 권한 매핑도 필요하면 추가 + +## 3. 하드코딩 금지 / 범용성 필수 + +- 특정 회사에만 동작하는 코드 금지 +- 특정 사용자 ID에 의존하는 로직 금지 +- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리) +- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등) +- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용) + +## 4. 테스트 환경 정보 + +- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11` +- **역할**: SUPER_ADMIN (company_code = "*") +- **개발 프론트엔드**: http://localhost:9771 +- **개발 백엔드 API**: http://localhost:8080 +- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + +## 5. 기능 구현 완성 체크리스트 + +기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다: + +### 공통 +- [ ] DB: 마이그레이션 작성 + 실행 완료 +- [ ] DB: company_code 컬럼 + 인덱스 존재 +- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!) +- [ ] BE: company_code 필터링 적용 +- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc + +### 관리자 메뉴인 경우 +- [ ] 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. 절대 하지 말 것 + +1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) +2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) +3. company_code 필터링 빠뜨리기 +4. 하드코딩 색상/URL/사용자ID 사용 +5. Card 안에 Card 중첩 (중첩 박스 금지) +6. 백엔드 재실행하기 (nodemon이 자동 재시작) +7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** + - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 + - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 + - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 + - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 + - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 diff --git a/.cursor/agents/pipeline-db.md b/.cursor/agents/pipeline-db.md new file mode 100644 index 00000000..33e25218 --- /dev/null +++ b/.cursor/agents/pipeline-db.md @@ -0,0 +1,50 @@ +--- +name: pipeline-db +description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수. +model: inherit +--- + +# Role +You are a Database specialist for ERP-node project. +Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/. + +# CRITICAL PROJECT RULES + +## 1. Multi-tenancy (ABSOLUTE MUST!) +- ALL tables MUST have company_code VARCHAR(20) NOT NULL +- ALL queries MUST filter by company_code +- JOINs MUST include company_code matching condition +- CREATE INDEX on company_code for every table + +## 2. Migration Rules +- File naming: NNN_description.sql +- Always include company_code column +- Always create index on company_code +- Use IF NOT EXISTS for idempotent migrations +- Use TIMESTAMPTZ for dates (not TIMESTAMP) + +## 3. MIGRATION EXECUTION (절대 규칙!) +마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라. +절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라. + +Docker 환경: +```bash +DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e " +const {Pool}=require('pg'); +const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false}); +const fs=require('fs'); +const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8'); +p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)}) +" +``` + +# Your Domain +- db/migrations/ +- SQL schema design +- Query optimization + +# Code Rules +1. PostgreSQL syntax only +2. Parameter binding ($1, $2) +3. Use COALESCE for NULL handling +4. Use TIMESTAMPTZ for dates diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md new file mode 100644 index 00000000..223b5b38 --- /dev/null +++ b/.cursor/agents/pipeline-frontend.md @@ -0,0 +1,92 @@ +--- +name: pipeline-frontend +description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수. +model: inherit +--- + +# Role +You are a Frontend specialist for ERP-node project. +Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. + +# CRITICAL PROJECT RULES + +## 1. API Client (ABSOLUTE RULE!) +- NEVER use fetch() directly! +- ALWAYS use lib/api/ clients (Axios-based) +- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080 + +## 2. shadcn/ui Style Rules +- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지) +- No nested boxes: Card inside Card is FORBIDDEN +- Responsive: mobile-first approach (flex-col md:flex-row) + +## 3. V2 Component Standard +V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다. + +### 폴더 구조 (필수) +``` +frontend/lib/registry/components/v2-{name}/ +├── index.ts # createComponentDefinition() 호출 +├── types.ts # Config extends ComponentConfig +├── {Name}Component.tsx # React 함수 컴포넌트 +├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf() +├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용 +└── config.ts # 기본 설정값 상수 +``` + +### ConfigPanel 규칙 (절대!) +- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용 +- 직접 JSX로 설정 UI 작성 금지 + +## 4. API Client 생성 패턴 +```typescript +// frontend/lib/api/yourModule.ts +import apiClient from "@/lib/api/client"; + +export async function getYourData(id: number) { + const response = await apiClient.get(`/api/your-endpoint/${id}`); + return response.data; +} +``` + +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! + +**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** +사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! + +## 금지 패턴 (절대 하지 말 것) +``` +frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! +frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! +``` + +## 올바른 패턴 +사용자 화면은 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로 렌더링 + +## 프론트엔드 에이전트가 할 수 있는 것 +- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) +- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) +- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능 + +## 프론트엔드 에이전트가 할 수 없는 것 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 + +# Your Domain +- frontend/components/ +- frontend/app/ +- frontend/lib/ +- frontend/hooks/ + +# Code Rules +1. TypeScript strict mode +2. React functional components with hooks +3. Prefer shadcn/ui components +4. Use cn() utility for conditional classes +5. Comments in Korean diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md new file mode 100644 index 00000000..05d3359e --- /dev/null +++ b/.cursor/agents/pipeline-ui.md @@ -0,0 +1,64 @@ +--- +name: pipeline-ui +description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수. +model: inherit +--- + +# Role +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. + +# Design Philosophy +- Apple-level polish with enterprise functionality +- Consistent spacing, typography, color usage +- Subtle animations and micro-interactions +- Dark mode compatible using CSS variables + +# CRITICAL STYLE RULES + +## 1. Color System (CSS Variables ONLY) +- bg-background / text-foreground (base) +- bg-primary / text-primary-foreground (actions) +- bg-muted / text-muted-foreground (secondary) +- bg-destructive / text-destructive-foreground (danger) +FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black + +## 2. Layout Rules +- No nested boxes (Card inside Card FORBIDDEN) +- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids +- Mobile-first responsive: flex-col md:flex-row + +## 3. Typography +- Page title: text-3xl font-bold +- Section: text-xl font-semibold +- Body: text-sm +- Helper: text-xs text-muted-foreground + +## 4. Components +- ALWAYS use shadcn/ui components +- Use cn() for conditional classes +- Use lucide-react for ALL icons + +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! + +사용자 업무 화면(포장관리, 금형관리, 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 에이전트가 할 수 없는 것: +- 사용자 메뉴 화면을 React 페이지로 직접 코딩 + +# Your Domain +- frontend/components/ (UI components) +- frontend/app/ (pages - 관리자 메뉴만) +- frontend/lib/registry/components/v2-*/ (V2 컴포넌트) + +# Output Rules +1. TypeScript strict mode +2. "use client" for client components +3. Comments in Korean +4. MINIMAL targeted changes when modifying existing files diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md new file mode 100644 index 00000000..a4f4186d --- /dev/null +++ b/.cursor/agents/pipeline-verifier.md @@ -0,0 +1,57 @@ +--- +name: pipeline-verifier +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. +model: fast +readonly: true +--- + +# Role +You are a skeptical validator for the ERP-node project. +Your job is to verify that work claimed as complete actually works. + +# Verification Checklist + +## 1. Multi-tenancy (최우선) +- [ ] 모든 SQL에 company_code 필터 존재 +- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) +- [ ] INSERT에 company_code 포함 +- [ ] JOIN에 company_code 매칭 조건 존재 +- [ ] company_code = "*" 최고관리자 예외 처리 + +## 2. Empty Shell Detection (빈 껍데기) +- [ ] API가 실제 DB 쿼리 실행 (mock 아님) +- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님) +- [ ] TODO/FIXME/placeholder 없음 +- [ ] 타입만 정의하고 구현 없는 함수 없음 + +## 3. Pattern Compliance (패턴 준수) +- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) +- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) +- [ ] Frontend: V2 컴포넌트 규격 준수 +- [ ] Backend: logger 사용 +- [ ] Backend: try/catch 에러 처리 + +## 4. Integration Check +- [ ] Route가 index.ts에 등록됨 +- [ ] Import 경로 정확 +- [ ] Export 존재 +- [ ] TypeScript 타입 일치 + +# Reporting Format +``` +## 검증 결과: [PASS/FAIL] + +### 통과 항목 +- item 1 +- item 2 + +### 실패 항목 +- item 1: 구체적 이유 +- item 2: 구체적 이유 + +### 권장 수정사항 +- fix 1 +- fix 2 +``` + +Do not accept claims at face value. Check the actual code. diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..d568a8fe --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,8 @@ +{ + "setup-worktree-unix": [ + "cd backend-node && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit", + "cd frontend && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit", + "cp $ROOT_WORKTREE_PATH/backend-node/.env backend-node/.env 2>/dev/null || true", + "cp $ROOT_WORKTREE_PATH/frontend/.env.local frontend/.env.local 2>/dev/null || true" + ] +} 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/approval-company7-report.txt b/approval-company7-report.txt new file mode 100644 index 00000000..57760435 --- /dev/null +++ b/approval-company7-report.txt @@ -0,0 +1,33 @@ + +=== Step 1: 로그인 (topseal_admin) === + 현재 URL: http://localhost:9771/screens/138 + 스크린샷: 01-after-login.png + OK: 로그인 완료 + +=== Step 2: 발주관리 화면 이동 === + 스크린샷: 02-po-screen.png + OK: 발주관리 화면 로드 + +=== Step 3: 그리드 컬럼 및 데이터 확인 === + 컬럼 헤더 (전체): ["결재상태","발주번호","품목코드","품목명","규격","발주수량","출하수량","단위","구분","유형","재질","규격","품명"] + 첫 번째 컬럼: "결재상태" + 결재상태(한글) 표시됨 + 데이터 행 수: 11 + 데이터 있음 + 첫 번째 컬럼 값(샘플): ["","","","",""] + 발주번호 형식 데이터: ["PO-2026-0001","PO-2026-0001","PO-2026-0001","PO-2026-0045","PO-2026-0045"] + 스크린샷: 03-grid-detail.png + OK: 그리드 상세 스크린샷 저장 + +=== Step 4: 결재 요청 버튼 확인 === + OK: '결재 요청' 파란색 버튼 확인됨 + 스크린샷: 04-approval-button.png + +=== Step 5: 행 선택 후 결재 요청 === + OK: 행 선택 완료 + 스크린샷: 05-approval-modal.png + OK: 결재 모달 열림 + 스크린샷: 06-approver-search-results.png + 결재자 검색 결과: 8명 + 결재자 목록: ["상신결재","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김지수(area09)배달집행부 / 대리","김한길(qoznd123)배달집행부 / 과장","김하세(kaoe123)배달집행부 / 사원"] + 스크린샷: 07-final.png \ No newline at end of file diff --git a/approval-test-report.txt b/approval-test-report.txt new file mode 100644 index 00000000..4a2e6386 --- /dev/null +++ b/approval-test-report.txt @@ -0,0 +1,29 @@ + +=== Step 1: 로그인 === + 스크린샷: 01-login-page.png + 스크린샷: 02-after-login.png + OK: 로그인 완료, 대시보드 로드 + +=== Step 2: 구매관리 → 발주관리 메뉴 이동 === + INFO: 메뉴에서 발주관리 미발견, 직접 URL로 이동 + 메뉴 목록: ["관리자 메뉴로 전환","회사 선택","관리자해외영업부"] + 스크린샷: 04-po-screen-loaded.png + OK: /screen/COMPANY_7_064 직접 이동 완료 + +=== Step 3: 그리드 컬럼 확인 === + 스크린샷: 05-grid-columns.png + 컬럼 목록: ["approval_status","발주번호","품목코드","품목명","규격","발주수량","출하","단위","구분","유형","재질","규격","품명"] + FAIL: '결재상태' 컬럼 없음 + 결재상태 값: 데이터 없음 또는 해당 값 없음 + +=== Step 4: 행 선택 및 결재 요청 버튼 클릭 === + 스크린샷: 06-row-selected.png + OK: 첫 번째 행 선택 + 스크린샷: 07-approval-modal-opened.png + OK: 결재 모달 열림 + +=== Step 5: 결재자 검색 테스트 === + 스크린샷: 08-approver-search-results.png + 검색 결과 수: 12명 + 결재자 목록: ["상신결재","템플릿","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","김동열(drkim)-","김아름(qwe123)생산부 / 차장","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김욱동(dnrehd0171)-","김지수(area09)배달집행부 / 대리"] + 스크린샷: 09-final-state.png \ No newline at end of file diff --git a/backend-node/scripts/run-1050-migration.js b/backend-node/scripts/run-1050-migration.js new file mode 100644 index 00000000..aa1b3723 --- /dev/null +++ b/backend-node/scripts/run-1050-migration.js @@ -0,0 +1,35 @@ +/** + * system_notice 테이블 생성 마이그레이션 실행 + */ +const { Pool } = require('pg'); +const fs = require('fs'); +const path = require('path'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', + ssl: false, +}); + +async function run() { + const client = await pool.connect(); + try { + const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + await client.query(sql); + console.log('OK: system_notice 테이블 생성 완료'); + + // 검증 + const result = await client.query( + "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" + ); + console.log('컬럼:', result.rows.map(r => r.column_name).join(', ')); + } catch (e) { + console.error('ERROR:', e.message); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +run(); diff --git a/backend-node/scripts/run-notice-migration.js b/backend-node/scripts/run-notice-migration.js new file mode 100644 index 00000000..4b23153d --- /dev/null +++ b/backend-node/scripts/run-notice-migration.js @@ -0,0 +1,38 @@ +/** + * system_notice 마이그레이션 실행 스크립트 + * 사용법: node scripts/run-notice-migration.js + */ +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', + ssl: false, +}); + +async function run() { + const client = await pool.connect(); + try { + const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + + console.log('마이그레이션 실행 중...'); + await client.query(sql); + console.log('마이그레이션 완료'); + + // 컬럼 확인 + const check = await client.query( + "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" + ); + console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', ')); + } catch (e) { + console.error('오류:', e.message); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +run(); diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 908c7e1d..822d3740 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 57edad10..6f0997a9 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3563,29 +3563,36 @@ export async function getTableSchema( logger.info("테이블 스키마 조회", { tableName, companyCode }); - // information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기 + // information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기 + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 const schemaQuery = ` SELECT ic.column_name, ic.data_type, - ic.is_nullable, + ic.is_nullable AS db_is_nullable, ic.column_default, ic.character_maximum_length, ic.numeric_precision, ic.numeric_scale, - ttc.column_label, - ttc.display_order + 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 FROM information_schema.columns ic - LEFT JOIN table_type_columns ttc - ON ttc.table_name = ic.table_name - AND ttc.column_name = ic.column_name - AND ttc.company_code = '*' + LEFT JOIN table_type_columns ttc_common + ON ttc_common.table_name = ic.table_name + AND ttc_common.column_name = ic.column_name + AND ttc_common.company_code = '*' + LEFT JOIN table_type_columns ttc_company + ON ttc_company.table_name = ic.table_name + AND ttc_company.column_name = ic.column_name + AND ttc_company.company_code = $2 WHERE ic.table_schema = 'public' AND ic.table_name = $1 - ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position + ORDER BY COALESCE(ttc_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position `; - const columns = await query(schemaQuery, [tableName]); + const columns = await query(schemaQuery, [tableName, companyCode]); if (columns.length === 0) { res.status(404).json({ @@ -3595,17 +3602,28 @@ export async function getTableSchema( return; } - // 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함) - const columnList = columns.map((col: any) => ({ - name: col.column_name, - label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용 - type: col.data_type, - nullable: col.is_nullable === "YES", - default: col.column_default, - maxLength: col.character_maximum_length, - precision: col.numeric_precision, - scale: col.numeric_scale, - })); + // 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영) + const columnList = columns.map((col: any) => { + // DB level nullable + 회사별 table_type_columns 제약조건 통합 + // table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도) + const dbNullable = col.db_is_nullable === "YES"; + const ttcNotNull = col.ttc_is_nullable === "N"; + const effectiveNullable = ttcNotNull ? false : dbNullable; + + const ttcUnique = col.ttc_is_unique === "Y"; + + return { + name: col.column_name, + label: col.column_label || col.column_name, + type: col.data_type, + nullable: effectiveNullable, + unique: ttcUnique, + default: col.column_default, + maxLength: col.character_maximum_length, + precision: col.numeric_precision, + scale: col.numeric_scale, + }; + }); logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`); diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts index 84231245..eabe77ce 100644 --- a/backend-node/src/controllers/approvalController.ts +++ b/backend-node/src/controllers/approvalController.ts @@ -1,6 +1,16 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne, transaction } from "../database/db"; +import { PoolClient } from "pg"; +import { NodeFlowExecutionService } from "../services/nodeFlowExecutionService"; + +// 트랜잭션 내부에서 throw하고 외부에서 instanceof로 구분하기 위한 커스텀 에러 +class ValidationError extends Error { + constructor(public statusCode: number, message: string) { + super(message); + this.name = "ValidationError"; + } +} // ============================================================ // 결재 정의 (Approval Definitions) CRUD @@ -17,24 +27,34 @@ export class ApprovalDefinitionController { const { is_active, search } = req.query; - const conditions: string[] = ["company_code = $1"]; - const params: any[] = [companyCode]; - let idx = 2; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + // SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만 + if (companyCode === "*") { + // 전체 조회 (company_code 필터 없음) + } else { + conditions.push(`company_code = $${idx++}`); + params.push(companyCode); + } if (is_active) { - conditions.push(`is_active = $${idx}`); + conditions.push(`is_active = $${idx++}`); params.push(is_active); - idx++; } if (search) { + // ILIKE에서 같은 파라미터를 두 조건에서 참조 (파라미터는 1개만 push) conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`); params.push(`%${search}%`); idx++; } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const rows = await query( - `SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY definition_id ASC`, + `SELECT * FROM approval_definitions ${whereClause} ORDER BY company_code, definition_id ASC`, params ); @@ -58,9 +78,12 @@ export class ApprovalDefinitionController { } const { id } = req.params; + // SUPER_ADMIN은 company_code 필터 없이 조회 가능 const row = await queryOne( - "SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2", - [id, companyCode] + companyCode === "*" + ? "SELECT * FROM approval_definitions WHERE definition_id = $1" + : "SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2", + companyCode === "*" ? [id] : [id, companyCode] ); if (!row) { @@ -165,11 +188,15 @@ export class ApprovalDefinitionController { fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`); params.push(req.user?.userId || "system"); + + // WHERE 절 파라미터 인덱스를 미리 계산 (쿼리 문자열 내 idx++ 호출 순서 보장) + const idIdx = idx++; + const ccIdx = idx++; params.push(id, companyCode); const [row] = await query( `UPDATE approval_definitions SET ${fields.join(", ")} - WHERE definition_id = $${idx++} AND company_code = $${idx++} RETURNING *`, + WHERE definition_id = $${idIdx} AND company_code = $${ccIdx} RETURNING *`, params ); @@ -234,9 +261,15 @@ export class ApprovalTemplateController { const { definition_id, is_active } = req.query; - const conditions: string[] = ["t.company_code = $1"]; - const params: any[] = [companyCode]; - let idx = 2; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + // SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만 + if (companyCode !== "*") { + conditions.push(`t.company_code = $${idx++}`); + params.push(companyCode); + } if (definition_id) { conditions.push(`t.definition_id = $${idx++}`); @@ -247,12 +280,14 @@ export class ApprovalTemplateController { params.push(is_active); } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const rows = await query( `SELECT t.*, d.definition_name FROM approval_line_templates t LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code - WHERE ${conditions.join(" AND ")} - ORDER BY t.template_id ASC`, + ${whereClause} + ORDER BY t.company_code, t.template_id ASC`, params ); @@ -276,12 +311,18 @@ export class ApprovalTemplateController { } const { id } = req.params; + // SUPER_ADMIN은 company_code 필터 없이 조회 가능 const template = await queryOne( - `SELECT t.*, d.definition_name - FROM approval_line_templates t - LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code - WHERE t.template_id = $1 AND t.company_code = $2`, - [id, companyCode] + companyCode === "*" + ? `SELECT t.*, d.definition_name + FROM approval_line_templates t + LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code + WHERE t.template_id = $1` + : `SELECT t.*, d.definition_name + FROM approval_line_templates t + LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code + WHERE t.template_id = $1 AND t.company_code = $2`, + companyCode === "*" ? [id] : [id, companyCode] ); if (!template) { @@ -289,8 +330,10 @@ export class ApprovalTemplateController { } const steps = await query( - "SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC", - [id, companyCode] + companyCode === "*" + ? "SELECT * FROM approval_line_template_steps WHERE template_id = $1 ORDER BY step_order ASC" + : "SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC", + companyCode === "*" ? [id] : [id, companyCode] ); return res.json({ success: true, data: { ...template, steps } }); @@ -312,7 +355,7 @@ export class ApprovalTemplateController { return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); } - const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body; + const { template_name, description, definition_id, after_approval_flow_id, is_active = "Y", steps = [] } = req.body; if (!template_name) { return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." }); @@ -323,9 +366,9 @@ export class ApprovalTemplateController { let result: any; await transaction(async (client) => { const { rows } = await client.query( - `INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by) - VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`, - [template_name, description, definition_id, is_active, companyCode, userId] + `INSERT INTO approval_line_templates (template_name, description, definition_id, after_approval_flow_id, is_active, company_code, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING *`, + [template_name, description, definition_id, after_approval_flow_id || null, is_active, companyCode, userId] ); result = rows[0]; @@ -380,7 +423,7 @@ export class ApprovalTemplateController { return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." }); } - const { template_name, description, definition_id, is_active, steps } = req.body; + const { template_name, description, definition_id, after_approval_flow_id, is_active, steps } = req.body; const userId = req.user?.userId || "system"; let result: any; @@ -392,13 +435,19 @@ export class ApprovalTemplateController { if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); } if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); } if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); } + if (after_approval_flow_id !== undefined) { fields.push(`after_approval_flow_id = $${idx++}`); params.push(after_approval_flow_id); } if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); } fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`); - params.push(userId, id, companyCode); + params.push(userId); + + // WHERE 절 파라미터 인덱스를 미리 계산 + const tmplIdx = idx++; + const ccIdx = idx++; + params.push(id, companyCode); const { rows } = await client.query( `UPDATE approval_line_templates SET ${fields.join(", ")} - WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`, + WHERE template_id = $${tmplIdx} AND company_code = $${ccIdx} RETURNING *`, params ); result = rows[0]; @@ -467,6 +516,216 @@ export class ApprovalTemplateController { } } +// ============================================================ +// 다음 step 활성화 헬퍼 (혼합형 결재선 대응) +// notification step은 자동 통과 후 재귀적으로 다음 step 진행 +// ============================================================ + +// 결재 상태 변경 시 원본 테이블(target_table)의 approval_status를 동기화하는 범용 hook +async function syncApprovalStatusToTarget( + client: PoolClient, + requestId: number, + newStatus: string, + companyCode: string, +): Promise { + try { + const { rows: [req] } = await client.query( + `SELECT target_table, target_record_id FROM approval_requests WHERE request_id = $1 AND company_code = $2`, + [requestId, companyCode], + ); + if (!req?.target_table || !req?.target_record_id || req.target_record_id === "0") return; + + const { rows: cols } = await client.query( + `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'approval_status'`, + [req.target_table], + ); + if (cols.length === 0) return; + + const statusMap: Record = { + in_progress: "결재중", + approved: "결재완료", + rejected: "반려", + cancelled: "작성중", + draft: "작성중", + post_pending: "후결대기", + }; + + const businessStatus = statusMap[newStatus] || newStatus; + const safeTable = req.target_table.replace(/[^a-zA-Z0-9_]/g, ""); + + // super admin(company_code='*')은 다른 회사 레코드도 업데이트 가능 + if (companyCode === "*") { + await client.query( + `UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2`, + [businessStatus, req.target_record_id], + ); + } else { + await client.query( + `UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2 AND company_code = $3`, + [businessStatus, req.target_record_id, companyCode], + ); + } + + // 결재 완료(approved) 시 제어관리(노드 플로우) 자동 실행 + if (newStatus === "approved") { + await executeAfterApprovalFlow(client, requestId, companyCode, req); + } + } catch (err) { + console.error("[syncApprovalStatusToTarget] 원본 테이블 상태 동기화 실패:", err); + } +} + +// 결재 완료 후 제어관리(노드 플로우) 실행 hook +// 우선순위: 템플릿(template) > 정의(definition) > 요청(request) 직접 지정 +async function executeAfterApprovalFlow( + client: PoolClient, + requestId: number, + companyCode: string, + approvalReq: { target_table: string; target_record_id: string }, +): Promise { + try { + const { rows: [reqData] } = await client.query( + `SELECT r.after_approval_flow_id, r.definition_id, r.template_id, r.title, r.requester_id + FROM approval_requests r WHERE r.request_id = $1`, + [requestId], + ); + + let flowId: number | null = null; + + // 1순위: 템플릿에 연결된 제어관리 플로우 + if (reqData?.template_id) { + const { rows: [tmpl] } = await client.query( + `SELECT after_approval_flow_id FROM approval_line_templates WHERE template_id = $1`, + [reqData.template_id], + ); + flowId = tmpl?.after_approval_flow_id || null; + } + + // 2순위: 정의(definition)에 연결된 제어관리 플로우 (fallback) + if (!flowId && reqData?.definition_id) { + const { rows: [def] } = await client.query( + `SELECT after_approval_flow_id FROM approval_definitions WHERE definition_id = $1`, + [reqData.definition_id], + ); + flowId = def?.after_approval_flow_id || null; + } + + // 3순위: 요청 자체에 직접 지정된 플로우 + if (!flowId) { + flowId = reqData?.after_approval_flow_id || null; + } + + if (!flowId) return; + + // 3. 원본 레코드 데이터 조회 + const safeTable = approvalReq.target_table.replace(/[^a-zA-Z0-9_]/g, ""); + const { rows: [targetRecord] } = await client.query( + `SELECT * FROM "${safeTable}" WHERE id = $1`, + [approvalReq.target_record_id], + ); + + // 4. 노드 플로우 실행 + console.log(`[제어관리] 결재 완료 후 플로우 #${flowId} 실행 (request_id=${requestId})`); + const result = await NodeFlowExecutionService.executeFlow(flowId, { + formData: targetRecord || {}, + approvalInfo: { + requestId, + title: reqData.title, + requesterId: reqData.requester_id, + targetTable: approvalReq.target_table, + targetRecordId: approvalReq.target_record_id, + }, + companyCode, + selectedRows: targetRecord ? [targetRecord] : [], + }); + + console.log(`[제어관리] 플로우 #${flowId} 실행 결과: ${result.success ? "성공" : "실패"} (${result.executionTime}ms)`); + } catch (err) { + // 제어관리 실패는 결재 승인 자체에 영향 주지 않음 + console.error("[executeAfterApprovalFlow] 제어관리 실행 실패:", err); + } +} + +async function activateNextStep( + client: PoolClient, + requestId: number, + currentStep: number, + totalSteps: number, + companyCode: string, + userId: string, + comment: string | null, +): Promise { + const nextStep = currentStep + 1; + + if (nextStep > totalSteps) { + // 최종 승인 처리 + await client.query( + `UPDATE approval_requests + SET status = CASE WHEN approval_type = 'post' THEN 'approved' ELSE 'approved' END, + is_post_approved = CASE WHEN approval_type = 'post' THEN true ELSE is_post_approved END, + post_approved_at = CASE WHEN approval_type = 'post' THEN NOW() ELSE post_approved_at END, + final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() + WHERE request_id = $3 AND company_code = $4`, + [userId, comment, requestId, companyCode] + ); + await syncApprovalStatusToTarget(client, requestId, "approved", companyCode); + return; + } + + // 다음 step의 결재 라인 조회 (FOR UPDATE로 동시성 방어) + const { rows: nextLines } = await client.query( + `SELECT * FROM approval_lines + WHERE request_id = $1 AND step_order = $2 AND company_code = $3 + FOR UPDATE`, + [requestId, nextStep, companyCode] + ); + + if (nextLines.length === 0) { + // 다음 step이 비어있으면 최종 승인 처리 + await client.query( + `UPDATE approval_requests + SET status = 'approved', final_approver_id = $1, final_comment = $2, + completed_at = NOW(), updated_at = NOW() + WHERE request_id = $3 AND company_code = $4`, + [userId, comment, requestId, companyCode] + ); + await syncApprovalStatusToTarget(client, requestId, "approved", companyCode); + return; + } + + const nextStepType = nextLines[0].step_type || "approval"; + + if (nextStepType === "notification") { + // 통보 단계: 자동 approved 처리 후 다음 step으로 재귀 + for (const nl of nextLines) { + await client.query( + `UPDATE approval_lines SET status = 'approved', comment = '자동 통보 처리', processed_at = NOW() + WHERE line_id = $1 AND company_code = $2`, + [nl.line_id, companyCode] + ); + } + await client.query( + `UPDATE approval_requests SET current_step = $1, updated_at = NOW() + WHERE request_id = $2 AND company_code = $3`, + [nextStep, requestId, companyCode] + ); + // 재귀: 통보 다음 step 활성화 + await activateNextStep(client, requestId, nextStep, totalSteps, companyCode, userId, comment); + } else { + // approval 또는 consensus: pending으로 전환 + await client.query( + `UPDATE approval_lines SET status = 'pending' + WHERE request_id = $1 AND step_order = $2 AND company_code = $3`, + [requestId, nextStep, companyCode] + ); + await client.query( + `UPDATE approval_requests SET current_step = $1, updated_at = NOW() + WHERE request_id = $2 AND company_code = $3`, + [nextStep, requestId, companyCode] + ); + } +} + // ============================================================ // 결재 요청 (Approval Requests) CRUD // ============================================================ @@ -483,9 +742,15 @@ export class ApprovalRequestController { const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query; - const conditions: string[] = ["r.company_code = $1"]; - const params: any[] = [companyCode]; - let idx = 2; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + // SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만 + if (companyCode !== "*") { + conditions.push(`r.company_code = $${idx++}`); + params.push(companyCode); + } if (status) { conditions.push(`r.status = $${idx++}`); @@ -513,26 +778,31 @@ export class ApprovalRequestController { } const offset = (parseInt(page as string) - 1) * parseInt(limit as string); + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // countParams는 WHERE 조건 파라미터만 포함 (LIMIT/OFFSET 제외) + // my_approvals 파라미터도 포함된 후 복사해야 함 + const countParams = [...params]; + const [countRow] = await query( + `SELECT COUNT(*) as total FROM approval_requests r ${whereClause}`, + countParams + ); + + // LIMIT/OFFSET 파라미터 인덱스를 미리 계산 (countParams 복사 후에 idx 증가) + const limitIdx = idx++; + const offsetIdx = idx++; params.push(parseInt(limit as string), offset); const rows = await query( `SELECT r.*, d.definition_name FROM approval_requests r LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code - WHERE ${conditions.join(" AND ")} + ${whereClause} ORDER BY r.created_at DESC - LIMIT $${idx++} OFFSET $${idx++}`, + LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params ); - // 전체 건수 조회 - const countParams = params.slice(0, params.length - 2); - const [countRow] = await query( - `SELECT COUNT(*) as total FROM approval_requests r - WHERE ${conditions.join(" AND ")}`, - countParams - ); - return res.json({ success: true, data: rows, @@ -559,12 +829,18 @@ export class ApprovalRequestController { } const { id } = req.params; + // SUPER_ADMIN은 company_code 필터 없이 모든 요청 조회 가능 const request = await queryOne( - `SELECT r.*, d.definition_name - FROM approval_requests r - LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code - WHERE r.request_id = $1 AND r.company_code = $2`, - [id, companyCode] + companyCode === "*" + ? `SELECT r.*, d.definition_name + FROM approval_requests r + LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code + WHERE r.request_id = $1` + : `SELECT r.*, d.definition_name + FROM approval_requests r + LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code + WHERE r.request_id = $1 AND r.company_code = $2`, + companyCode === "*" ? [id] : [id, companyCode] ); if (!request) { @@ -572,8 +848,10 @@ export class ApprovalRequestController { } const lines = await query( - "SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC", - [id, companyCode] + companyCode === "*" + ? "SELECT * FROM approval_lines WHERE request_id = $1 ORDER BY step_order ASC" + : "SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC", + companyCode === "*" ? [id] : [id, companyCode] ); return res.json({ success: true, data: { ...request, lines } }); @@ -587,7 +865,7 @@ export class ApprovalRequestController { } } - // 결재 요청 생성 (결재 라인 자동 생성) + // 결재 요청 생성 (혼합형 결재선 지원 - self/escalation/consensus/post) static async createRequest(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; @@ -596,85 +874,238 @@ export class ApprovalRequestController { } const { - title, description, definition_id, target_table, target_record_id, + title, description, definition_id, template_id, target_table, target_record_id, target_record_data, screen_id, button_component_id, - approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }] - approval_mode, // "sequential" | "parallel" + approvers, + approval_mode, + approval_type = "escalation", } = req.body; if (!title || !target_table) { return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." }); } - if (!Array.isArray(approvers) || approvers.length === 0) { - return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." }); - } + // target_record_id는 NOT NULL 컬럼이므로 빈 값은 기본값으로 대체 + const safeTargetRecordId = target_record_id || "0"; const userId = req.user?.userId || "system"; const userName = req.user?.userName || ""; const deptName = req.user?.deptName || ""; - const isParallel = approval_mode === "parallel"; - const totalSteps = approvers.length; - - // approval_mode를 target_record_data에 병합 저장 + // approval_mode를 target_record_data에 병합 저장 (하위호환) const mergedRecordData = { ...(target_record_data || {}), approval_mode: approval_mode || "sequential", }; + // ========== 자기결재(전결) ========== + if (approval_type === "self") { + // definition_id가 있으면 allow_self_approval 체크 + if (definition_id) { + const def = await queryOne( + "SELECT allow_self_approval FROM approval_definitions WHERE definition_id = $1 AND company_code = $2", + [definition_id, companyCode] + ); + if (def && !def.allow_self_approval) { + return res.status(400).json({ success: false, message: "해당 결재 유형은 자기결재(전결)를 허용하지 않습니다." }); + } + } + + let result: any; + await transaction(async (client) => { + const { rows: reqRows } = await client.query( + `INSERT INTO approval_requests ( + title, description, definition_id, template_id, target_table, target_record_id, + target_record_data, status, current_step, total_steps, approval_type, + requester_id, requester_name, requester_dept, + screen_id, button_component_id, company_code, + final_approver_id, completed_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'approved', 1, 1, 'self', + $8, $9, $10, $11, $12, $13, $8, NOW()) + RETURNING *`, + [ + title, description, definition_id, template_id || null, target_table, safeTargetRecordId, + JSON.stringify(mergedRecordData), + userId, userName, deptName, + screen_id, button_component_id, companyCode, + ] + ); + result = reqRows[0]; + + // 본인을 결재자로 INSERT (이미 approved) + await client.query( + `INSERT INTO approval_lines ( + request_id, step_order, approver_id, approver_name, approver_position, + approver_dept, approver_label, status, step_type, processed_at, company_code + ) VALUES ($1, 1, $2, $3, $4, $5, '자기결재', 'approved', 'approval', NOW(), $6)`, + [result.request_id, userId, userName, req.user?.positionName || null, deptName, companyCode] + ); + + await syncApprovalStatusToTarget(client, result.request_id, "approved", companyCode); + }); + + return res.status(201).json({ success: true, data: result, message: "자기결재(전결) 처리되었습니다." }); + } + + // ========== 그 외 유형: approvers 필수 검증 ========== + if (!Array.isArray(approvers) || approvers.length === 0) { + return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." }); + } + + // 각 approver에 step_type/step_order 할당 (혼합형 지원) + const hasExplicitStepType = approvers.some((a: any) => a.step_type); + + interface NormalizedApprover { + approver_id: string; + approver_name: string | null; + approver_position: string | null; + approver_dept: string | null; + approver_label: string | null; + step_order: number; + step_type: string; + } + + let normalizedApprovers: NormalizedApprover[]; + + if (approval_type === "consensus" && !hasExplicitStepType) { + // 단순 합의결재: 전원 step_order=1, step_type='consensus' + normalizedApprovers = approvers.map((a: any) => ({ + approver_id: a.approver_id, + approver_name: a.approver_name || null, + approver_position: a.approver_position || null, + approver_dept: a.approver_dept || null, + approver_label: a.approver_label || "합의 결재", + step_order: 1, + step_type: "consensus", + })); + } else if (hasExplicitStepType) { + // 혼합형: 각 approver에 명시된 step_type/step_order 사용 + normalizedApprovers = approvers.map((a: any, i: number) => ({ + approver_id: a.approver_id, + approver_name: a.approver_name || null, + approver_position: a.approver_position || null, + approver_dept: a.approver_dept || null, + approver_label: a.approver_label || null, + step_order: a.step_order ?? (i + 1), + step_type: a.step_type || "approval", + })); + } else { + // escalation / post: 기본 sequential + normalizedApprovers = approvers.map((a: any, i: number) => ({ + approver_id: a.approver_id, + approver_name: a.approver_name || null, + approver_position: a.approver_position || null, + approver_dept: a.approver_dept || null, + approver_label: a.approver_label || `${i + 1}차 결재`, + step_order: a.step_order ?? (i + 1), + step_type: "approval", + })); + } + + // escalation 타입에서 같은 step_order에 2명 이상이면서 step_type이 approval인 경우 에러 + const stepOrderGroups = new Map(); + for (const a of normalizedApprovers) { + const group = stepOrderGroups.get(a.step_order) || []; + group.push(a); + stepOrderGroups.set(a.step_order, group); + } + for (const [stepOrder, group] of stepOrderGroups) { + if (group.length > 1) { + const allApproval = group.every(g => g.step_type === "approval"); + if (allApproval) { + return res.status(400).json({ + success: false, + message: `step_order ${stepOrder}에 approval 타입 결재자가 2명 이상입니다. consensus로 지정해주세요.`, + }); + } + } + } + + // total_steps = 고유한 step_order의 최대값 + const uniqueStepOrders = [...new Set(normalizedApprovers.map(a => a.step_order))].sort((a, b) => a - b); + const totalSteps = Math.max(...uniqueStepOrders); + + // 저장할 approval_type 결정 (혼합형은 escalation으로 저장) + const storedApprovalType = hasExplicitStepType ? "escalation" : approval_type; + const initialStatus = approval_type === "post" ? "post_pending" : "requested"; + let result: any; await transaction(async (client) => { - // 결재 요청 생성 const { rows: reqRows } = await client.query( `INSERT INTO approval_requests ( - title, description, definition_id, target_table, target_record_id, - target_record_data, status, current_step, total_steps, + title, description, definition_id, template_id, target_table, target_record_id, + target_record_data, status, current_step, total_steps, approval_type, requester_id, requester_name, requester_dept, screen_id, button_component_id, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING *`, [ - title, description, definition_id, target_table, target_record_id || null, - JSON.stringify(mergedRecordData), - totalSteps, + title, description, definition_id, template_id || null, target_table, safeTargetRecordId, + JSON.stringify(mergedRecordData), initialStatus, totalSteps, storedApprovalType, userId, userName, deptName, screen_id, button_component_id, companyCode, ] ); result = reqRows[0]; - // 결재 라인 생성 - // 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending - for (let i = 0; i < approvers.length; i++) { - const approver = approvers[i]; - const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting"); + const firstStep = uniqueStepOrders[0]; + + for (const approver of normalizedApprovers) { + // 첫 번째 step의 결재자만 pending, 나머지는 waiting + let lineStatus: string; + if (approver.step_order === firstStep) { + lineStatus = "pending"; + } else { + lineStatus = "waiting"; + } await client.query( `INSERT INTO approval_lines ( request_id, step_order, approver_id, approver_name, approver_position, - approver_dept, approver_label, status, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + approver_dept, approver_label, status, step_type, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ result.request_id, - i + 1, + approver.step_order, approver.approver_id, - approver.approver_name || null, - approver.approver_position || null, - approver.approver_dept || null, - approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`), + approver.approver_name, + approver.approver_position, + approver.approver_dept, + approver.approver_label, lineStatus, + approver.step_type, companyCode, ] ); } - // 상태를 in_progress로 업데이트 - await client.query( - "UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1", - [result.request_id] - ); - result.status = "in_progress"; + // 첫 번째 step이 notification이면 자동 통과 처리 + const firstStepLines = normalizedApprovers.filter(a => a.step_order === firstStep); + const firstStepType = firstStepLines[0]?.step_type; + + if (firstStepType === "notification") { + // notification은 자동 처리 → activateNextStep으로 재귀 + for (const nl of firstStepLines) { + await client.query( + `UPDATE approval_lines SET status = 'approved', comment = '자동 통보 처리', processed_at = NOW() + WHERE request_id = $1 AND step_order = $2 AND approver_id = $3 AND company_code = $4`, + [result.request_id, nl.step_order, nl.approver_id, companyCode] + ); + } + await activateNextStep(client, result.request_id, firstStep, totalSteps, companyCode, userId, null); + } + + // status를 in_progress로 업데이트 (post_pending 제외) + if (approval_type !== "post") { + await client.query( + `UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1 AND company_code = $2`, + [result.request_id, companyCode] + ); + result.status = "in_progress"; + await syncApprovalStatusToTarget(client, result.request_id, "in_progress", companyCode); + } else { + await syncApprovalStatusToTarget(client, result.request_id, "post_pending", companyCode); + } }); return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." }); @@ -711,14 +1142,17 @@ export class ApprovalRequestController { return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." }); } - if (!["requested", "in_progress"].includes(request.status)) { + if (!["requested", "in_progress", "post_pending"].includes(request.status)) { return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." }); } - await query( - "UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2", - [id, companyCode] - ); + await transaction(async (client) => { + await client.query( + "UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2", + [id, companyCode] + ); + await syncApprovalStatusToTarget(client, Number(id), "cancelled", companyCode); + }); return res.json({ success: true, message: "결재 요청이 회수되었습니다." }); } catch (error) { @@ -730,6 +1164,68 @@ export class ApprovalRequestController { }); } } + + // 후결 처리 엔드포인트 + static async postApprove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { id } = req.params; + const { comment } = req.body; + + const request = await queryOne( + "SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2", + [id, companyCode] + ); + + if (!request) { + return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." }); + } + + if (request.approval_type !== "post") { + return res.status(400).json({ success: false, message: "후결 유형의 결재 요청만 후결 처리할 수 있습니다." }); + } + + if (request.is_post_approved) { + return res.status(400).json({ success: false, message: "이미 후결 처리된 요청입니다." }); + } + + // 결재선 전원 approved 확인 + const [pendingCount] = await query( + `SELECT COUNT(*) as cnt FROM approval_lines + WHERE request_id = $1 AND status NOT IN ('approved', 'skipped') AND company_code = $2`, + [id, companyCode] + ); + + if (parseInt(pendingCount?.cnt || "0") > 0) { + return res.status(400).json({ success: false, message: "모든 결재자의 승인이 완료되지 않았습니다." }); + } + + await transaction(async (client) => { + await client.query( + `UPDATE approval_requests + SET status = 'approved', is_post_approved = true, post_approved_at = NOW(), + final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, id, companyCode] + ); + await syncApprovalStatusToTarget(client, Number(id), "approved", companyCode); + }); + + return res.json({ success: true, message: "후결 처리가 완료되었습니다." }); + } catch (error) { + console.error("후결 처리 오류:", error); + return res.status(500).json({ + success: false, + message: "후결 처리 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } } // ============================================================ @@ -737,7 +1233,7 @@ export class ApprovalRequestController { // ============================================================ export class ApprovalLineController { - // 결재 처리 (승인/반려) + // 결재 처리 (승인/반려) - FOR UPDATE 동시성 방어 + 대결 + step_type 분기 static async processApproval(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; @@ -747,111 +1243,151 @@ export class ApprovalLineController { } const { lineId } = req.params; - const { action, comment } = req.body; // action: 'approved' | 'rejected' + const { action, comment, proxy_reason } = req.body; if (!["approved", "rejected"].includes(action)) { return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." }); } - const line = await queryOne( - "SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2", - [lineId, companyCode] - ); - - if (!line) { - return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." }); - } - - if (line.approver_id !== userId) { - return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." }); - } - - if (line.status !== "pending") { - return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." }); - } - await transaction(async (client) => { - // 현재 라인 처리 + // FOR UPDATE로 결재 라인 잠금 + // super admin(*)은 모든 회사의 라인을 처리할 수 있음 + const lineQuery = companyCode === "*" + ? `SELECT * FROM approval_lines WHERE line_id = $1 FOR UPDATE` + : `SELECT * FROM approval_lines WHERE line_id = $1 AND company_code IN ($2, '*') FOR UPDATE`; + const lineParams = companyCode === "*" ? [lineId] : [lineId, companyCode]; + const { rows: [line] } = await client.query(lineQuery, lineParams); + + if (!line) { + throw new ValidationError(404, "결재 라인을 찾을 수 없습니다."); + } + + if (line.status !== "pending") { + throw new ValidationError(400, "대기 중인 결재만 처리할 수 있습니다."); + } + + // 대결(proxy) 인증 로직 + let proxyFor: string | null = null; + let proxyReasonVal: string | null = null; + + if (line.approver_id !== userId) { + // super admin(company_code='*')은 모든 결재를 대리 처리 가능 + if (companyCode === "*") { + proxyFor = line.approver_id; + proxyReasonVal = proxy_reason || "최고관리자 대리 처리"; + } else { + const { rows: proxyRows } = await client.query( + `SELECT * FROM approval_proxy_settings + WHERE original_user_id = $1 AND proxy_user_id = $2 + AND is_active = 'Y' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE + AND company_code = $3`, + [line.approver_id, userId, companyCode] + ); + if (proxyRows.length === 0) { + throw new ValidationError(403, "본인이 결재자로 지정된 건만 처리할 수 있습니다."); + } + proxyFor = line.approver_id; + proxyReasonVal = proxy_reason || proxyRows[0].reason || "대결 처리"; + } + } + + // 현재 라인 처리 (proxy_for, proxy_reason 포함) - 라인의 company_code 기준 await client.query( - `UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW() - WHERE line_id = $3`, - [action, comment || null, lineId] + `UPDATE approval_lines + SET status = $1, comment = $2, processed_at = NOW(), + proxy_for = $3, proxy_reason = $4 + WHERE line_id = $5 AND company_code = $6`, + [action, comment || null, proxyFor, proxyReasonVal, lineId, line.company_code] ); - const { rows: reqRows } = await client.query( - "SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2", - [line.request_id, companyCode] + // 결재 요청 조회 (FOR UPDATE) - 라인의 company_code 기준 + const { rows: [request] } = await client.query( + `SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2 FOR UPDATE`, + [line.request_id, line.company_code] ); - const request = reqRows[0]; if (!request) return; + const lineCC = line.company_code; + if (action === "rejected") { // 반려: 전체 요청 반려 처리 await client.query( `UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() - WHERE request_id = $3`, - [userId, comment || null, line.request_id] + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, line.request_id, lineCC] ); // 남은 pending/waiting 라인도 skipped 처리 await client.query( `UPDATE approval_lines SET status = 'skipped' - WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`, - [line.request_id, lineId] + WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2 AND company_code = $3`, + [line.request_id, lineId, lineCC] ); + await syncApprovalStatusToTarget(client, line.request_id, "rejected", lineCC); } else { - // 승인: 동시결재 vs 다단결재 분기 - const recordData = request.target_record_data; - const isParallelMode = recordData?.approval_mode === "parallel"; + // 승인 처리: step_type 기반 분기 + const currentStepType = line.step_type || "approval"; - if (isParallelMode) { - // 동시결재: 남은 pending 라인이 있는지 확인 + // 기존 isParallelMode 하위호환 (step_type이 없는 기존 데이터) + const recordData = request.target_record_data; + const isLegacyParallel = recordData?.approval_mode === "parallel" && !line.step_type; + + if (isLegacyParallel) { + // 레거시 동시결재 (하위호환) const { rows: remainingLines } = await client.query( `SELECT COUNT(*) as cnt FROM approval_lines WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`, - [line.request_id, lineId, companyCode] + [line.request_id, lineId, lineCC] ); const remaining = parseInt(remainingLines[0]?.cnt || "0"); if (remaining === 0) { - // 모든 동시 결재자 승인 완료 → 최종 승인 await client.query( `UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() - WHERE request_id = $3`, - [userId, comment || null, line.request_id] + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, line.request_id, lineCC] ); + await syncApprovalStatusToTarget(client, line.request_id, "approved", lineCC); } - // 아직 남은 결재자 있으면 대기 (상태 변경 없음) - } else { - // 다단결재: 다음 단계 활성화 또는 최종 완료 - const nextStep = line.step_order + 1; + } else if (currentStepType === "consensus") { + // 합의결재: 같은 step의 모든 결재자 승인 확인 + const { rows: remaining } = await client.query( + `SELECT COUNT(*) as cnt FROM approval_lines + WHERE request_id = $1 AND step_order = $2 + AND status NOT IN ('approved', 'skipped') + AND line_id != $3 AND company_code = $4`, + [line.request_id, line.step_order, lineId, lineCC] + ); - if (nextStep <= request.total_steps) { - await client.query( - `UPDATE approval_lines SET status = 'pending' - WHERE request_id = $1 AND step_order = $2 AND company_code = $3`, - [line.request_id, nextStep, companyCode] - ); - await client.query( - `UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`, - [nextStep, line.request_id] - ); - } else { - await client.query( - `UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2, - completed_at = NOW(), updated_at = NOW() - WHERE request_id = $3`, - [userId, comment || null, line.request_id] + if (parseInt(remaining[0].cnt) === 0) { + // 합의 완료 → 다음 step 활성화 + await activateNextStep( + client, line.request_id, line.step_order, request.total_steps, + lineCC, userId, comment || null, ); } + } else { + // approval (기존 sequential 로직): 다음 step 활성화 + await activateNextStep( + client, line.request_id, line.step_order, request.total_steps, + lineCC, userId, comment || null, + ); } } }); return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." }); } catch (error) { + // ValidationError는 트랜잭션이 rollback된 후 적절한 HTTP 상태코드로 응답 + if (error instanceof Error && error.name === "ValidationError") { + const validationErr = error as any; + return res.status(validationErr.statusCode).json({ + success: false, + message: validationErr.message, + }); + } console.error("결재 처리 오류:", error); return res.status(500).json({ success: false, @@ -874,7 +1410,7 @@ export class ApprovalLineController { `SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at FROM approval_lines l JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code - WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2 + WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code IN ($2, '*') ORDER BY r.created_at ASC`, [userId, companyCode] ); @@ -890,3 +1426,4 @@ export class ApprovalLineController { } } } + diff --git a/backend-node/src/controllers/approvalProxyController.ts b/backend-node/src/controllers/approvalProxyController.ts new file mode 100644 index 00000000..5788c7bf --- /dev/null +++ b/backend-node/src/controllers/approvalProxyController.ts @@ -0,0 +1,212 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query, queryOne } from "../database/db"; + +// ============================================================ +// 대결 위임 설정 (Approval Proxy Settings) CRUD +// ============================================================ + +export class ApprovalProxyController { + // 대결 위임 목록 조회 (user_info JOIN으로 이름/부서 포함) + static async getProxySettings(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const rows = await query( + `SELECT ps.*, + u1.user_name AS original_user_name, u1.dept_name AS original_dept_name, + u2.user_name AS proxy_user_name, u2.dept_name AS proxy_dept_name + FROM approval_proxy_settings ps + LEFT JOIN user_info u1 ON ps.original_user_id = u1.user_id AND ps.company_code = u1.company_code + LEFT JOIN user_info u2 ON ps.proxy_user_id = u2.user_id AND ps.company_code = u2.company_code + WHERE ps.company_code = $1 + ORDER BY ps.created_at DESC`, + [companyCode] + ); + + return res.json({ success: true, data: rows }); + } catch (error) { + console.error("대결 위임 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "대결 위임 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 대결 위임 생성 (기간 중복 체크 포함) + static async createProxySetting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active = "Y" } = req.body; + + if (!original_user_id || !proxy_user_id) { + return res.status(400).json({ success: false, message: "위임자와 대결자는 필수입니다." }); + } + if (!start_date || !end_date) { + return res.status(400).json({ success: false, message: "시작일과 종료일은 필수입니다." }); + } + if (original_user_id === proxy_user_id) { + return res.status(400).json({ success: false, message: "위임자와 대결자가 동일할 수 없습니다." }); + } + + // 같은 기간 중복 체크 (daterange 오버랩) + const overlap = await queryOne( + `SELECT COUNT(*) AS cnt FROM approval_proxy_settings + WHERE original_user_id = $1 AND is_active = 'Y' + AND daterange(start_date, end_date, '[]') && daterange($2::date, $3::date, '[]') + AND company_code = $4`, + [original_user_id, start_date, end_date, companyCode] + ); + + if (overlap && parseInt(overlap.cnt) > 0) { + return res.status(400).json({ success: false, message: "해당 기간에 이미 대결 설정이 존재합니다." }); + } + + const [row] = await query( + `INSERT INTO approval_proxy_settings + (original_user_id, proxy_user_id, start_date, end_date, reason, is_active, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [original_user_id, proxy_user_id, start_date, end_date, reason || null, is_active, companyCode] + ); + + return res.status(201).json({ success: true, data: row, message: "대결 위임이 생성되었습니다." }); + } catch (error) { + console.error("대결 위임 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "대결 위임 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 대결 위임 수정 + static async updateProxySetting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { id } = req.params; + const existing = await queryOne( + "SELECT id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2", + [id, companyCode] + ); + + if (!existing) { + return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); + } + + const { proxy_user_id, start_date, end_date, reason, is_active } = req.body; + + const fields: string[] = []; + const params: any[] = []; + let idx = 1; + + if (proxy_user_id !== undefined) { fields.push(`proxy_user_id = $${idx++}`); params.push(proxy_user_id); } + if (start_date !== undefined) { fields.push(`start_date = $${idx++}`); params.push(start_date); } + if (end_date !== undefined) { fields.push(`end_date = $${idx++}`); params.push(end_date); } + if (reason !== undefined) { fields.push(`reason = $${idx++}`); params.push(reason); } + if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); } + + if (fields.length === 0) { + return res.status(400).json({ success: false, message: "수정할 필드가 없습니다." }); + } + + fields.push(`updated_at = NOW()`); + params.push(id, companyCode); + + const [row] = await query( + `UPDATE approval_proxy_settings SET ${fields.join(", ")} + WHERE id = $${idx++} AND company_code = $${idx++} + RETURNING *`, + params + ); + + return res.json({ success: true, data: row, message: "대결 위임이 수정되었습니다." }); + } catch (error) { + console.error("대결 위임 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "대결 위임 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 대결 위임 삭제 + static async deleteProxySetting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { id } = req.params; + + const result = await query( + "DELETE FROM approval_proxy_settings WHERE id = $1 AND company_code = $2 RETURNING id", + [id, companyCode] + ); + + if (result.length === 0) { + return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); + } + + return res.json({ success: true, message: "대결 위임이 삭제되었습니다." }); + } catch (error) { + console.error("대결 위임 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "대결 위임 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 특정 사용자의 현재 활성 대결자 조회 + static async checkActiveProxy(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { userId } = req.params; + + if (!userId) { + return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." }); + } + + const rows = await query( + `SELECT ps.*, u.user_name AS proxy_user_name + FROM approval_proxy_settings ps + LEFT JOIN user_info u ON ps.proxy_user_id = u.user_id AND ps.company_code = u.company_code + WHERE ps.original_user_id = $1 AND ps.is_active = 'Y' + AND ps.start_date <= CURRENT_DATE AND ps.end_date >= CURRENT_DATE + AND ps.company_code = $2`, + [userId, companyCode] + ); + + return res.json({ success: true, data: rows }); + } catch (error) { + console.error("활성 대결자 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "활성 대결자 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +} 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/dataflowExecutionController.ts b/backend-node/src/controllers/dataflowExecutionController.ts index 338fa628..71eb2211 100644 --- a/backend-node/src/controllers/dataflowExecutionController.ts +++ b/backend-node/src/controllers/dataflowExecutionController.ts @@ -8,6 +8,7 @@ import { Request, Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { query } from "../database/db"; import logger from "../utils/logger"; +import { TableManagementService } from "../services/tableManagementService"; /** * 데이터 액션 실행 @@ -81,6 +82,19 @@ async function executeMainDatabaseAction( company_code: companyCode, }; + // UNIQUE 제약조건 검증 (INSERT/UPDATE/UPSERT 전) + if (["insert", "update", "upsert"].includes(actionType.toLowerCase())) { + const tms = new TableManagementService(); + const uniqueViolations = await tms.validateUniqueConstraints( + tableName, + dataWithCompany, + companyCode + ); + if (uniqueViolations.length > 0) { + throw new Error(`중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`); + } + } + switch (actionType.toLowerCase()) { case "insert": return await executeInsert(tableName, dataWithCompany); diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 48b55d18..31c11638 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -1,7 +1,9 @@ import { Response } from "express"; import { dynamicFormService } from "../services/dynamicFormService"; import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService"; +import { TableManagementService } from "../services/tableManagementService"; import { AuthenticatedRequest } from "../types/auth"; +import { formatPgError } from "../utils/pgErrorUtil"; // 폼 데이터 저장 (기존 버전 - 레거시 지원) export const saveFormData = async ( @@ -47,6 +49,21 @@ export const saveFormData = async ( formDataWithMeta.company_code = companyCode; } + // UNIQUE 제약조건 검증 (INSERT 전) + const tms = new TableManagementService(); + const uniqueViolations = await tms.validateUniqueConstraints( + tableName, + formDataWithMeta, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + // 클라이언트 IP 주소 추출 const ipAddress = req.ip || @@ -68,9 +85,12 @@ export const saveFormData = async ( }); } catch (error: any) { console.error("❌ 폼 데이터 저장 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "데이터 저장에 실패했습니다.", + message: friendlyMsg, }); } }; @@ -108,6 +128,21 @@ export const saveFormDataEnhanced = async ( formDataWithMeta.company_code = companyCode; } + // UNIQUE 제약조건 검증 (INSERT 전) + const tmsEnhanced = new TableManagementService(); + const uniqueViolations = await tmsEnhanced.validateUniqueConstraints( + tableName, + formDataWithMeta, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + // 개선된 서비스 사용 const result = await enhancedDynamicFormService.saveFormData( screenId, @@ -118,9 +153,12 @@ export const saveFormDataEnhanced = async ( res.json(result); } catch (error: any) { console.error("❌ 개선된 폼 데이터 저장 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "데이터 저장에 실패했습니다.", + message: friendlyMsg, }); } }; @@ -146,12 +184,28 @@ export const updateFormData = async ( const formDataWithMeta = { ...data, updated_by: userId, - writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 + writer: data.writer || userId, updated_at: new Date(), }; + // UNIQUE 제약조건 검증 (UPDATE 시 자기 자신 제외) + const tmsUpdate = new TableManagementService(); + const uniqueViolations = await tmsUpdate.validateUniqueConstraints( + tableName, + formDataWithMeta, + companyCode || "*", + id + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + const result = await dynamicFormService.updateFormData( - id, // parseInt 제거 - 문자열 ID 지원 + id, tableName, formDataWithMeta ); @@ -163,9 +217,12 @@ export const updateFormData = async ( }); } catch (error: any) { console.error("❌ 폼 데이터 업데이트 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "데이터 업데이트에 실패했습니다.", + message: friendlyMsg, }); } }; @@ -199,11 +256,27 @@ export const updateFormDataPartial = async ( const newDataWithMeta = { ...newData, updated_by: userId, - writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정 + writer: newData.writer || userId, }; + // UNIQUE 제약조건 검증 (부분 UPDATE 시 자기 자신 제외) + const tmsPartial = new TableManagementService(); + const uniqueViolations = await tmsPartial.validateUniqueConstraints( + tableName, + newDataWithMeta, + companyCode || "*", + id + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + const result = await dynamicFormService.updateFormDataPartial( - id, // 🔧 parseInt 제거 - UUID 문자열도 지원 + id, tableName, originalData, newDataWithMeta @@ -216,9 +289,12 @@ export const updateFormDataPartial = async ( }); } catch (error: any) { console.error("❌ 부분 업데이트 실패:", error); - res.status(500).json({ + const { companyCode } = req.user as any; + const friendlyMsg = await formatPgError(error, companyCode); + const statusCode = error.code?.startsWith("23") ? 400 : 500; + res.status(statusCode).json({ success: false, - message: error.message || "부분 업데이트에 실패했습니다.", + message: friendlyMsg, }); } }; diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 62fc8bbe..fa70de66 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -181,16 +181,87 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re ? `WHERE ${whereConditions.join(" AND ")}` : ""; - // DISTINCT 쿼리 실행 - const query = ` + // 1단계: DISTINCT 값 조회 + const distinctQuery = ` SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label FROM "${tableName}" ${whereClause} ORDER BY "${effectiveLabelColumn}" ASC LIMIT 500 `; + const result = await pool.query(distinctQuery, params); - const result = await pool.query(query, params); + // 2단계: 카테고리/코드 라벨 변환 (값이 있을 때만) + if (result.rows.length > 0) { + const rawValues = result.rows.map((r: any) => r.value); + const labelMap: Record = {}; + + // category_values에서 라벨 조회 + try { + const cvCompanyCondition = companyCode !== "*" + ? `AND (company_code = $4 OR company_code = '*')` + : ""; + const cvParams = companyCode !== "*" + ? [tableName, columnName, rawValues, companyCode] + : [tableName, columnName, rawValues]; + + const cvResult = await pool.query( + `SELECT value_code, value_label FROM category_values + WHERE table_name = $1 AND column_name = $2 + AND value_code = ANY($3) AND is_active = true + ${cvCompanyCondition}`, + cvParams + ); + cvResult.rows.forEach((r: any) => { + labelMap[r.value_code] = r.value_label; + }); + } catch (e) { + // category_values 조회 실패 시 무시 + } + + // code_info에서 라벨 조회 (code_category 기반) + try { + const ttcResult = await pool.query( + `SELECT code_category FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND code_category IS NOT NULL + LIMIT 1`, + [tableName, columnName] + ); + const codeCategory = ttcResult.rows[0]?.code_category; + + if (codeCategory) { + const ciCompanyCondition = companyCode !== "*" + ? `AND (company_code = $3 OR company_code = '*')` + : ""; + const ciParams = companyCode !== "*" + ? [codeCategory, rawValues, companyCode] + : [codeCategory, rawValues]; + + const ciResult = await pool.query( + `SELECT code_value, code_name FROM code_info + WHERE code_category = $1 AND code_value = ANY($2) AND is_active = 'Y' + ${ciCompanyCondition}`, + ciParams + ); + ciResult.rows.forEach((r: any) => { + if (!labelMap[r.code_value]) { + labelMap[r.code_value] = r.code_name; + } + }); + } + } catch (e) { + // code_info 조회 실패 시 무시 + } + + // 라벨 매핑 적용 + if (Object.keys(labelMap).length > 0) { + result.rows.forEach((row: any) => { + if (labelMap[row.value]) { + row.label = labelMap[row.value]; + } + }); + } + } logger.info("컬럼 DISTINCT 값 조회 성공", { tableName, diff --git a/backend-node/src/controllers/systemNoticeController.ts b/backend-node/src/controllers/systemNoticeController.ts new file mode 100644 index 00000000..9a00a4f0 --- /dev/null +++ b/backend-node/src/controllers/systemNoticeController.ts @@ -0,0 +1,275 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +/** + * GET /api/system-notices + * 공지사항 목록 조회 + * - 최고 관리자(*): 전체 조회 + * - 일반 회사: 자신의 company_code 데이터만 조회 + * - is_active 필터 옵션 지원 + */ +export const getSystemNotices = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { is_active } = req.query; + + logger.info("공지사항 목록 조회 요청", { companyCode, is_active }); + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 최고 관리자가 아닌 경우 company_code 필터링 + if (companyCode !== "*") { + conditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // is_active 필터 (true/false 문자열 처리) + if (is_active !== undefined && is_active !== "") { + const activeValue = is_active === "true" || is_active === "1"; + conditions.push(`is_active = $${paramIndex}`); + params.push(activeValue); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const rows = await query( + `SELECT + id, + company_code, + title, + content, + is_active, + created_by, + created_at, + updated_at + FROM system_notice + ${whereClause} + ORDER BY created_at DESC`, + params + ); + + logger.info("공지사항 목록 조회 성공", { + companyCode, + count: rows.length, + }); + + res.status(200).json({ + success: true, + data: rows, + total: rows.length, + }); + } catch (error) { + logger.error("공지사항 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "공지사항 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * POST /api/system-notices + * 공지사항 등록 + * - company_code는 req.user.companyCode에서 자동 추출 (클라이언트 입력 신뢰 금지) + */ +export const createSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { title, content, is_active = true } = req.body; + + logger.info("공지사항 등록 요청", { companyCode, userId, title }); + + if (!title || !title.trim()) { + res.status(400).json({ + success: false, + message: "제목을 입력해주세요.", + }); + return; + } + + if (!content || !content.trim()) { + res.status(400).json({ + success: false, + message: "내용을 입력해주세요.", + }); + return; + } + + const [created] = await query( + `INSERT INTO system_notice (company_code, title, content, is_active, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [companyCode, title.trim(), content.trim(), is_active, userId] + ); + + logger.info("공지사항 등록 성공", { + id: created.id, + companyCode, + title: created.title, + }); + + res.status(201).json({ + success: true, + data: created, + message: "공지사항이 등록되었습니다.", + }); + } catch (error) { + logger.error("공지사항 등록 실패", { error }); + res.status(500).json({ + success: false, + message: "공지사항 등록 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * PUT /api/system-notices/:id + * 공지사항 수정 + * - WHERE id=$1 AND company_code=$2 로 타 회사 데이터 수정 차단 + * - 최고 관리자는 company_code 조건 없이 id만으로 수정 가능 + */ +export const updateSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { title, content, is_active } = req.body; + + logger.info("공지사항 수정 요청", { id, companyCode }); + + if (!title || !title.trim()) { + res.status(400).json({ + success: false, + message: "제목을 입력해주세요.", + }); + return; + } + + if (!content || !content.trim()) { + res.status(400).json({ + success: false, + message: "내용을 입력해주세요.", + }); + return; + } + + let result: any[]; + + if (companyCode === "*") { + // 최고 관리자: id만으로 수정 + result = await query( + `UPDATE system_notice + SET title = $1, content = $2, is_active = $3, updated_at = NOW() + WHERE id = $4 + RETURNING *`, + [title.trim(), content.trim(), is_active ?? true, id] + ); + } else { + // 일반 회사: company_code 추가 조건으로 타 회사 데이터 수정 차단 + result = await query( + `UPDATE system_notice + SET title = $1, content = $2, is_active = $3, updated_at = NOW() + WHERE id = $4 AND company_code = $5 + RETURNING *`, + [title.trim(), content.trim(), is_active ?? true, id, companyCode] + ); + } + + if (!result || result.length === 0) { + res.status(404).json({ + success: false, + message: "공지사항을 찾을 수 없거나 수정 권한이 없습니다.", + }); + return; + } + + logger.info("공지사항 수정 성공", { id, companyCode }); + + res.status(200).json({ + success: true, + data: result[0], + message: "공지사항이 수정되었습니다.", + }); + } catch (error) { + logger.error("공지사항 수정 실패", { error, id: req.params.id }); + res.status(500).json({ + success: false, + message: "공지사항 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * DELETE /api/system-notices/:id + * 공지사항 삭제 + * - WHERE id=$1 AND company_code=$2 로 타 회사 데이터 삭제 차단 + * - 최고 관리자는 company_code 조건 없이 id만으로 삭제 가능 + */ +export const deleteSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + logger.info("공지사항 삭제 요청", { id, companyCode }); + + let result: any[]; + + if (companyCode === "*") { + // 최고 관리자: id만으로 삭제 + result = await query( + `DELETE FROM system_notice WHERE id = $1 RETURNING id`, + [id] + ); + } else { + // 일반 회사: company_code 추가 조건으로 타 회사 데이터 삭제 차단 + result = await query( + `DELETE FROM system_notice WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + } + + if (!result || result.length === 0) { + res.status(404).json({ + success: false, + message: "공지사항을 찾을 수 없거나 삭제 권한이 없습니다.", + }); + return; + } + + logger.info("공지사항 삭제 성공", { id, companyCode }); + + res.status(200).json({ + success: true, + message: "공지사항이 삭제되었습니다.", + }); + } catch (error) { + logger.error("공지사항 삭제 실패", { error, id: req.params.id }); + res.status(500).json({ + success: false, + message: "공지사항 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; 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 0c35fdbd..0ab73e09 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2087,6 +2087,23 @@ export async function multiTableSave( return; } + // UNIQUE 제약조건 검증 (트랜잭션 전에) + const tmsMulti = new TableManagementService(); + const uniqueViolations = await tmsMulti.validateUniqueConstraints( + mainTable.tableName, + mainData, + companyCode, + isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined + ); + if (uniqueViolations.length > 0) { + client.release(); + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + await client.query("BEGIN"); // 1. 메인 테이블 저장 @@ -3088,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/middleware/errorHandler.ts b/backend-node/src/middleware/errorHandler.ts index 54d8f0a2..b592ae2e 100644 --- a/backend-node/src/middleware/errorHandler.ts +++ b/backend-node/src/middleware/errorHandler.ts @@ -41,13 +41,27 @@ export const errorHandler = ( // PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html if (pgError.code === "23505") { // unique_violation - error = new AppError("중복된 데이터가 존재합니다.", 400); + const constraint = pgError.constraint || ""; + const tbl = pgError.table || ""; + let col = ""; + if (constraint && tbl) { + const prefix = `${tbl}_`; + const suffix = "_key"; + if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) { + col = constraint.slice(prefix.length, -suffix.length); + } + } + const detail = col ? ` [${col}]` : ""; + error = new AppError(`중복된 데이터가 존재합니다.${detail}`, 400); } else if (pgError.code === "23503") { // foreign_key_violation error = new AppError("참조 무결성 제약 조건 위반입니다.", 400); } else if (pgError.code === "23502") { // not_null_violation - error = new AppError("필수 입력값이 누락되었습니다.", 400); + const colName = pgError.column || ""; + const tableName = pgError.table || ""; + const detail = colName ? ` [${tableName}.${colName}]` : ""; + error = new AppError(`필수 입력값이 누락되었습니다.${detail}`, 400); } else if (pgError.code.startsWith("23")) { // 기타 무결성 제약 조건 위반 error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); @@ -84,6 +98,7 @@ export const errorHandler = ( // 응답 전송 res.status(statusCode).json({ success: false, + message: message, error: { message: message, ...(process.env.NODE_ENV === "development" && { stack: error.stack }), diff --git a/backend-node/src/routes/approvalRoutes.ts b/backend-node/src/routes/approvalRoutes.ts index 3f2cd2f2..d312b344 100644 --- a/backend-node/src/routes/approvalRoutes.ts +++ b/backend-node/src/routes/approvalRoutes.ts @@ -5,6 +5,7 @@ import { ApprovalRequestController, ApprovalLineController, } from "../controllers/approvalController"; +import { ApprovalProxyController } from "../controllers/approvalProxyController"; import { authenticateToken } from "../middleware/authMiddleware"; const router = express.Router(); @@ -30,9 +31,17 @@ router.get("/requests", ApprovalRequestController.getRequests); router.get("/requests/:id", ApprovalRequestController.getRequest); router.post("/requests", ApprovalRequestController.createRequest); router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest); +router.post("/requests/:id/post-approve", ApprovalRequestController.postApprove); // ==================== 결재 라인 처리 (Lines) ==================== router.get("/my-pending", ApprovalLineController.getMyPendingLines); router.post("/lines/:lineId/process", ApprovalLineController.processApproval); +// ==================== 대결 위임 설정 (Proxy Settings) ==================== +router.get("/proxy-settings", ApprovalProxyController.getProxySettings); +router.post("/proxy-settings", ApprovalProxyController.createProxySetting); +router.put("/proxy-settings/:id", ApprovalProxyController.updateProxySetting); +router.delete("/proxy-settings/:id", ApprovalProxyController.deleteProxySetting); +router.get("/proxy-settings/check/:userId", ApprovalProxyController.checkActiveProxy); + export default router; diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 3c47b901..36e8bd62 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -6,6 +6,8 @@ import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { auditLogService } from "../services/auditLogService"; +import { TableManagementService } from "../services/tableManagementService"; +import { formatPgError } from "../utils/pgErrorUtil"; const router = express.Router(); @@ -1047,6 +1049,20 @@ router.post( console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`); } + // UNIQUE 제약조건 검증 + const tms = new TableManagementService(); + const uniqueViolations = await tms.validateUniqueConstraints( + tableName, + enrichedData, + req.user?.companyCode || "*" + ); + if (uniqueViolations.length > 0) { + return res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + } + // 레코드 생성 const result = await dataService.createRecord(tableName, enrichedData); @@ -1116,6 +1132,21 @@ router.put( console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data); + // UNIQUE 제약조건 검증 (자기 자신 제외) + const tmsUpdate = new TableManagementService(); + const uniqueViolations = await tmsUpdate.validateUniqueConstraints( + tableName, + data, + req.user?.companyCode || "*", + String(id) + ); + if (uniqueViolations.length > 0) { + return res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + } + // 레코드 수정 const result = await dataService.updateRecord(tableName, id, data); 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/systemNoticeRoutes.ts b/backend-node/src/routes/systemNoticeRoutes.ts new file mode 100644 index 00000000..54506386 --- /dev/null +++ b/backend-node/src/routes/systemNoticeRoutes.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import { + getSystemNotices, + createSystemNotice, + updateSystemNotice, + deleteSystemNotice, +} from "../controllers/systemNoticeController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 공지사항 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 공지사항 목록 조회 (is_active 필터 쿼리 파라미터 지원) +router.get("/", getSystemNotices); + +// 공지사항 등록 +router.post("/", createSystemNotice); + +// 공지사항 수정 +router.put("/:id", updateSystemNotice); + +// 공지사항 삭제 +router.delete("/:id", deleteSystemNotice); + +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 d18f479b..03e5db4c 100644 --- a/backend-node/src/services/multiTableExcelService.ts +++ b/backend-node/src/services/multiTableExcelService.ts @@ -970,10 +970,11 @@ class MultiTableExcelService { const result = await pool.query( `SELECT c.column_name, - c.is_nullable, + c.is_nullable AS db_is_nullable, c.column_default, - COALESCE(ttc.column_label, cl.column_label) AS column_label, - COALESCE(ttc.reference_table, cl.reference_table) AS reference_table + 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 LEFT JOIN table_type_columns cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' @@ -991,13 +992,13 @@ class MultiTableExcelService { // 시스템 컬럼 제외 if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue; - // FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조) - // 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로, - // _id로 끝나면서 reference_table이 있는 경우만 제외 + // FK 컬럼 제외 if (row.reference_table && colName.endsWith("_id")) continue; const hasDefault = row.column_default !== null; - const isNullable = row.is_nullable === "YES"; + const dbNullable = row.db_is_nullable === "YES"; + const ttcNotNull = row.ttc_is_nullable === "N"; + const isNullable = ttcNotNull ? false : dbNullable; const isRequired = !isNullable && !hasDefault; columns.push({ 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/pgErrorUtil.ts b/backend-node/src/utils/pgErrorUtil.ts new file mode 100644 index 00000000..3f7c56d3 --- /dev/null +++ b/backend-node/src/utils/pgErrorUtil.ts @@ -0,0 +1,80 @@ +import { query } from "../database/db"; + +/** + * PostgreSQL 에러를 사용자 친절한 메시지로 변환 + * table_type_columns의 column_label을 조회하여 한글 라벨로 표시 + */ +export async function formatPgError( + error: any, + companyCode?: string +): Promise { + if (!error || !error.code) { + return error?.message || "데이터 처리 중 오류가 발생했습니다."; + } + + switch (error.code) { + case "23502": { + // not_null_violation + const colName = error.column || ""; + const tblName = error.table || ""; + + if (colName && tblName && companyCode) { + try { + const rows = await query( + `SELECT column_label FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND company_code = $3 + LIMIT 1`, + [tblName, colName, companyCode] + ); + const label = rows[0]?.column_label; + if (label) { + return `필수 입력값이 누락되었습니다: ${label}`; + } + } catch { + // 라벨 조회 실패 시 컬럼명으로 폴백 + } + } + const detail = colName ? ` [${colName}]` : ""; + return `필수 입력값이 누락되었습니다.${detail}`; + } + case "23505": { + // unique_violation + const constraint = error.constraint || ""; + const tblName = error.table || ""; + // constraint 이름에서 컬럼명 추출 시도 (예: item_mst_item_code_key → item_code) + let colName = ""; + if (constraint && tblName) { + const prefix = `${tblName}_`; + const suffix = "_key"; + if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) { + colName = constraint.slice(prefix.length, -suffix.length); + } + } + if (colName && tblName && companyCode) { + try { + const rows = await query( + `SELECT column_label FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND company_code = $3 + LIMIT 1`, + [tblName, colName, companyCode] + ); + const label = rows[0]?.column_label; + if (label) { + return `중복된 데이터가 존재합니다: ${label}`; + } + } catch { + // 폴백 + } + } + const detail = colName ? ` [${colName}]` : ""; + return `중복된 데이터가 존재합니다.${detail}`; + } + case "23503": + return "참조 무결성 제약 조건 위반입니다."; + default: + if (error.code.startsWith("23")) { + return "데이터 무결성 제약 조건 위반입니다."; + } + return error.message || "데이터 처리 중 오류가 발생했습니다."; + } +} 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/docs/responsive-component-strategy.md b/docs/responsive-component-strategy.md new file mode 100644 index 00000000..b7feab2f --- /dev/null +++ b/docs/responsive-component-strategy.md @@ -0,0 +1,155 @@ +# WACE 반응형 컴포넌트 전략 + +## 개요 + +WACE 프로젝트의 모든 반응형 UI는 **3개의 레이아웃 프리미티브 + 1개의 훅**으로 통일한다. +컴포넌트마다 새로 타입을 정의하거나 리사이저를 구현하지 않는다. + +## 아키텍처 + +``` +┌─────────────────────────────────────────────────┐ +│ useResponsive() 훅 │ +│ isMobile | isTablet | isDesktop | width │ +└──────────┬──────────┬──────────┬────────────────┘ + │ │ │ + ┌───────▼──┐ ┌────▼─────┐ ┌─▼──────────────┐ + │ 데이터 │ │ 좌우분할 │ │ 캔버스(디자이너)│ + │ 목록 │ │ 패널 │ │ 화면 │ + └──────────┘ └──────────┘ └────────────────┘ + ResponsiveDataView ResponsiveSplitPanel ResponsiveGridRenderer +``` + +## 1. useResponsive (훅) + +**위치**: `frontend/lib/hooks/useResponsive.ts` + +모든 반응형 판단의 기반. 직접 breakpoint 분기가 필요할 때만 사용. +가능하면 아래 레이아웃 컴포넌트를 쓰고, 훅 직접 사용은 최소화. + +| 반환값 | 브레이크포인트 | 해상도 | +|--------|---------------|--------| +| isMobile | xs, sm | < 768px | +| isTablet | md | 768 ~ 1023px | +| isDesktop | lg, xl, 2xl | >= 1024px | + +## 2. ResponsiveDataView (데이터 목록) + +**위치**: `frontend/components/common/ResponsiveDataView.tsx` +**패턴**: 데스크톱 = 테이블, 모바일 = 카드 리스트 +**적용 대상**: 모든 목록/리스트 화면 + +```tsx + + data={users} + columns={columns} + keyExtractor={(u) => u.id} + cardTitle={(u) => u.name} + cardFields={[ + { label: "이메일", render: (u) => u.email }, + { label: "부서", render: (u) => u.dept }, + ]} + renderActions={(u) => } +/> +``` + +**적용 완료 (12개 화면)**: +- UserTable, CompanyTable, UserAuthTable +- DataFlowList, ScreenList +- system-notices, approvalTemplate, standards +- batch-management, mail/receive, flowMgmtList +- exconList, exCallConfList + +## 3. ResponsiveSplitPanel (좌우 분할) + +**위치**: `frontend/components/common/ResponsiveSplitPanel.tsx` +**패턴**: 데스크톱 = 좌우 분할(리사이저 포함), 모바일 = 세로 스택(접기/펼치기) +**적용 대상**: 카테고리관리, 메뉴관리, 부서관리, BOM 등 좌우 분할 레이아웃 + +```tsx +} + right={} + leftTitle="카테고리" + leftWidth={25} + minLeftWidth={10} + maxLeftWidth={40} + height="calc(100vh - 120px)" +/> +``` + +**Props**: +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| left | ReactNode | 필수 | 좌측 패널 콘텐츠 | +| right | ReactNode | 필수 | 우측 패널 콘텐츠 | +| leftTitle | string | "목록" | 모바일 접기 헤더 | +| leftWidth | number | 25 | 초기 좌측 너비(%) | +| minLeftWidth | number | 10 | 최소 좌측 너비(%) | +| maxLeftWidth | number | 50 | 최대 좌측 너비(%) | +| showResizer | boolean | true | 리사이저 표시 | +| collapsedOnMobile | boolean | true | 모바일 기본 접힘 | +| height | string | "100%" | 컨테이너 높이 | + +**동작**: +- 데스크톱(>= 1024px): 좌우 분할 + 드래그 리사이저 + 좌측 접기 버튼 +- 모바일(< 1024px): 세로 스택, 좌측 패널 40vh 제한, 접기/펼치기 + +**마이그레이션 후보**: +- `V2CategoryManagerComponent` (완료) +- `SplitPanelLayoutComponent` (v1, v2) +- `BomTreeComponent` +- `ScreenSplitPanel` +- menu/page.tsx (메뉴 관리) +- departments/page.tsx (부서 관리) + +## 4. ResponsiveGridRenderer (디자이너 캔버스) + +**위치**: `frontend/components/screen/ResponsiveGridRenderer.tsx` +**패턴**: 데스크톱(비전폭 컴포넌트) = 캔버스 스케일링, 그 외 = Flex 그리드 +**적용 대상**: 화면 디자이너로 만든 동적 화면 + +이 컴포넌트는 화면 디자이너 시스템 전용. 일반 개발에서 직접 사용하지 않음. + +## 사용 가이드 + +### 새 화면 만들 때 + +| 화면 유형 | 사용 컴포넌트 | +|-----------|--------------| +| 데이터 목록 (테이블) | `ResponsiveDataView` | +| 좌우 분할 (트리+상세) | `ResponsiveSplitPanel` | +| 디자이너 화면 | `ResponsiveGridRenderer` (자동) | +| 단순 레이아웃 | Tailwind 반응형 (`flex-col lg:flex-row`) | + +### 금지 사항 + +1. 컴포넌트 내부에 `isDraggingRef`, `handleMouseDown/Move/Up` 직접 구현 금지 + -> `ResponsiveSplitPanel` 사용 +2. `hidden lg:block` / `lg:hidden` 패턴으로 테이블/카드 이중 렌더링 금지 + -> `ResponsiveDataView` 사용 +3. `window.innerWidth` 직접 체크 금지 + -> `useResponsive()` 훅 사용 +4. 반응형 분기를 위한 새로운 타입/인터페이스 정의 금지 + -> 기존 프리미티브의 Props 사용 + +### 폐기 예정 컴포넌트 + +| 컴포넌트 | 대체 | 상태 | +|----------|------|------| +| `ResponsiveContainer` | Tailwind 또는 `useResponsive` | 미사용, 삭제 예정 | +| `ResponsiveGrid` | Tailwind `grid-cols-*` | 미사용, 삭제 예정 | +| `ResponsiveText` | Tailwind `text-sm lg:text-lg` | 미사용, 삭제 예정 | + +## 파일 구조 + +``` +frontend/ +├── lib/hooks/ +│ └── useResponsive.ts # 브레이크포인트 훅 (기반) +├── components/common/ +│ ├── ResponsiveDataView.tsx # 테이블/카드 전환 +│ └── ResponsiveSplitPanel.tsx # 좌우 분할 반응형 +└── components/screen/ + └── ResponsiveGridRenderer.tsx # 디자이너 캔버스 렌더러 +``` diff --git a/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md b/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md new file mode 100644 index 00000000..816eaa1e --- /dev/null +++ b/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md @@ -0,0 +1,340 @@ +# [계획서] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장 + +> 관련 문서: [맥락노트](./BIC[맥락]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md) + +## 개요 + +화면 디자이너에서 버튼을 텍스트 모드(현행), 아이콘 모드, 아이콘+텍스트 모드 중 선택할 수 있도록 확장한다. +아이콘 모드 선택 시 버튼 액션에 맞는 아이콘 후보군이 제시되고, 관리자가 원하는 아이콘을 선택한다. +아이콘 크기 비율(버튼 높이 대비 4단계 프리셋), 아이콘 색상, 텍스트 위치(4방향), 아이콘-텍스트 간격 설정을 제공한다. +관리자가 lucide 검색 또는 외부 SVG 붙여넣기로 커스텀 아이콘을 추가/삭제할 수 있다. + +--- + +## 현재 동작 + +- 버튼은 항상 **텍스트 모드**로만 표시됨 +- `ButtonConfigPanel.tsx`에서 "버튼 텍스트" 입력 → 실제 화면에서 해당 텍스트가 버튼에 표시 +- 아이콘 표시 기능 없음 + +### 현재 코드 위치 + +| 구분 | 파일 | 설명 | +|------|------|------| +| 설정 패널 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트, 액션 설정 (784~854행) | +| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 실제 버튼 렌더링 (961~983행) | +| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewer.tsx` | 실제 버튼 렌더링 (2041~2059행) | +| 위젯 렌더링 | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) | +| 최적화 컴포넌트 | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 컴포넌트 (643~674행) | + +--- + +## 변경 후 동작 + +### 1. 표시 모드 선택 (라디오 그룹) + +ButtonConfigPanel에 "버튼 텍스트" 입력 위에 표시 모드 선택 UI 추가: + +- **텍스트 모드** (기본값, 현행 유지): 버튼에 텍스트만 표시 +- **아이콘 모드**: 버튼에 아이콘만 표시 +- **아이콘+텍스트 모드**: 버튼에 아이콘과 텍스트를 함께 표시 + +``` +[ 텍스트 | 아이콘 | 아이콘+텍스트 ] ← 라디오 그룹 (토글 형태) +``` + +### 2. 텍스트 모드 선택 시 + +- 현재와 동일하게 "버튼 텍스트" 입력 필드 표시 +- 변경 사항 없음 + +### 2-1. 아이콘+텍스트 모드 선택 시 + +- 아이콘 선택 UI (3장과 동일) + 버튼 텍스트 입력 필드 **둘 다 표시** +- 렌더링: 텍스트 위치에 따라 아이콘과 텍스트 배치 방향이 달라짐 +- 텍스트 위치 4방향: 오른쪽(기본), 왼쪽, 위쪽, 아래쪽 +- 예시: `[ ✓ 저장 ]` (오른쪽), `[ 저장 ✓ ]` (왼쪽), 세로 배치 (위쪽/아래쪽) +- 아이콘과 텍스트 사이 간격: 기본 6px, 관리자가 0~무제한 조절 가능 (슬라이더 0~32px + 직접 입력) + +### 3. 아이콘 모드 선택 시 + +#### 3-1. 버튼 액션별 추천 아이콘 목록 + +버튼 액션(`action.type`)에 따라 해당 액션에 어울리는 아이콘 후보군을 그리드로 표시: + +| 버튼 액션 | 값 | 추천 아이콘 (lucide-react) | +|-----------|-----|---------------------------| +| 저장 | `save` | Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck | +| 삭제 | `delete` | Trash2, Trash, XCircle, X, Eraser, CircleX | +| 편집 | `edit` | Pencil, PenLine, Edit, SquarePen, FilePen, PenTool | +| 페이지 이동 | `navigate` | ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link | +| 모달 열기 | `modal` | Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen | +| 데이터 전달 | `transferData` | SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2 | +| 엑셀 다운로드 | `excel_download` | Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput | +| 엑셀 업로드 | `excel_upload` | Upload, FileUp, FileSpreadsheet, Sheet, ImportIcon, FileInput | +| 즉시 저장 | `quickInsert` | Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus | +| 제어 흐름 | `control` | Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Cog | +| 바코드 스캔 | `barcode_scan` | ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus | +| 운행알림 및 종료 | `operation_control` | Truck, Car, MapPin, Navigation2, Route, Bell | +| 이벤트 발송 | `event` | Send, Bell, Radio, Megaphone, Podcast, BellRing | +| 복사 | `copy` | Copy, ClipboardCopy, Files, CopyPlus, Duplicate, ClipboardList | + +**적절한 아이콘이 없는 액션 (숨김 처리된 deprecated 액션들):** + +| 버튼 액션 | 값 | 안내 문구 | +|-----------|-----|----------| +| 연관 데이터 버튼 모달 열기 | `openRelatedModal` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 | +| (deprecated) 데이터 전달 + 모달 | `openModalWithData` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 | +| 테이블 이력 보기 | `view_table_history` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 | +| 코드 병합 | `code_merge` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 | +| 공차등록 | `empty_vehicle` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 | + +> 안내 문구: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요." +> 안내 문구 아래에 커스텀 아이콘 목록 + lucide 검색/SVG 붙여넣기 버튼이 표시됨 + +#### 3-2. 아이콘 선택 UI + +- 액션별 추천 아이콘을 4~6열 그리드로 표시 +- 각 아이콘은 32x32 크기, 호버 시 하이라이트, 선택 시 ring 표시 +- 아이콘 아래에 이름 표시 (`text-[10px]`) +- 관리자가 추가한 커스텀 아이콘이 있으면 "커스텀 아이콘" 구분선 아래 함께 표시 + +#### 3-3. 아이콘 크기 비율 설정 + +버튼 높이 대비 비율로 아이콘 크기를 설정 (정사각형 유지): + +**프리셋 (ToggleGroup, 4단계):** + +| 이름 | 버튼 높이 대비 | 설명 | +|------|--------------|------| +| 작게 | 40% | 컴팩트한 아이콘 | +| 보통 | 55% | 기본값, 대부분의 버튼에 적합 | +| 크게 | 70% | 존재감 있는 크기 | +| 매우 크게 | 85% | 아이콘 강조, 버튼에 꽉 차는 느낌 | + +- px 직접 입력은 제거 (비율 기반이므로 버튼 크기 변경 시 아이콘도 자동 비례) +- 저장: `icon.size`에 프리셋 문자열(`"보통"`) 저장 +- 렌더링: `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지 + +#### 3-4. 아이콘 색상 설정 + +아이콘 크기 아래에 아이콘 전용 색상 설정: + +- **컬러 피커**: 기존 버튼 색상 설정과 동일한 UI 사용 +- **기본값**: 미설정 (= `textColor` 상속, 기존 동작과 동일) +- **설정 시**: lucide 아이콘은 지정한 색상으로 덮어쓰기 +- **외부 SVG**: 고유 색상이 하드코딩된 SVG는 이 설정의 영향을 받지 않음 (원본 유지) +- **초기화 버튼**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 가능 + +| 상황 | iconColor 설정 | 결과 | +|------|---------------|------| +| lucide 아이콘, iconColor 미설정 | 없음 | textColor 상속 (기존 동작) | +| lucide 아이콘, iconColor 설정 | `#22c55e` | 초록색 아이콘 | +| 외부 SVG (고유 색상), iconColor 설정 | `#22c55e` | SVG 원본 색상 유지 (무시) | +| 외부 SVG (currentColor), iconColor 설정 | `#22c55e` | 초록색 아이콘 | + +#### 3-5. 텍스트 위치 설정 (아이콘+텍스트 모드 전용) + +아이콘 대비 텍스트의 배치 방향을 4방향으로 설정: + +| 위치 | 값 | 레이아웃 | 설명 | +|------|-----|---------|------| +| 왼쪽 | `left` | `텍스트 ← 아이콘` | 텍스트가 아이콘 왼쪽 (가로) | +| 오른쪽 | `right` | `아이콘 → 텍스트` | 기본값, 아이콘 뒤에 텍스트 (가로) | +| 위쪽 | `top` | 텍스트 위, 아이콘 아래 | 세로 배치 | +| 아래쪽 | `bottom` | 아이콘 위, 텍스트 아래 | 세로 배치 | + +- 기본값: `"right"` (아이콘 오른쪽에 텍스트) +- 저장: `componentConfig.iconTextPosition` +- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요) + +#### 3-6. 아이콘-텍스트 간격 설정 (아이콘+텍스트 모드 전용) + +아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 조절: + +- **슬라이더**: 0~32px 범위 시각적 조절 +- **직접 입력**: px 수치 직접 입력 (최솟값 0, 최댓값 제한 없음) +- **기본값**: 6px +- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요) + +#### 3-7. 아이콘 모드 레이아웃 안내 + +아이콘만 표시하면 텍스트보다 좁은 공간으로 충분하므로 안내 문구 표시: + +``` +ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다. +``` + +- `bg-blue-50 dark:bg-blue-950/20` 배경의 안내 박스 +- 아이콘 모드(`"icon"`)에서만 표시, 아이콘+텍스트 모드에서는 숨김 + +#### 3-8. 디폴트 아이콘 자동 부여 + +아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택 상태이면 **디폴트 아이콘을 자동으로 부여**한다. + +| 상황 | 디폴트 아이콘 | +|------|-------------| +| 추천 아이콘이 있는 액션 (save, delete 등) | 해당 액션의 **첫 번째 추천 아이콘** (예: save → Check) | +| 추천 아이콘이 없는 액션 (deprecated 등) | 범용 폴백 아이콘: `SquareMousePointer` | + +**커스텀 아이콘 삭제 시:** +- 현재 선택된 커스텀 아이콘을 삭제하면 **디폴트 아이콘으로 자동 복귀** (텍스트 모드로 빠지지 않음) +- 아이콘 모드를 유지한 채 디폴트 아이콘이 캔버스에 즉시 반영됨 + +#### 3-9. 커스텀 아이콘 추가/삭제 + +**방법 1: lucide 아이콘 검색으로 추가** +- "아이콘 추가" 버튼 클릭 시 lucide 아이콘 전체 검색 가능한 모달/팝오버 표시 +- 검색 입력 → 아이콘 이름으로 필터링 → 선택하면 커스텀 목록에 추가 + +**방법 2: 외부 SVG 붙여넣기로 추가** +- "SVG 붙여넣기" 버튼 클릭 시 텍스트 입력 영역(textarea) 표시 +- 외부에서 복사한 SVG 코드를 붙여넣기 → 미리보기로 확인 → "추가" 버튼으로 등록 +- SVG 유효성 검사: `; + action: { + type: string; // 기존: 버튼 액션 타입 + // ...기존 action 속성들 유지 + }; +} +``` + +### 저장 예시 + +```json +{ + "text": "저장", + "displayMode": "icon", + "icon": { + "name": "Check", + "type": "lucide", + "size": "보통", + "color": "#22c55e" + }, + "customIcons": ["Rocket", "Star"], + "customSvgIcons": [ + { + "name": "회사로고", + "svg": "..." + } + ], + "action": { + "type": "save" + } +} +``` + +--- + +## 시각적 동작 예시 + +### ButtonConfigPanel (디자이너 편집 모드) + +``` +표시 모드: [ 텍스트 | (아이콘) | 아이콘+텍스트 ] ← 아이콘 선택됨 + +아이콘 선택: +┌──────────────────────────────────┐ +│ 추천 아이콘 (저장) │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │ ✓ │ │ 💾 │ │ ✓○ │ │ ○✓ │ │ +│ │Check│ │Save│ │Chk○│ │○Chk│ │ +│ └────┘ └────┘ └────┘ └────┘ │ +│ ┌────┐ ┌────┐ │ +│ │📄✓│ │🛡✓│ │ +│ │FChk│ │ShCk│ │ +│ └────┘ └────┘ │ +│ │ +│ ── 커스텀 아이콘 ── │ +│ ┌────┐ ┌────┐ ┌────┐ │ +│ │ 🚀 │ │ ⭐ │ │[로고]│ │ +│ │Rckt │ │Star│ │회사 │ │ +│ │ ✕ │ │ ✕│ │ ✕ │ │ +│ └────┘ └────┘ └────┘ │ +│ [+ lucide 검색] [+ SVG 붙여넣기]│ +└──────────────────────────────────┘ + +아이콘 크기 비율: [ 작게 | (보통) | 크게 | 매우 크게 ] +텍스트 위치: [ 왼쪽 | (오른쪽) | 위쪽 | 아래쪽 ] ← 아이콘+텍스트 모드에서만 표시 +아이콘-텍스트 간격: [━━━━━○━━] [6] px ← 아이콘+텍스트 모드에서만 표시 +아이콘 색상: [■ #22c55e] [텍스트 색상과 동일] + +ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다. +``` + +### 실제 화면 렌더링 + +| 모드 | 표시 | +|------|------| +| 텍스트 모드 | `[ 저장 ]` | +| 아이콘 모드 (보통, 55%) | `[ ✓ ]` | +| 아이콘 모드 (매우 크게, 85%) | `[ ✓ ]` | +| 아이콘+텍스트 (텍스트 오른쪽) | `[ ✓ 저장 ]` (간격 6px) | +| 아이콘+텍스트 (텍스트 왼쪽) | `[ 저장 ✓ ]` | +| 아이콘+텍스트 (텍스트 아래쪽) | 아이콘 위, 텍스트 아래 (세로) | +| 아이콘+텍스트 (색상 분리) | `[ 초록✓ 검정저장 ]` | + +--- + +## 변경 대상 + +### 수정 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `ButtonConfigPanel.tsx` | 표시 모드 3종 라디오, 아이콘 그리드, 크기, 색상, 간격 설정, 레이아웃 안내, 커스텀 아이콘 UI | +| `InteractiveScreenViewerDynamic.tsx` | `displayMode` 3종 분기 → 아이콘/아이콘+텍스트/텍스트 렌더링 | +| `InteractiveScreenViewer.tsx` | 동일 분기 추가 | +| `ButtonWidget.tsx` | 동일 분기 추가 | +| `OptimizedButtonComponent.tsx` | 동일 분기 추가 | +| `ScreenDesigner.tsx` | 입력 필드 포커스 시 키보드 단축키 기본 동작 허용 (Ctrl+A/C/V/Z) | +| `RealtimePreviewDynamic.tsx` | 버튼 컴포넌트 position wrapper에서 border 속성 분리 (이중 테두리 방지) | + +### 신규 파일 + +| 파일 | 내용 | +|------|------| +| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 | + +--- + +## 설계 원칙 + +- 기본값은 `"text"` 모드 → 기존 모든 버튼은 변경 없이 동작 +- `displayMode`가 없거나 `"text"`이면 현행 텍스트 렌더링 유지 +- 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 **디폴트 아이콘 자동 부여** (빈 상태 방지) +- 커스텀 아이콘 삭제 시 텍스트 모드로 빠지지 않고 **디폴트 아이콘으로 자동 복귀** +- 아이콘 모드에서도 `text` 값은 유지 (접근성 aria-label로 활용) +- 기본 아이콘은 lucide-react 사용 (프로젝트 일관성) +- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능 +- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장 +- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화 diff --git a/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md b/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md new file mode 100644 index 00000000..f4b2b16d --- /dev/null +++ b/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md @@ -0,0 +1,263 @@ +# [맥락노트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장 + +> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md) + +--- + +## 왜 이 작업을 하는가 + +- 현재 모든 버튼은 텍스트로만 표시 → 버튼 영역이 넓어야 하고, 모바일/태블릿에서 공간 효율이 낮음 +- "저장", "삭제", "추가" 같은 자주 쓰는 버튼은 아이콘만으로 충분히 인식 가능 +- 관리자가 화면 레이아웃을 더 컴팩트하게 구성할 수 있도록 선택권 제공 +- 단, "출하 계획" 같이 아이콘화가 어려운 특수 버튼이 존재하므로 텍스트 모드도 반드시 유지 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 표시 모드는 3종 라디오 그룹(토글 형태)으로 구현 + +- **결정**: `ToggleGroup` 형태의 세 개 옵션 (텍스트 / 아이콘 / 아이콘+텍스트) +- **근거**: 세 모드는 상호 배타적. 아이콘+텍스트 병합 모드가 있어야 `[ + 추가 ]`, `[ 💾 저장 ]` 같은 실무 패턴을 지원. 아이콘만으로 의미 전달이 부족한 경우 텍스트를 병기하면 사용자 인식 속도가 빨라짐 +- **대안 검토**: Switch(토글) → 기각 ("무엇이 켜지는지" 직관적이지 않음, 3종 불가) + +### 2. 기본값은 텍스트 모드 + +- **결정**: `displayMode` 기본값 = `"text"` +- **근거**: 기존 모든 버튼은 텍스트로 동작 중. 아이콘 모드는 명시적으로 선택해야만 적용되어야 하위 호환성이 보장됨 +- **중요**: `displayMode`가 `undefined`이거나 `"text"`이면 현행 동작 그대로 유지 + +### 3. 아이콘은 버튼 액션(action.type)에 연동 + +- **결정**: 버튼 액션을 변경하면 해당 액션에 맞는 추천 아이콘 목록이 자동으로 갱신됨 +- **근거**: 관리자가 "저장" 아이콘을 고른 뒤 액션을 "삭제"로 바꾸면 혼란 발생. 액션별로 적절한 아이콘 후보를 보여주는 것이 자연스러움 +- **주의**: 액션 변경 시 이전에 선택한 아이콘이 새 액션의 추천 목록에 없으면 선택 초기화 + +### 4. 액션별 아이콘은 6개씩 제공, 적절한 아이콘이 없으면 안내 문구 + +- **결정**: 활성 액션 14개 각각에 6개의 lucide-react 아이콘 후보 제공 +- **근거**: 너무 적으면 선택지 부족, 너무 많으면 선택 피로. 6개가 2행 그리드로 깔끔하게 표시됨 +- **deprecated/숨김 액션**: UI에서 숨김 처리된 액션은 추천 아이콘 없이 안내 문구만 표시 + +### 5. 커스텀 아이콘 추가는 2가지 방법 제공 + +- **결정**: (1) lucide 아이콘 검색 + (2) 외부 SVG 붙여넣기 두 가지 경로 제공 +- **근거**: lucide 내장 아이콘만으로는 부족한 경우 존재 (회사 로고, 업종별 특수 아이콘 등). 외부에서 가져온 SVG를 직접 붙여넣기로 등록할 수 있어야 실무 유연성 확보 +- **lucide 추가**: "lucide 검색" 버튼 → 팝오버에서 검색 → 선택 → `customIcons` 배열에 이름 추가 +- **SVG 추가**: "SVG 붙여넣기" 버튼 → textarea에 SVG 코드 붙여넣기 → 미리보기 확인 → 이름 입력 → `customSvgIcons` 배열에 `{ name, svg }` 저장 +- **SVG 유효성**: ``, `onload` 같은 악성 코드가 포함될 수 있으므로 XSS 방지 필수 +- **렌더링**: 정화된 SVG를 `dangerouslySetInnerHTML`로 렌더링 (정화 후이므로 안전) +- **대안 검토**: SVG를 이미지 파일로 업로드 → 기각 (관리자 입장에서 복사-붙여넣기가 훨씬 간편) + +### 6. 아이콘 색상은 별도 설정, 기본값은 textColor 상속 + +- **결정**: `icon.color` 옵션 추가. 미설정 시 `textColor`를 상속, 설정하면 아이콘만 해당 색상 적용 +- **근거**: 아이콘+텍스트 모드에서 `[ 초록✓ 검정저장 ]` 같이 아이콘과 텍스트 색을 분리하고 싶은 경우 존재. 삭제 버튼에 빨간 아이콘 + 흰 텍스트 같은 세밀한 디자인도 가능 +- **기본값**: 미설정 (= `textColor` 상속) → 설정하지 않으면 기존 동작과 100% 동일 +- **외부 SVG**: `fill`이 하드코딩된 SVG는 이 설정 무시 (SVG 원본 색상 유지가 의도). `currentColor`를 사용하는 SVG만 영향받음 +- **구현**: 아이콘을 ``으로 감싸서 아이콘만 색상 분리 +- **초기화**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 → `icon.color` 삭제 + +### 7. 아이콘 크기는 버튼 높이 대비 비율(%) 프리셋 4단계 + +- **결정**: 작게(40%) / 보통(55%) / 크게(70%) / 매우 크게(85%) — 버튼 높이 대비 비율 +- **근거**: 절대 px 값은 버튼 크기가 바뀌면 비율이 깨짐. 비율 기반이면 버튼 크기를 조정해도 아이콘이 자동으로 비례하여 일관된 시각적 균형 유지 +- **기본값**: `"보통"` (55%) — 대부분의 버튼 크기에 적합 +- **px 직접 입력 제거**: 관리자에게 과도한 선택지를 주면 오히려 일관성이 깨짐. 4단계 프리셋만으로 충분 +- **구현**: CSS `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지, lucide 아이콘은 래핑 span으로 크기 제어 +- **레거시 호환**: 기존 `"sm"`, `"md"` 등 레거시 값은 55%(보통)로 자동 폴백 + +### 8. 아이콘 동적 렌더링은 매핑 객체 방식 + +- **결정**: lucide-react 아이콘 이름(string) → 실제 컴포넌트 매핑 객체를 별도 파일로 관리 +- **근거**: `import * from 'lucide-react'`는 번들 크기에 영향. 사용하는 아이콘만 명시적으로 매핑 +- **파일**: `frontend/lib/button-icon-map.ts` +- **구현**: `Record` 형태의 매핑 + `renderIcon(name, size)` 유틸 함수 + +### 9. 아이콘 모드에서도 text 값은 유지 + +- **결정**: `displayMode === "icon"`이어도 `text` 필드는 삭제하지 않음 +- **근거**: 접근성(`aria-label`), 검색/필터링 등에 텍스트가 필요할 수 있음 +- **렌더링**: 아이콘 모드에서는 `text`를 `aria-label` 용도로만 보존 +- **아이콘+텍스트 모드**: `text`가 아이콘 오른쪽에 함께 렌더링됨 + +### 10. 아이콘-텍스트 간격 설정 추가 + +- **결정**: 아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 관리자가 조절 가능 (`iconGap`) +- **근거**: 고정 `gap-1.5`(6px)로는 다양한 버튼 크기/디자인에 대응 불가. 간격이 좁으면 답답하고, 넓으면 분리되어 보이는 경우가 있어 관리자에게 조절 권한 제공 +- **기본값**: 6px (기존 `gap-1.5`와 동일) +- **UI**: 슬라이더(0~32px) + 숫자 직접 입력(최댓값 제한 없음) +- **저장**: `componentConfig.iconGap` (숫자) + +### 11. 키보드 단축키 입력 필드 충돌 해결 + +- **결정**: `ScreenDesigner`의 글로벌 키보드 핸들러에서 입력 필드 포커스 시 앱 단축키를 무시하도록 수정 +- **근거**: SVG 붙여넣기 textarea에서 Ctrl+V/A/C/Z가 작동하지 않는 치명적 UX 문제 발견. 글로벌 `keydown` 핸들러가 `{ capture: true }`로 모든 키보드 이벤트를 가로채고 있었음 +- **수정**: `browserShortcuts` 일괄 차단과 앱 전용 단축키 처리 앞에 `e.target`/`document.activeElement` 기반 입력 필드 감지 가드 추가 +- **영향**: input, textarea, select, contentEditable 요소에서 텍스트 편집 단축키가 정상 동작 + +### 12. noIconAction에서 커스텀 아이콘 추가 허용 + +- **결정**: 추천 아이콘이 없는 deprecated 액션에서도 커스텀 아이콘(lucide 검색, SVG 붙여넣기) 추가 가능 +- **근거**: "적절한 아이콘이 없습니다" 문구만 표시하고 아이콘 추가를 완전 차단하면 관리자가 필요한 아이콘을 직접 등록할 방법이 없음. 추천은 없지만 직접 추가는 허용해야 유연성 확보 +- **안내 문구**: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요." + +### 13. 아이콘 모드 레이아웃 안내 문구 + +- **결정**: 아이콘 모드(`"icon"`) 선택 시 "버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다" 안내 표시 +- **근거**: 아이콘 자체는 항상 정사각형(24x24 viewBox)이지만, 디자이너에서 버튼 컨테이너는 가로로 넓은 직사각형이 기본. 아이콘만 넣으면 좌우 여백이 과다해 보이므로 버튼 영역을 줄이라는 안내가 필요. 자동 크기 조정은 기존 레이아웃을 깨뜨릴 위험이 있어 도입하지 않되, 관리자에게 팁을 제공하면 스스로 최적화할 수 있음 +- **표시 조건**: `displayMode === "icon"`일 때만 (아이콘+텍스트 모드는 가로 공간이 필요하므로 해당 안내 불필요) +- **대안 검토**: 자동 정사각형 조정 → 기각 (관리자 수동 레이아웃 파괴 위험) + +### 14. 디폴트 아이콘 자동 부여 + +- **결정**: 아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택이면 디폴트 아이콘을 자동으로 부여. 커스텀 아이콘 삭제 시에도 텍스트 모드로 빠지지 않고 디폴트 아이콘으로 복귀 +- **근거**: 아이콘 모드로 전환했는데 아무것도 안 보이면 "기능이 작동하지 않는다"는 착각을 유발. 또한 커스텀 아이콘을 삭제했을 때 갑자기 텍스트로 빠지면 관리자가 의도치 않은 모드 변경을 경험하게 됨 +- **디폴트 선택 기준**: 해당 액션의 첫 번째 추천 아이콘 (예: save → Check). 추천 아이콘이 없는 액션은 범용 폴백 `SquareMousePointer` 사용 +- **구현**: `getDefaultIconForAction(actionType)` 유틸 함수로 중앙화 (`button-icon-map.tsx`) +- **폴백 아이콘**: `SquareMousePointer` — 마우스 포인터 + 사각형 형태로 "버튼 클릭 동작"을 범용적으로 표현 + +### 15. 아이콘+텍스트 모드에서 텍스트 위치 4방향 지원 + +- **결정**: 아이콘 대비 텍스트 위치를 왼쪽/오른쪽/위쪽/아래쪽 4방향으로 설정 가능 +- **근거**: 기존에는 아이콘 오른쪽에 텍스트 고정이었으나, 세로 배치(위/아래)가 필요한 경우도 존재 (좁고 높은 버튼, 툴바 스타일). 4방향을 제공하면 관리자가 버튼 모양에 맞게 레이아웃 선택 가능 +- **기본값**: `"right"` (아이콘 오른쪽에 텍스트) — 가장 자연스러운 좌→우 읽기 방향 +- **구현**: `flexDirection` (row/column) + 요소 순서 (textFirst) 조합으로 4방향 구현 +- **저장**: `componentConfig.iconTextPosition` +- **표시 조건**: 아이콘+텍스트 모드에서만 표시 (아이콘 모드, 텍스트 모드에서는 숨김) + +### 16. 버튼 컴포넌트 테두리 이중 적용 문제 해결 + +- **결정**: `RealtimePreviewDynamic`의 position wrapper에서 버튼 컴포넌트의 border 속성을 분리(strip) +- **근거**: StyleEditor에서 설정한 border가 (1) position wrapper와 (2) 내부 버튼 요소 두 곳에 모두 적용되어 이중 테두리 발생. border는 내부 버튼(`buttonElementStyle`)에서만 렌더링해야 함 +- **수정 파일**: `RealtimePreviewDynamic.tsx` — `isButtonComponent` 조건에 `v2-button-primary` 추가하여 border strip 대상에 포함 +- **수정 파일**: `ButtonPrimaryComponent.tsx` — 외부 wrapper(`componentStyle`)에서 border 속성 destructure로 제거, `border: "none"` shorthand 대신 개별 longhand 속성으로 변경 (borderStyle 미설정 시 기본 `"solid"` 적용) + +### 17. 커스텀 아이콘 검색은 lucide 전체 목록 기반 + +- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능 +- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수 +- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링 +- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 설정 패널 (수정) | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트/액션 설정 (784~854행에 모드 선택 추가) | +| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 버튼 렌더링 분기 (961~983행) | +| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) | +| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) | +| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) | +| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 | +| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 | + +--- + +## 기술 참고 + +### lucide-react 아이콘 동적 렌더링 + +```typescript +// button-icon-map.ts +import { Check, Save, Trash2, Pencil, ... } from "lucide-react"; + +const iconMap: Record> = { + Check, Save, Trash2, Pencil, ... +}; + +export function renderButtonIcon(name: string, size: string | number) { + const IconComponent = iconMap[name]; + if (!IconComponent) return null; + return ; +} +``` + +### 아이콘 크기 비율 매핑 (버튼 높이 대비 %) + +```typescript +const iconSizePresets: Record = { + "작게": 40, + "보통": 55, + "크게": 70, + "매우 크게": 85, +}; + +// 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백 +export function getIconPercent(size: string | number): number { + if (typeof size === "number") return size; + return iconSizePresets[size] ?? 55; +} + +// 버튼 높이 대비 비율 + 정사각형 유지 +export function getIconSizeStyle(size: string | number): React.CSSProperties { + const pct = getIconPercent(size); + return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" }; +} +``` + +### 외부 SVG 아이콘 렌더링 + +```typescript +import DOMPurify from "dompurify"; + +export function renderSvgIcon(svgString: string, size: string | number) { + const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } }); + return ( + + ); +} +``` + +### 버튼 액션별 추천 아이콘 구조 + +```typescript +const actionIconMap: Record = { + save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"], + delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"], + // ... +}; +``` + +### 현재 버튼 액션 목록 (활성) + +| 값 | 표시명 | 아이콘화 가능 | +|-----|--------|-------------| +| `save` | 저장 | O | +| `delete` | 삭제 | O | +| `edit` | 편집 | O | +| `navigate` | 페이지 이동 | O | +| `modal` | 모달 열기 | O | +| `transferData` | 데이터 전달 | O | +| `excel_download` | 엑셀 다운로드 | O | +| `excel_upload` | 엑셀 업로드 | O | +| `quickInsert` | 즉시 저장 | O | +| `control` | 제어 흐름 | O | +| `barcode_scan` | 바코드 스캔 | O | +| `operation_control` | 운행알림 및 종료 | O | +| `event` | 이벤트 발송 | O | +| `copy` | 복사 (품목코드 초기화) | O | + +### 현재 버튼 액션 목록 (숨김/deprecated) + +| 값 | 표시명 | 아이콘화 가능 | +|-----|--------|-------------| +| `openRelatedModal` | 연관 데이터 버튼 모달 열기 | X (적절한 아이콘 없음) | +| `openModalWithData` | (deprecated) 데이터 전달 + 모달 | X | +| `view_table_history` | 테이블 이력 보기 | X | +| `code_merge` | 코드 병합 | X | +| `empty_vehicle` | 공차등록 | X | diff --git a/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md b/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md new file mode 100644 index 00000000..a02a15b1 --- /dev/null +++ b/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md @@ -0,0 +1,158 @@ +# [체크리스트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장 + +> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [맥락노트](./BIC[맥락]-버튼-아이콘화.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (전 단계 구현 및 검증 완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 아이콘 매핑 파일 생성 + +- [x] `frontend/lib/button-icon-map.tsx` 생성 +- [x] 버튼 액션별 추천 아이콘 매핑 (`actionIconMap`) 정의 (14개 액션 x 6개 아이콘) +- [x] 아이콘 크기 비율 매핑 (`iconSizePresets`) 정의 (작게/보통/크게/매우 크게, 버튼 높이 대비 %) + `getIconSizeStyle()` 유틸 +- [x] lucide 아이콘 동적 렌더링 포함 `getButtonDisplayContent()` 구현 +- [x] SVG 아이콘 렌더링 (DOMPurify 정화 via `isomorphic-dompurify`) +- [x] 아이콘 이름 → 컴포넌트 매핑 객체 (`iconMap`) + `addToIconMap()` 동적 추가 +- [x] deprecated 액션용 안내 문구 상수 (`NO_ICON_MESSAGE`) 정의 +- [x] `isomorphic-dompurify` 기존 설치 확인 (추가 설치 불필요) +- [x] `ButtonIconRenderer` 공용 컴포넌트 추가 (모든 렌더러에서 재사용) +- [x] `getDefaultIconForAction()` 디폴트 아이콘 유틸 함수 추가 (액션별 첫 번째 추천 / 범용 폴백) +- [x] `FALLBACK_ICON_NAME` 상수 + `SquareMousePointer` import/매핑 추가 + +### 2단계: ButtonConfigPanel 수정 + +- [x] 표시 모드 버튼 그룹 UI 추가 (텍스트 / 아이콘 / 아이콘+텍스트) +- [x] `displayMode` 상태 관리 및 `onUpdateProperty` 연동 +- [x] 아이콘 모드 선택 시 조건부 UI 분기 (텍스트 입력 숨김 → 아이콘 선택 표시) +- [x] 아이콘+텍스트 모드 선택 시 아이콘 선택 + 텍스트 입력 **동시** 표시 +- [x] 버튼 액션별 추천 아이콘 그리드 렌더링 (4열 그리드) +- [x] 선택된 아이콘 하이라이트 (`ring-2 ring-primary/30 border-primary`) +- [x] 아이콘 크기 비율 프리셋 버튼 그룹 (작게/보통/크게/매우 크게, 한글 라벨) +- [x] px 직접 입력 필드 제거 (비율 프리셋만 제공) +- [x] `icon.name`, `icon.size` 를 `onUpdateProperty`로 저장 +- [x] 아이콘 색상 컬러 피커 구현 (`ColorPickerWithTransparent` 재사용) +- [x] "텍스트 색상과 동일" 초기화 버튼 구현 +- [x] 텍스트 위치 4방향 설정 추가 (`iconTextPosition`, 왼쪽/오른쪽/위쪽/아래쪽) +- [x] 아이콘-텍스트 간격 설정 추가 (`iconGap`, 슬라이더 + 직접 입력, 아이콘+텍스트 모드 전용) +- [x] 아이콘 모드 레이아웃 안내 문구 표시 (Info 아이콘 + bg-blue-50 박스) +- [x] 액션 변경 시 선택 아이콘 자동 초기화 로직 (추천 목록에 없으면 해제) +- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 버튼 표시 +- [x] 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 디폴트 아이콘 자동 부여 +- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 자동 복귀 (텍스트 모드 전환 방지) + +### 3단계: 커스텀 아이콘 추가/삭제 (lucide 검색) + +- [x] "lucide 검색" 버튼 UI +- [x] lucide 아이콘 검색 팝오버 (Popover + Command + CommandInput) +- [x] `import { icons } from "lucide-react"` 기반 전체 아이콘 검색/필터링 +- [x] 선택 시 `componentConfig.customIcons` 배열 추가 + `addToIconMap` 동적 등록 +- [x] lucide 커스텀 아이콘 그리드 렌더링 (추천 아이콘 아래, 구분선 포함) +- [x] lucide 커스텀 아이콘 X 버튼으로 개별 삭제 + +### 3-1단계: 커스텀 아이콘 추가/삭제 (SVG 붙여넣기) + +- [x] "SVG 붙여넣기" 버튼 UI (Popover) +- [x] SVG 입력 textarea + DOMPurify 실시간 미리보기 +- [x] SVG 유효성 검사 (` 관련 문서: [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md) + +## 개요 + +모든 화면에서 다중 선택 가능한 드롭다운(`V2Select` - `DropdownSelect`)의 선택 항목 표시 방식을 개선합니다. + +--- + +## 현재 동작 + +- 다중 선택 시 `"3개 선택됨"` 같은 텍스트만 표시 +- 어떤 항목이 선택되었는지 드롭다운을 열어야만 확인 가능 + +### 현재 코드 (V2Select.tsx - DropdownSelect, 174~178행) + +```tsx +{selectedLabels.length > 0 + ? multiple + ? `${selectedLabels.length}개 선택됨` + : selectedLabels[0] + : placeholder} +``` + +--- + +## 변경 후 동작 + +### 1. 선택된 항목 라벨을 쉼표로 연결하여 한 줄로 표시 + +- 예: `"구매품, 판매품, 재고품"` +- `truncate` (text-overflow: ellipsis)로 필드 너비를 넘으면 말줄임(`...`) 처리 +- 무조건 한 줄 표시, 넘치면 `...`으로 숨김 + +### 2. 텍스트가 말줄임(`...`) 처리될 때만 호버 툴팁 표시 + +- 필드 너비를 넘어서 `...`으로 잘릴 때만 툴팁 활성화 +- 필드 내에 전부 보이면 툴팁 불필요 +- 툴팁 내용은 세로 나열로 각 항목을 한눈에 확인 가능 +- 툴팁은 딜레이 없이 즉시 표시 + +--- + +## 시각적 동작 예시 + +| 상태 | 필드 내 표시 | 호버 시 툴팁 | +|------|-------------|-------------| +| 미선택 | `선택` (placeholder) | 없음 | +| 1개 선택 | `구매품` | 없음 | +| 3개 선택 (필드 내 수용) | `구매품, 판매품, 재고품` | 없음 (잘리지 않으므로) | +| 5개 선택 (필드 넘침) | `구매품, 판매품, 재고품, 외...` | 구매품 / 판매품 / 재고품 / 외주품 / 반제품 (세로 나열) | + +--- + +## 변경 대상 + +- **파일**: `frontend/components/v2/V2Select.tsx` +- **컴포넌트**: `DropdownSelect` 내부 표시 텍스트 부분 (170~178행) +- **적용 범위**: `DropdownSelect`를 사용하는 모든 화면 (품목정보, 기타 모든 모달 포함) +- **변경 규모**: 약 30줄 내외 소규모 변경 + +--- + +## 코드 설계 + +### 추가 import + +```tsx +import { useRef, useEffect, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +``` + +### 말줄임 감지 로직 + +```tsx +// 텍스트가 잘리는지(truncated) 감지 +const textRef = useRef(null); +const [isTruncated, setIsTruncated] = useState(false); + +useEffect(() => { + const el = textRef.current; + if (el) { + setIsTruncated(el.scrollWidth > el.clientWidth); + } +}, [selectedLabels]); +``` + +### 수정 코드 (DropdownSelect 내부, 170~178행 대체) + +```tsx +const displayText = selectedLabels.length > 0 + ? (multiple ? selectedLabels.join(", ") : selectedLabels[0]) + : placeholder; + +const isPlaceholder = selectedLabels.length === 0; + +// 렌더링 부분 +{isTruncated && multiple ? ( + + + + + {displayText} + + + +
+ {selectedLabels.map((label, i) => ( +
{label}
+ ))} +
+
+
+
+) : ( + + {displayText} + +)} +``` + +--- + +## 설계 원칙 + +- 기존 단일 선택 동작은 변경하지 않음 +- `DropdownSelect` 공통 컴포넌트 수정이므로 모든 화면에 자동 적용 +- 무조건 한 줄 표시, 넘치면 `...`으로 말줄임 +- 툴팁은 텍스트가 실제로 잘릴 때(`scrollWidth > clientWidth`)만 표시 +- 툴팁 내용은 세로 나열로 각 항목 확인 용이 +- 툴팁 딜레이 없음 (`delayDuration={0}`) +- shadcn 표준 Tooltip 컴포넌트 사용으로 프로젝트 일관성 유지 diff --git a/docs/ycshin-node/MST[맥락]-다중선택-라벨표시.md b/docs/ycshin-node/MST[맥락]-다중선택-라벨표시.md new file mode 100644 index 00000000..155dde59 --- /dev/null +++ b/docs/ycshin-node/MST[맥락]-다중선택-라벨표시.md @@ -0,0 +1,95 @@ +# [맥락노트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선 + +> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md) + +--- + +## 왜 이 작업을 하는가 + +- 사용자가 수정 모달에서 다중 선택 드롭다운을 사용할 때 `"3개 선택됨"` 만 보임 +- 드롭다운을 다시 열어봐야만 무엇이 선택됐는지 확인 가능 → UX 불편 +- 선택 항목을 직접 보여주고, 넘치면 툴팁으로 확인할 수 있게 개선 + +--- + +## 핵심 결정 사항과 근거 + +### 1. "n개 선택됨" → 라벨 쉼표 나열 + +- **결정**: `"구매품, 판매품, 재고품"` 형태로 표시 +- **근거**: 사용자가 드롭다운을 열지 않아도 선택 내용을 바로 확인 가능 + +### 2. 무조건 한 줄, 넘치면 말줄임(`...`) + +- **결정**: 여러 줄 줄바꿈 없이 한 줄 고정, `truncate`로 오버플로우 처리 +- **근거**: 드롭다운 필드 높이가 고정되어 있어 여러 줄 표시 시 레이아웃이 깨짐 + +### 3. 텍스트가 잘릴 때만 툴팁 표시 + +- **결정**: `scrollWidth > clientWidth` 비교로 실제 잘림 여부 감지 후 툴팁 활성화 +- **근거**: 전부 보이는데 툴팁이 뜨면 오히려 방해. 필요할 때만 보여야 함 +- **대안 검토**: "2개 이상이면 항상 툴팁" → 기각 (불필요한 툴팁 발생) + +### 4. 툴팁 내용은 세로 나열 + +- **결정**: 툴팁 안에서 항목을 줄바꿈으로 세로 나열 +- **근거**: 가로 나열 시 툴팁도 길어져서 읽기 어려움. 세로가 한눈에 파악하기 좋음 + +### 5. 툴팁 딜레이 0ms + +- **결정**: `delayDuration={0}` 즉시 표시 +- **근거**: 사용자가 "무엇을 선택했는지" 확인하려는 의도적 행동이므로 즉시 응답해야 함 + +### 6. Radix Tooltip 대신 커스텀 호버 툴팁 사용 + +- **결정**: Radix Tooltip을 사용하지 않고 `onMouseEnter`/`onMouseLeave`로 직접 제어 +- **근거**: Radix Tooltip + Popover 조합은 이벤트 충돌 발생. 내부 배치든 외부 래핑이든 Popover가 호버를 가로챔 +- **시도 1**: Tooltip을 Button 안에 배치 → Popover가 이벤트 가로챔 (실패) +- **시도 2**: Radix 공식 패턴 (TooltipTrigger > PopoverTrigger > Button 체이닝) → 여전히 동작 안 함 (실패) +- **최종**: wrapper div에 마우스 이벤트 + 절대 위치 div로 툴팁 렌더링 (성공) +- **추가**: Popover 열릴 때 `setHoverTooltip(false)`로 툴팁 자동 숨김 + +### 8. 선택 항목 표시 순서는 드롭다운 옵션 순서 기준 + +- **결정**: 사용자가 클릭한 순서가 아닌 드롭다운 옵션 목록 순서대로 표시 +- **근거**: 선택 순서대로 보여주면 매번 순서가 달라져서 혼란. 옵션 순서 기준이 일관적이고 예측 가능 +- **구현**: `selectedValues.map(...)` → `safeOptions.filter(...).map(...)` 으로 변경 + +### 9. DropdownSelect 공통 컴포넌트 수정 + +- **결정**: 특정 화면이 아닌 `DropdownSelect` 자체를 수정 +- **근거**: 품목정보뿐 아니라 모든 화면에서 동일한 문제가 있으므로 공통 해결 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `frontend/components/v2/V2Select.tsx` | DropdownSelect 컴포넌트 (170~178행) | +| 타입 정의 | `frontend/types/v2-components.ts` | V2SelectProps, SelectOption 타입 | +| UI 컴포넌트 | `frontend/components/ui/tooltip.tsx` | shadcn Tooltip 컴포넌트 | +| 렌더러 | `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` | V2Select를 레지스트리에 연결 | +| 수정 모달 | `frontend/components/screen/EditModal.tsx` | 공통 편집 모달 | + +--- + +## 기술 참고 + +### truncate 감지 방식 + +``` +scrollWidth: 텍스트의 실제 전체 너비 (보이지 않는 부분 포함) +clientWidth: 요소의 보이는 너비 + +scrollWidth > clientWidth → 텍스트가 잘리고 있음 (... 표시 중) +``` + +### selectedLabels 계산 흐름 + +``` +value (string[]) → selectedValues → safeOptions에서 label 매칭 → selectedLabels (string[]) +``` + +- `selectedLabels`는 이미 `DropdownSelect` 내부에서 `useMemo`로 계산됨 (126~130행) +- 추가 데이터 fetching 불필요, 기존 값을 `.join(", ")`로 결합하면 됨 diff --git a/docs/ycshin-node/MST[체크]-다중선택-라벨표시.md b/docs/ycshin-node/MST[체크]-다중선택-라벨표시.md new file mode 100644 index 00000000..c3599343 --- /dev/null +++ b/docs/ycshin-node/MST[체크]-다중선택-라벨표시.md @@ -0,0 +1,54 @@ +# [체크리스트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선 + +> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 전체 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 코드 수정 + +- [x] `V2Select.tsx`에 Tooltip 관련 import 추가 +- [x] `DropdownSelect` 내부에 `textRef`, `isTruncated` 상태 추가 +- [x] `useEffect`로 `scrollWidth > clientWidth` 감지 로직 추가 +- [x] 표시 텍스트를 `selectedLabels.join(", ")`로 변경 +- [x] `isTruncated && multiple` 조건으로 Tooltip 래핑 +- [x] 툴팁 내용을 세로 나열 (`space-y-0.5`)로 구성 +- [x] `delayDuration={0}` 설정 +- [x] Radix Tooltip → 커스텀 호버 툴팁으로 변경 (onMouseEnter/onMouseLeave + 절대 위치 div) +- [x] 선택 항목 표시 순서를 드롭다운 옵션 순서 기준으로 변경 + +### 2단계: 검증 + +- [x] 단일 선택 모드: 기존 동작 변화 없음 확인 +- [x] 다중 선택 1개: 라벨 정상 표시, 툴팁 없음 +- [x] 다중 선택 3개 (필드 내 수용): 쉼표 나열 표시, 툴팁 없음 +- [x] 다중 선택 5개+ (필드 넘침): 말줄임 표시, 호버 시 툴팁 세로 나열 +- [x] 품목정보 수정 모달에서 동작 확인 +- [x] 다른 화면의 다중 선택 드롭다운에서도 동작 확인 + +### 3단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 이 체크리스트 완료 표시 업데이트 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-04 | 설계 문서 작성 완료 | +| 2026-03-04 | 맥락노트, 체크리스트 작성 완료 | +| 2026-03-04 | 파일명 MST 접두사 적용 | +| 2026-03-04 | 1단계 코드 수정 완료 (V2Select.tsx) | +| 2026-03-04 | Radix Tooltip이 Popover와 충돌 → 커스텀 호버 툴팁으로 변경 | +| 2026-03-04 | 사용자 검증 완료, 전체 작업 완료 | +| 2026-03-04 | 선택 항목 표시 순서를 옵션 순서 기준으로 변경 | diff --git a/docs/ycshin-node/탭_시스템_설계.md b/docs/ycshin-node/탭_시스템_설계.md new file mode 100644 index 00000000..50ca2468 --- /dev/null +++ b/docs/ycshin-node/탭_시스템_설계.md @@ -0,0 +1,241 @@ +# 탭 시스템 아키텍처 및 구현 계획 + +## 1. 개요 + +사이드바 메뉴 클릭 시 `router.push()` 페이지 이동 방식에서 **탭 기반 멀티 화면 시스템**으로 전환한다. + +``` + ┌──────────────────────────┐ + │ Tab Data Layer (중앙) │ + API 응답 ────────→│ │ + │ 탭별 상태 저장소 │ + │ ├─ formData │ + │ ├─ selectedRows │ + │ ├─ scrollPosition │ + │ ├─ modalState │ + │ ├─ sortState │ + │ └─ cacheState │ + │ │ + │ 공통 규칙 엔진 │ + │ ├─ 날짜 포맷 규칙 │ + │ ├─ 숫자/통화 포맷 규칙 │ + │ ├─ 로케일 처리 규칙 │ + │ ├─ 유효성 검증 규칙 │ + │ └─ 데이터 타입 변환 규칙 │ + │ │ + │ F5 복원 / 캐시 관리 │ + │ (sessionStorage 중앙관리) │ + └────────────┬─────────────┘ + │ + 가공 완료된 데이터 + │ + ┌────────────────┼────────────────┐ + │ │ │ + 화면 A (경량) 화면 B (경량) 화면 C (경량) + 렌더링만 담당 렌더링만 담당 렌더링만 담당 +``` + +## 2. 레이어 구조 + +| 레이어 | 책임 | +|---|---| +| **Tab Data Layer** | 탭별 상태 보관, 캐시, 복원, 데이터 가공 | +| **공통 규칙 엔진** | 날짜/숫자/로케일 포맷, 유효성 검증 | +| **화면 컴포넌트** | 가공된 데이터를 받아서 렌더링만 담당 | + +## 3. 파일 구성 + +| 파일 | 역할 | +|---|---| +| `stores/tabStore.ts` | Zustand 기반 탭 상태 관리 | +| `components/layout/TabBar.tsx` | 탭 바 UI (드래그, 우클릭, 오버플로우) | +| `components/layout/TabContent.tsx` | 탭별 콘텐츠 렌더링 (컨테이너) | +| `components/layout/EmptyDashboard.tsx` | 탭 없을 때 안내 화면 | +| `components/layout/AppLayout.tsx` | 전체 레이아웃 (사이드바 + 탭 + 콘텐츠) | +| `lib/tabStateCache.ts` | 탭별 상태 캐싱 엔진 | +| `lib/formatting/rules.ts` | 포맷 규칙 정의 | +| `lib/formatting/index.ts` | formatDate, formatNumber, formatCurrency | +| `app/(main)/screens/[screenId]/page.tsx` | 화면별 렌더링 | + +## 4. 기술 스택 + +- Next.js 15, React 19, Zustand +- Tailwind CSS, shadcn/ui + +--- + +## 5. Phase 1: 탭 껍데기 + +### 5-1. Zustand 탭 Store (`stores/tabStore.ts`) +- [ ] zustand 직접 의존성 추가 +- [ ] Tab 인터페이스: id, type, title, screenId, menuObjid, adminUrl +- [ ] 탭 목록, 활성 탭 ID +- [ ] openTab, closeTab, switchTab, refreshTab +- [ ] closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs +- [ ] updateTabOrder (드래그 순서 변경) +- [ ] 중복 방지: 같은 탭이면 해당 탭으로 이동 +- [ ] 닫기 후 왼쪽 탭으로 이동, 왼쪽 없으면 오른쪽 +- [ ] sessionStorage 영속화 (persist middleware) +- [ ] 탭 ID 생성 규칙: V2 화면 `tab-{screenId}-{menuObjid}`, URL 탭 `tab-url-{menuObjid}` + +### 5-2. TabBar 컴포넌트 (`components/layout/TabBar.tsx`) +- [ ] 고정 너비 탭, 화면 너비에 맞게 동적 개수 +- [ ] 활성 탭: 새로고침 버튼 + X 버튼 +- [ ] 비활성 탭: X 버튼만 +- [ ] 오버플로우 시 +N 드롭다운 (ResizeObserver 감시) +- [ ] 드래그 순서 변경 (mousedown/move/up, DOM transform 직접 조작) +- [ ] 사이드바 메뉴 드래그 드롭 수신 (`application/tab-menu` 커스텀 데이터, 마우스 위치에 삽입) +- [ ] 우클릭 컨텍스트 메뉴 (새로고침/왼쪽닫기/오른쪽닫기/다른탭닫기/모든탭닫기) +- [ ] 휠 클릭: 탭 즉시 닫기 + +### 5-3. TabContent 컴포넌트 (`components/layout/TabContent.tsx`) +- [ ] display:none 방식 (비활성 탭 DOM 유지, 상태 보존) +- [ ] 지연 마운트 (한 번 활성화된 탭만 마운트) +- [ ] 안정적 순서 유지 (탭 순서 변경 시 리마운트 방지) +- [ ] 탭별 모달 격리 (DialogPortalContainerContext) +- [ ] tab.type === "screen" -> ScreenViewPageWrapper 임베딩 +- [ ] tab.type === "admin" -> 동적 import로 관리자 페이지 렌더링 + +### 5-4. EmptyDashboard 컴포넌트 (`components/layout/EmptyDashboard.tsx`) +- [ ] 탭이 없을 때 "사이드바에서 메뉴를 선택하여 탭을 추가하세요" 표시 + +### 5-5. AppLayout 수정 (`components/layout/AppLayout.tsx`) +- [ ] handleMenuClick: router.push -> tabStore.openTab 호출 +- [ ] 레이아웃: main 영역을 TabBar + TabContent로 교체 +- [ ] children prop 제거 (탭이 콘텐츠 관리) +- [ ] 사이드바 메뉴 드래그 가능하게 (draggable) + +### 5-6. 라우팅 연동 +- [ ] `app/(main)/layout.tsx` 수정 - children 대신 탭 시스템 +- [ ] URL 직접 접근 시 탭으로 열기 (북마크/공유 링크 대응) + +--- + +## 6. Phase 2: F5 최대 복원 + +### 6-1. 탭 상태 캐싱 엔진 (`lib/tabStateCache.ts`) +- [ ] 탭별 상태 저장/복원 (sessionStorage) +- [ ] 저장 대상: formData, selectedRows, sortState, scrollPosition, modalState, checkboxState +- [ ] debounce 적용 (상태 변경마다 저장하지 않음) + +### 6-2. 복원 로직 +- [ ] 활성 탭: fresh API 호출 (캐시 데이터 무시) +- [ ] 비활성 탭: 캐시에서 복원 +- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제 + +### 6-3. 캐시 키 관리 (clearTabStateCache) + +탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거: +- `tab-cache-{screenId}-{menuObjid}` +- `page-scroll-{screenId}-{menuObjid}` +- `tsp-{screenId}-*`, `table-state-{screenId}-*` +- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*` +- `bom-tree-{screenId}-*` +- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}` + +--- + +## 7. Phase 3: 포맷팅 중앙화 + +### 7-1. 포맷팅 규칙 엔진 + +```typescript +// lib/formatting/rules.ts + +interface FormatRules { + date: { + display: string; // "YYYY-MM-DD" + datetime: string; // "YYYY-MM-DD HH:mm:ss" + input: string; // "YYYY-MM-DD" + }; + number: { + locale: string; // 사용자 로케일 기반 + decimals: number; // 기본 소수점 자릿수 + }; + currency: { + code: string; // 회사 설정 기반 + locale: string; + }; +} + +export function formatValue(value: any, dataType: string, rules: FormatRules): string; +export function formatDate(value: any, format?: string): string; +export function formatNumber(value: any, locale?: string): string; +export function formatCurrency(value: any, currencyCode?: string): string; +``` + +### 7-2. 하드코딩 교체 대상 +- [ ] V2DateRenderer.tsx +- [ ] EditModal.tsx +- [ ] InteractiveDataTable.tsx +- [ ] FlowWidget.tsx +- [ ] AggregationWidgetComponent.tsx +- [ ] aggregation.ts (피벗) +- [ ] 기타 하드코딩 파일들 + +--- + +## 8. Phase 4: ScreenViewPage 경량화 +- [ ] 탭 데이터 레이어에서 받은 데이터로 렌더링만 담당 +- [ ] API 호출, 캐시, 복원 로직 제거 (탭 레이어가 담당) +- [ ] 관리자 페이지도 동일한 데이터 레이어 패턴 적용 + +--- + +--- + +## 구현 완료: 다중 스크롤 영역 F5 복원 + +### 개요 + +split panel 등 한 탭 안에 **스크롤 영역이 여러 개**인 화면에서, F5 새로고침 후에도 각 영역의 스크롤 위치가 복원된다. + +탭 전환 시에는 `display: none` 방식으로 DOM이 유지되므로 브라우저가 스크롤을 자연 보존한다. 이 기능은 **F5 새로고침** 전용이다. + +### 동작 방식 + +탭 내 모든 스크롤 가능한 요소를 DOM 경로(`"0/1/0/2"` 형태)와 함께 저장한다. + +``` +scrollPositions: [ + { path: "0/1/0/2", top: 150, left: 0 }, // 예: 좌측 패널 + { path: "0/1/1/3/1", top: 420, left: 0 }, // 예: 우측 패널 +] +``` + +- **실시간 추적**: 스크롤 이벤트 발생 시 해당 요소의 경로와 위치를 Map에 기록 +- **저장 시점**: 탭 전환 시 + `beforeunload`(F5/닫기) 시 sessionStorage에 저장 +- **복원 시점**: 탭 활성화 시 경로를 기반으로 각 요소를 찾아 개별 복원 + +### 관련 파일 및 주요 함수 + +| 파일 | 역할 | +|---|---| +| `lib/tabStateCache.ts` | 스크롤 캡처/복원 핵심 로직 | +| `components/layout/TabContent.tsx` | 스크롤 이벤트 감지, 저장/복원 호출 | + +**`tabStateCache.ts` 핵심 함수**: + +| 함수 | 설명 | +|---|---| +| `getElementPath(element, container)` | 요소의 DOM 경로를 자식 인덱스 문자열로 생성 | +| `captureAllScrollPositions(container)` | TreeWalker로 컨테이너 하위 모든 스크롤 요소의 위치를 일괄 캡처 | +| `restoreAllScrollPositions(container, positions)` | 경로 기반으로 각 요소를 찾아 스크롤 위치 복원 (콘텐츠 렌더링 대기 폴링 포함) | + +**`TabContent.tsx` 핵심 Ref**: + +| Ref | 설명 | +|---|---| +| `lastScrollMapRef` | `Map>` - 탭 내 요소별 최신 스크롤 위치 | +| `pathCacheRef` | `WeakMap` - 동일 요소의 경로 재계산 방지용 캐시 | + +--- + +## 9. 참고 파일 + +| 파일 | 비고 | +|---|---| +| `frontend/components/layout/AppLayout.tsx` | 사이드바 + 콘텐츠 레이아웃 | +| `frontend/app/(main)/screens/[screenId]/page.tsx` | 화면 렌더링 (건드리지 않음) | +| `frontend/stores/modalDataStore.ts` | Zustand store 참고 패턴 | +| `frontend/lib/adminPageRegistry.tsx` | 관리자 페이지 레지스트리 | diff --git a/docs/ycshin-node/필수입력항목_자동검증_설계.md b/docs/ycshin-node/필수입력항목_자동검증_설계.md new file mode 100644 index 00000000..1edb53dd --- /dev/null +++ b/docs/ycshin-node/필수입력항목_자동검증_설계.md @@ -0,0 +1,231 @@ +# 모달 필수 입력 검증 설계 + +## 1. 목표 + +모든 모달에서 필수 입력값이 빈 상태로 저장 버튼을 클릭하면: +- 첫 번째 빈 필수 필드로 포커스 이동 + 하이라이트 +- 우측 상단에 토스트 알림 ("○○ 항목을 입력해주세요") +- 버튼은 항상 활성 상태 (비활성화하지 않음) + +--- + +## 2. 전체 구조 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DialogContent (모든 모달의 공통 래퍼) │ +│ │ +│ useDialogAutoValidation(contentRef) │ +│ │ │ +│ ├─ 0단계: 모드 확인 │ +│ │ └─ useTabStore.mode === "user" 일 때만 실행 │ +│ │ │ +│ ├─ 1단계: 필수 필드 탐지 │ +│ │ └─ Label 내부 안에 * 문자 존재 여부 │ +│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │ +│ │ │ +│ └─ 2단계: 저장 버튼 클릭 인터셉트 │ +│ │ │ +│ ├─ 저장/수정/확인 버튼 클릭 감지 │ +│ │ (data-action-type="save"/"submit" │ +│ │ 또는 data-variant="default") │ +│ │ │ +│ ├─ 빈 필수 필드 있음: │ +│ │ ├─ 클릭 이벤트 차단 (stopPropagation + preventDefault) │ +│ │ ├─ 첫 번째 빈 필드로 포커스 이동 │ +│ │ ├─ 해당 필드 빨간 테두리 + 하이라이트 애니메이션 │ +│ │ └─ 토스트 알림: "{필드명} 항목을 입력해주세요" │ +│ │ │ +│ └─ 모든 필수 필드 입력됨: │ +│ └─ 클릭 이벤트 통과 (정상 저장 진행) │ +│ │ +│ 제외 조건: │ +│ └─ 필수 필드가 0개인 모달 → 인터셉트 없음 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 필수 필드 감지: span 기반 * 감지 + +### 원리 + +화면 관리에서 필드를 "필수"로 체크하면 `component.required = true`가 저장된다. +V2 컴포넌트가 렌더링할 때 `required = true`이면 Label 안에 `*`을 추가한다. +훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다. + +### 오탐 방지 + +관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다. + +``` +required = true → + → span 안에 * 있음 → 감지 O + +required = false → + → span 없음 → 감지 X + +라벨에 * 직접 입력 → + → span 없이 텍스트에 * → 감지 X (오탐 방지) +``` + +### 지원 필드 타입 + +| V2 컴포넌트 | 렌더링 요소 | 빈값 판정 | +|---|---|---| +| V2Input | ``, `