diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md deleted file mode 100644 index 6b4ff99c..00000000 --- a/.cursor/agents/pipeline-backend.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -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 deleted file mode 100644 index 57049ce6..00000000 --- a/.cursor/agents/pipeline-common-rules.md +++ /dev/null @@ -1,182 +0,0 @@ -# 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 deleted file mode 100644 index 33e25218..00000000 --- a/.cursor/agents/pipeline-db.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -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 deleted file mode 100644 index 223b5b38..00000000 --- a/.cursor/agents/pipeline-frontend.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -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 deleted file mode 100644 index 05d3359e..00000000 --- a/.cursor/agents/pipeline-ui.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -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 deleted file mode 100644 index a4f4186d..00000000 --- a/.cursor/agents/pipeline-verifier.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -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/rules/screen-designer-e2e-guide.mdc b/.cursor/rules/screen-designer-e2e-guide.mdc new file mode 100644 index 00000000..e52ec2dd --- /dev/null +++ b/.cursor/rules/screen-designer-e2e-guide.mdc @@ -0,0 +1,98 @@ +# 화면 디자이너 E2E 테스트 접근 가이드 + +## 화면 디자이너 접근 방법 (Playwright) + +화면 디자이너는 SPA 탭 기반 시스템이라 URL 직접 접근이 안 된다. +다음 3단계를 반드시 따라야 한다. + +### 1단계: 로그인 + +```typescript +await page.goto('http://localhost:9771/login'); +await page.waitForLoadState('networkidle'); +await page.getByPlaceholder('사용자 ID를 입력하세요').fill('wace'); +await page.getByPlaceholder('비밀번호를 입력하세요').fill('qlalfqjsgh11'); +await page.getByRole('button', { name: '로그인' }).click(); +await page.waitForTimeout(8000); +``` + +### 2단계: sessionStorage 탭 상태 주입 + openDesigner 쿼리 + +```typescript +await page.evaluate(() => { + sessionStorage.setItem('erp-tab-store', JSON.stringify({ + state: { + tabs: [{ + id: 'tab-screenmng', + title: '화면 관리', + path: '/admin/screenMng/screenMngList', + isActive: true, + isPinned: false + }], + activeTabId: 'tab-screenmng' + }, + version: 0 + })); +}); + +// openDesigner 쿼리 파라미터로 화면 디자이너 자동 열기 +await page.goto('http://localhost:9771/admin/screenMng/screenMngList?openDesigner=' + screenId); +await page.waitForTimeout(10000); +``` + +### 3단계: 컴포넌트 클릭 + 설정 패널 확인 + +```typescript +// 패널 버튼 클릭 (설정 패널 열기) +const panelBtn = page.locator('button:has-text("패널")'); +if (await panelBtn.count() > 0) { + await panelBtn.first().click(); + await page.waitForTimeout(2000); +} + +// 편집 탭 확인 +const editTab = page.locator('button:has-text("편집")'); +// editTab.count() > 0 이면 설정 패널 열림 확인 +``` + +## 화면 ID 찾기 (API) + +특정 컴포넌트를 포함한 화면을 API로 검색: + +```typescript +const screenId = await page.evaluate(async () => { + const token = localStorage.getItem('authToken') || ''; + const h = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; + + const resp = await fetch('http://localhost:8080/api/screen-management/screens?page=1&size=50', { headers: h }); + const data = await resp.json(); + const items = data.data || []; + + for (const s of items) { + try { + const lr = await fetch('http://localhost:8080/api/screen-management/screens/' + s.screenId + '/layout-v2', { headers: h }); + const ld = await lr.json(); + const raw = JSON.stringify(ld); + // 원하는 컴포넌트 타입 검색 + if (raw.includes('v2-select')) return s.screenId; + } catch {} + } + return items[0]?.screenId || null; +}); +``` + +## 검증 포인트 + +| 확인 항목 | Locator | 기대값 | +|----------|---------|--------| +| 디자이너 열림 | `button:has-text("패널")` | count > 0 | +| 편집 탭 | `button:has-text("편집")` | count > 0 | +| 카드 선택 | `text=이 필드는 어떤 데이터를 선택하나요?` | visible | +| 고급 설정 | `text=고급 설정` | visible | +| JS 에러 없음 | `page.on('pageerror')` | 0건 | + +## 테스트 계정 + +- ID: `wace` +- PW: `qlalfqjsgh11` +- 권한: SUPER_ADMIN (최고 관리자) diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index e72f6b9f..c3eeb736 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon routingTable = "item_routing_version", routingFkColumn = "item_code", search = "", + extraColumns = "", + filterConditions = "", } = req.query as Record; - const searchCondition = search - ? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)` - : ""; const params: any[] = [companyCode]; - if (search) params.push(`%${search}%`); + let paramIndex = 2; + + // 검색 조건 + let searchCondition = ""; + if (search) { + searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + // 추가 컬럼 SELECT + const extraColumnNames: string[] = extraColumns + ? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean) + : []; + const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", "); + const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", "); + + // 사전 필터 조건 + let filterWhere = ""; + if (filterConditions) { + try { + const filters = JSON.parse(filterConditions) as Array<{ + column: string; + operator: string; + value: string; + }>; + for (const f of filters) { + if (!f.column || !f.value) continue; + if (f.operator === "equals") { + filterWhere += ` AND i.${f.column} = $${paramIndex}`; + params.push(f.value); + } else if (f.operator === "contains") { + filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`; + params.push(`%${f.value}%`); + } else if (f.operator === "not_equals") { + filterWhere += ` AND i.${f.column} != $${paramIndex}`; + params.push(f.value); + } + paramIndex++; + } + } catch { /* 파싱 실패 시 무시 */ } + } const query = ` SELECT i.id, i.${nameColumn} AS item_name, - i.${codeColumn} AS item_code, + i.${codeColumn} AS item_code + ${extraSelect ? ", " + extraSelect : ""}, COUNT(rv.id) AS routing_count FROM ${tableName} i LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} AND rv.company_code = i.company_code WHERE i.company_code = $1 ${searchCondition} - GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date + ${filterWhere} + GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date ORDER BY i.created_date DESC NULLS LAST `; @@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { client.release(); } } + +// ============================================================ +// 등록 품목 관리 (item_routing_registered) +// ============================================================ + +/** + * 화면별 등록된 품목 목록 조회 + */ +export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode } = req.params; + const { + tableName = "item_info", + nameColumn = "item_name", + codeColumn = "item_number", + routingTable = "item_routing_version", + routingFkColumn = "item_code", + search = "", + extraColumns = "", + } = req.query as Record; + + const params: any[] = [companyCode, screenCode]; + let paramIndex = 3; + + let searchCondition = ""; + if (search) { + searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const extraColumnNames: string[] = extraColumns + ? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean) + : []; + const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", "); + const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", "); + + const query = ` + SELECT + irr.id AS registered_id, + irr.sort_order, + i.id, + i.${nameColumn} AS item_name, + i.${codeColumn} AS item_code + ${extraSelect ? ", " + extraSelect : ""}, + COUNT(rv.id) AS routing_count + FROM item_routing_registered irr + JOIN ${tableName} i ON irr.item_id = i.id + AND i.company_code = irr.company_code + LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} + AND rv.company_code = i.company_code + WHERE irr.company_code = $1 + AND irr.screen_code = $2 + ${searchCondition} + GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""} + ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC + `; + + const result = await getPool().query(query, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("등록 품목 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 품목 등록 (화면에 품목 추가) + */ +export async function registerItem(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode, itemId, itemCode } = req.body; + if (!screenCode || !itemId) { + return res.status(400).json({ success: false, message: "screenCode, itemId 필수" }); + } + + const query = ` + INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (screen_code, item_id, company_code) DO NOTHING + RETURNING * + `; + const result = await getPool().query(query, [ + screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null, + ]); + + if (result.rowCount === 0) { + return res.json({ success: true, message: "이미 등록된 품목입니다", data: null }); + } + + logger.info("품목 등록", { companyCode, screenCode, itemId }); + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("품목 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 여러 품목 일괄 등록 + */ +export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode, items } = req.body; + if (!screenCode || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "screenCode, items[] 필수" }); + } + + const client = await getPool().connect(); + try { + await client.query("BEGIN"); + const inserted: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (screen_code, item_id, company_code) DO NOTHING + RETURNING *`, + [screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null] + ); + if (result.rows[0]) inserted.push(result.rows[0]); + } + + await client.query("COMMIT"); + logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length }); + return res.json({ success: true, data: inserted }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("품목 일괄 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 등록 품목 제거 + */ +export async function unregisterItem(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 getPool().query( + `DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" }); + } + + logger.info("등록 품목 제거", { companyCode, id }); + return res.json({ success: true }); + } catch (error: any) { + logger.error("등록 품목 제거 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 7630b359..c613d55f 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail); // 전체 저장 (일괄) router.put("/save-all", ctrl.saveAll); +// 등록 품목 관리 (화면별 품목 목록) +router.get("/registered-items/:screenCode", ctrl.getRegisteredItems); +router.post("/registered-items", ctrl.registerItem); +router.post("/registered-items/batch", ctrl.registerItemsBatch); +router.delete("/registered-items/:id", ctrl.unregisterItem); + export default router; diff --git a/docs/POP_작업진행_설계서.md b/docs/POP_작업진행_설계서.md new file mode 100644 index 00000000..3b77ddc5 --- /dev/null +++ b/docs/POP_작업진행_설계서.md @@ -0,0 +1,620 @@ +# POP 작업진행 관리 설계서 + +> 작성일: 2026-03-13 +> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계 + +--- + +## 1. 핵심 설계 원칙 + +**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.** + +- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분 +- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회 +- 작업 진행 상태만 별도 테이블에서 관리 + +--- + +## 2. 기존 테이블 구조 (마스터 데이터) + +### 2-1. ER 다이어그램 + +> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다. + +```mermaid +erDiagram + %% ========== 마스터 데이터 (변경 없음) ========== + + item_info { + varchar id PK "UUID" + varchar item_number "품번" + varchar item_name "품명" + varchar company_code "회사코드" + } + + item_routing_version { + varchar id PK "UUID" + varchar item_code "품번 (= item_info.item_number)" + varchar version_name "버전명" + boolean is_default "기본버전 여부" + varchar company_code "회사코드" + } + + item_routing_detail { + varchar id PK "UUID" + varchar routing_version_id FK "→ item_routing_version.id" + varchar seq_no "공정순서 10,20,30..." + varchar process_code FK "→ process_mng.process_code" + varchar is_required "필수/선택" + varchar is_fixed_order "고정/선택" + varchar standard_time "표준시간(분)" + varchar company_code "회사코드" + } + + process_mng { + varchar id PK "UUID" + varchar process_code "공정코드" + varchar process_name "공정명" + varchar process_type "공정유형" + varchar company_code "회사코드" + } + + process_work_item { + varchar id PK "UUID" + varchar routing_detail_id FK "→ item_routing_detail.id" + varchar work_phase "PRE / IN / POST" + varchar title "작업항목명" + varchar is_required "Y/N" + int sort_order "정렬순서" + varchar company_code "회사코드" + } + + process_work_item_detail { + varchar id PK "UUID" + varchar work_item_id FK "→ process_work_item.id" + varchar detail_type "check/inspect/input/procedure/info" + varchar content "내용" + varchar input_type "입력타입" + varchar inspection_code "검사코드" + varchar unit "단위" + varchar lower_limit "하한값" + varchar upper_limit "상한값" + varchar company_code "회사코드" + } + + %% ========== 트랜잭션 데이터 ========== + + work_instruction { + varchar id PK "UUID" + varchar work_instruction_no "작업지시번호" + varchar item_id FK "→ item_info.id ★핵심★" + varchar status "waiting/in_progress/completed/cancelled" + varchar qty "지시수량" + varchar completed_qty "완성수량" + varchar worker "작업자" + varchar company_code "회사코드" + } + + work_order_process { + varchar id PK "UUID" + varchar wo_id FK "→ work_instruction.id" + varchar routing_detail_id FK "→ item_routing_detail.id ★추가★" + varchar seq_no "공정순서" + varchar process_code "공정코드" + varchar process_name "공정명" + varchar status "waiting/in_progress/completed/skipped" + varchar plan_qty "계획수량" + varchar good_qty "양품수량" + varchar defect_qty "불량수량" + timestamp started_at "시작시간" + timestamp completed_at "완료시간" + varchar company_code "회사코드" + } + + work_order_work_item { + varchar id PK "UUID ★신규★" + varchar company_code "회사코드" + varchar work_order_process_id FK "→ work_order_process.id" + varchar work_item_id FK "→ process_work_item.id" + varchar work_phase "PRE/IN/POST" + varchar status "pending/completed/skipped/failed" + varchar completed_by "완료자" + timestamp completed_at "완료시간" + } + + work_order_work_item_result { + varchar id PK "UUID ★신규★" + varchar company_code "회사코드" + varchar work_order_work_item_id FK "→ work_order_work_item.id" + varchar work_item_detail_id FK "→ process_work_item_detail.id" + varchar detail_type "check/inspect/input/procedure" + varchar result_value "결과값" + varchar is_passed "Y/N/null" + varchar recorded_by "기록자" + timestamp recorded_at "기록시간" + } + + %% ========== 관계 ========== + + %% 마스터 체인: 품목 → 라우팅 → 작업기준정보 + item_info ||--o{ item_routing_version : "item_number = item_code" + item_routing_version ||--o{ item_routing_detail : "id = routing_version_id" + item_routing_detail }o--|| process_mng : "process_code" + item_routing_detail ||--o{ process_work_item : "id = routing_detail_id" + process_work_item ||--o{ process_work_item_detail : "id = work_item_id" + + %% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행 + work_instruction }o--|| item_info : "item_id = id" + work_instruction ||--o{ work_order_process : "id = wo_id" + work_order_process }o--|| item_routing_detail : "routing_detail_id = id" + work_order_process ||--o{ work_order_work_item : "id = work_order_process_id" + work_order_work_item }o--|| process_work_item : "work_item_id = id" + work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id" + work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id" +``` + +### 2-1-1. 관계 요약 (텍스트) + +``` +[마스터 데이터 체인 - 조회용, 변경 없음] + + item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail + (품목) item_number (라우팅 버전) routing_ (공정별 상세) + = item_code version_id + │ + process_mng ◄───┘ process_code (공정 마스터) + │ + ├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail + │ (작업기준정보) (작업기준정보 상세) + │ routing_detail_id work_item_id + │ +[트랜잭션 데이터 - 상태 관리] │ + │ + work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★) + (작업지시) wo_id (공정별 진행) + item_id → item_info │ + ├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result + │ (작업기준정보 진행) (상세 결과값) + │ work_order_process_id work_order_work_item_id + │ work_item_id → process_work_item work_item_detail_id → process_work_item_detail + │ ★신규 테이블★ ★신규 테이블★ +``` + +### 2-2. 마스터 테이블 상세 + +#### item_info (품목 마스터) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| item_number | 품번 | item_routing_version.item_code와 매칭 | +| item_name | 품명 | | +| company_code | 회사코드 | 멀티테넌시 | + +#### item_routing_version (라우팅 버전) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| item_code | 품번 | item_info.item_number와 매칭 | +| version_name | 버전명 | 예: "기본 라우팅", "버전2" | +| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 | +| company_code | 회사코드 | | + +#### item_routing_detail (라우팅 상세 - 공정별) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| routing_version_id | FK → item_routing_version.id | | +| seq_no | 공정 순서 | 10, 20, 30... | +| process_code | 공정코드 | FK → process_mng.process_code | +| is_required | 필수/선택 | "필수" / "선택" | +| is_fixed_order | 순서고정 여부 | "고정" / "선택" | +| work_type | 작업유형 | | +| standard_time | 표준시간(분) | | +| outsource_supplier | 외주업체 | | +| company_code | 회사코드 | | + +#### process_work_item (작업기준정보) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| routing_detail_id | FK → item_routing_detail.id | | +| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) | +| title | 작업항목명 | 예: "장비 체크", "소재 준비" | +| is_required | 필수여부 | Y/N | +| sort_order | 정렬순서 | | +| description | 설명 | | +| company_code | 회사코드 | | + +#### process_work_item_detail (작업기준정보 상세) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| work_item_id | FK → process_work_item.id | | +| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) | +| content | 내용 | 예: "소음검사", "치수검사" | +| input_type | 입력타입 | select, text 등 | +| inspection_code | 검사코드 | | +| inspection_method | 검사방법 | | +| unit | 단위 | | +| lower_limit | 하한값 | | +| upper_limit | 상한값 | | +| is_required | 필수여부 | Y/N | +| sort_order | 정렬순서 | | +| company_code | 회사코드 | | + +--- + +## 3. 작업 진행 테이블 (트랜잭션 데이터) + +### 3-1. work_instruction (작업지시) - 기존 테이블 + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| work_instruction_no | 작업지시번호 | 예: WO-2026-001 | +| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** | +| status | 작업지시 상태 | waiting / in_progress / completed / cancelled | +| qty | 지시수량 | | +| completed_qty | 완성수량 | | +| work_team | 작업팀 | | +| worker | 작업자 | | +| equipment_id | 설비 | | +| start_date | 시작일 | | +| end_date | 종료일 | | +| remark | 비고 | | +| company_code | 회사코드 | | + +> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용. + +### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요 + +작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| wo_id | FK → work_instruction.id | 작업지시 참조 | +| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** | +| seq_no | 공정 순서 | 라우팅에서 복사 | +| process_code | 공정코드 | 라우팅에서 복사 | +| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) | +| is_required | 필수여부 | 라우팅에서 복사 | +| is_fixed_order | 순서고정 | 라우팅에서 복사 | +| standard_time | 표준시간 | 라우팅에서 복사 | +| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** | +| plan_qty | 계획수량 | | +| input_qty | 투입수량 | | +| good_qty | 양품수량 | | +| defect_qty | 불량수량 | | +| equipment_code | 사용설비 | | +| accepted_by | 접수자 | | +| accepted_at | 접수시간 | | +| started_at | 시작시간 | | +| completed_at | 완료시간 | | +| remark | 비고 | | +| company_code | 회사코드 | | + +### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블 + +POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | gen_random_uuid() | +| company_code | 회사코드 | 멀티테넌시 | +| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 | +| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 | +| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) | +| status | 완료상태 | pending / completed / skipped / failed | +| completed_by | 완료자 | 작업자 ID | +| completed_at | 완료시간 | | +| created_date | 생성일 | | +| updated_date | 수정일 | | +| writer | 작성자 | | + +### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블 + +작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | gen_random_uuid() | +| company_code | 회사코드 | 멀티테넌시 | +| work_order_work_item_id | FK → work_order_work_item.id | | +| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 | +| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) | +| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 | +| is_passed | 합격여부 | Y / N / null(해당없음) | +| remark | 비고 | 불합격 사유 등 | +| recorded_by | 기록자 | | +| recorded_at | 기록시간 | | +| created_date | 생성일 | | +| updated_date | 수정일 | | +| writer | 작성자 | | + +--- + +## 4. POP 데이터 플로우 + +### 4-1. 작업지시 등록 시 (ERP 측) + +``` +[작업지시 생성] + │ + ├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등) + │ + ├── 2. item_id → item_info.item_number 조회 + │ + ├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전) + │ + ├── 4. routing_version_id → item_routing_detail 조회 (공정 목록) + │ + └── 5. 각 공정별로 work_order_process INSERT + ├── wo_id = work_instruction.id + ├── routing_detail_id = item_routing_detail.id ← 핵심! + ├── seq_no, process_code, process_name 복사 + ├── status = 'waiting' + └── plan_qty = work_instruction.qty +``` + +### 4-2. POP 작업 조회 시 + +``` +[POP 화면: 작업지시 선택] + │ + ├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress') + │ + ├── 2. 선택한 작업지시의 공정 목록 조회 + │ SELECT wop.*, pm.process_name + │ FROM work_order_process wop + │ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code + │ WHERE wop.wo_id = {작업지시ID} + │ ORDER BY CAST(wop.seq_no AS int) + │ + └── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조) + SELECT pwi.*, pwid.* + FROM process_work_item pwi + LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id + WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id} + ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order +``` + +### 4-3. POP 작업 실행 시 + +``` +[작업자가 공정 시작] + │ + ├── 1. work_order_process UPDATE + │ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자} + │ + ├── 2. work_instruction UPDATE (첫 공정 시작 시) + │ SET status = 'in_progress' + │ + ├── 3. 작업기준정보 항목별 체크/입력 시 + │ ├── work_order_work_item UPSERT (항목별 상태) + │ └── work_order_work_item_result UPSERT (상세 결과값) + │ + └── 4. 공정 완료 시 + ├── work_order_process UPDATE + │ SET status = 'completed', completed_at = NOW(), + │ good_qty = {양품}, defect_qty = {불량} + │ + └── (모든 공정 완료 시) + work_instruction UPDATE + SET status = 'completed', completed_qty = {최종양품} +``` + +--- + +## 5. 핵심 조회 쿼리 + +### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회 + +```sql +-- 작업지시의 공정별 진행 현황 + 작업기준정보 +SELECT + wi.work_instruction_no, + wi.qty, + wi.status as wi_status, + ii.item_number, + ii.item_name, + wop.id as process_id, + wop.seq_no, + wop.process_code, + wop.process_name, + wop.status as process_status, + wop.plan_qty, + wop.good_qty, + wop.defect_qty, + wop.started_at, + wop.completed_at, + wop.routing_detail_id, + -- 작업기준정보는 routing_detail_id로 마스터 조회 + pwi.id as work_item_id, + pwi.work_phase, + pwi.title as work_item_title, + pwi.is_required as work_item_required +FROM work_instruction wi +JOIN item_info ii ON wi.item_id = ii.id +JOIN work_order_process wop ON wi.id = wop.wo_id +LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id +WHERE wi.id = $1 + AND wi.company_code = $2 +ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order; +``` + +### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회 + +```sql +-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인 +SELECT + pwi.id as work_item_id, + pwi.work_phase, + pwi.title, + pwi.is_required, + pwid.id as detail_id, + pwid.detail_type, + pwid.content, + pwid.input_type, + pwid.inspection_code, + pwid.inspection_method, + pwid.unit, + pwid.lower_limit, + pwid.upper_limit, + -- 진행 상태 + wowi.status as item_status, + wowi.completed_by, + wowi.completed_at, + -- 결과값 + wowir.result_value, + wowir.is_passed, + wowir.remark as result_remark +FROM process_work_item pwi +LEFT JOIN process_work_item_detail pwid + ON pwi.id = pwid.work_item_id +LEFT JOIN work_order_work_item wowi + ON wowi.work_item_id = pwi.id + AND wowi.work_order_process_id = $1 -- work_order_process.id +LEFT JOIN work_order_work_item_result wowir + ON wowir.work_order_work_item_id = wowi.id + AND wowir.work_item_detail_id = pwid.id +WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id +ORDER BY + CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END, + pwi.sort_order, + pwid.sort_order; +``` + +--- + +## 6. 변경사항 요약 + +### 6-1. 기존 테이블 변경 + +| 테이블 | 변경내용 | +|--------|---------| +| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 | + +### 6-2. 신규 테이블 + +| 테이블 | 용도 | +|--------|------| +| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 | +| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 | + +### 6-3. 건드리지 않는 것 + +| 테이블 | 이유 | +|--------|------| +| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 | +| item_routing_version | 마스터 데이터, 변경 없음 | +| item_routing_detail | 마스터 데이터, 변경 없음 | +| process_work_item | 마스터 데이터, 변경 없음 | +| process_work_item_detail | 마스터 데이터, 변경 없음 | + +--- + +## 7. DDL (마이그레이션 SQL) + +```sql +-- 1. work_order_process에 routing_detail_id 추가 +ALTER TABLE work_order_process +ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500); + +CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id +ON work_order_process(routing_detail_id); + +-- 2. 작업기준정보별 진행 상태 테이블 +CREATE TABLE IF NOT EXISTS work_order_work_item ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + work_order_process_id VARCHAR(500) NOT NULL, + work_item_id VARCHAR(500) NOT NULL, + work_phase VARCHAR(500), + status VARCHAR(500) DEFAULT 'pending', + completed_by VARCHAR(500), + completed_at TIMESTAMP, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) +); + +CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id); +CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id); +CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code); + +-- 3. 작업기준정보 상세 결과 테이블 +CREATE TABLE IF NOT EXISTS work_order_work_item_result ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + work_order_work_item_id VARCHAR(500) NOT NULL, + work_item_detail_id VARCHAR(500) NOT NULL, + detail_type VARCHAR(500), + result_value VARCHAR(500), + is_passed VARCHAR(500), + remark TEXT, + recorded_by VARCHAR(500), + recorded_at TIMESTAMP DEFAULT NOW(), + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) +); + +CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id); +CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id); +CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code); +``` + +--- + +## 8. 상태값 정의 + +### work_instruction.status (작업지시 상태) +| 값 | 의미 | +|----|------| +| waiting | 대기 | +| in_progress | 진행중 | +| completed | 완료 | +| cancelled | 취소 | + +### work_order_process.status (공정 상태) +| 값 | 의미 | +|----|------| +| waiting | 대기 (아직 시작 안 함) | +| in_progress | 진행중 (작업자가 시작) | +| completed | 완료 | +| skipped | 건너뜀 (선택 공정인 경우) | + +### work_order_work_item.status (작업기준정보 항목 상태) +| 값 | 의미 | +|----|------| +| pending | 미완료 | +| completed | 완료 | +| skipped | 건너뜀 | +| failed | 실패 (검사 불합격 등) | + +### work_order_work_item_result.is_passed (검사 합격여부) +| 값 | 의미 | +|----|------| +| Y | 합격 | +| N | 불합격 | +| null | 해당없음 (체크/입력 항목) | + +--- + +## 9. 설계 의도 요약 + +1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리 +2. **조회 경로**: `work_instruction.item_id` → `item_info.item_number` → `item_routing_version` → `item_routing_detail` → `process_work_item` → `process_work_item_detail` +3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장 +4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능 +5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지) + +--- + +## 10. 주의사항 + +- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함 +- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용 +- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장 +- 모든 테이블에 `company_code` 필수 (멀티테넌시) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 9c19405c..4726cf39 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -774,7 +774,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ /> - {/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */} + {/* 선택된 컴포넌트 정보 표시 */} {isSelected && (
{type === "widget" && ( @@ -785,7 +785,18 @@ const RealtimePreviewDynamicComponent: React.FC = ({ )} {type !== "widget" && (
- {component.componentConfig?.type || type} + {(() => { + const ft = (component as any).componentConfig?.fieldType; + if (ft) { + const labels: Record = { + text: "텍스트", number: "숫자", textarea: "여러줄", + select: "셀렉트", category: "카테고리", entity: "엔티티", + numbering: "채번", + }; + return labels[ft] || ft; + } + return (component as any).componentConfig?.type || componentType || type; + })()}
)}
diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index a902d7f3..c718c70e 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -204,6 +204,66 @@ function FullWidthOverlayRow({ ); } +function ProportionalRenderer({ + components, + canvasWidth, + canvasHeight, + renderComponent, +}: ResponsiveGridRendererProps) { + const containerRef = useRef(null); + const [containerW, setContainerW] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w && w > 0) setContainerW(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const topLevel = components.filter((c) => !c.parentId); + const ratio = containerW > 0 ? containerW / canvasWidth : 1; + + const maxBottom = topLevel.reduce((max, c) => { + const bottom = c.position.y + (c.size?.height || 40); + return Math.max(max, bottom); + }, 0); + + return ( +
0 ? `${maxBottom * ratio}px` : "200px" }} + > + {containerW > 0 && + topLevel.map((component) => { + const typeId = getComponentTypeId(component); + return ( +
+ {renderComponent(component)} +
+ ); + })} +
+ ); +} + export function ResponsiveGridRenderer({ components, canvasWidth, @@ -213,6 +273,18 @@ export function ResponsiveGridRenderer({ const { isMobile } = useResponsive(); const topLevel = components.filter((c) => !c.parentId); + const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c)); + + if (!isMobile && !hasFullWidthComponent) { + return ( + + ); + } const rows = groupComponentsIntoRows(topLevel); const processedRows: ProcessedRow[] = []; @@ -298,7 +370,46 @@ export function ResponsiveGridRenderer({ const { normalComps } = processedRow; const allButtons = normalComps.every((c) => isButtonComponent(c)); - const gap = isMobile ? 8 : allButtons ? 8 : getRowGap(normalComps, canvasWidth); + + // 데스크톱에서 버튼만 있는 행: 디자이너의 x, width를 비율로 적용 + if (allButtons && normalComps.length > 0 && !isMobile) { + const rowHeight = Math.max(...normalComps.map(c => c.size?.height || 40)); + + return ( +
0 ? `${rowMarginTop}px` : undefined, + }} + > + {normalComps.map((component) => { + const typeId = getComponentTypeId(component); + const leftPct = (component.position.x / canvasWidth) * 100; + const widthPct = ((component.size?.width || 90) / canvasWidth) * 100; + + return ( +
+ {renderComponent(component)} +
+ ); + })} +
+ ); + } + + const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth); const hasFlexHeightComp = normalComps.some((c) => { const h = c.size?.height || 0; @@ -310,7 +421,6 @@ export function ResponsiveGridRenderer({ key={`row-${rowIndex}`} className={cn( "flex w-full flex-wrap overflow-hidden", - allButtons && "justify-end px-2 py-1", hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0" )} style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }} @@ -357,7 +467,7 @@ export function ResponsiveGridRenderer({ style={{ width: isFullWidth ? "100%" : undefined, flexBasis: useFlexHeight ? undefined : flexBasis, - flexGrow: 1, + flexGrow: percentWidth, flexShrink: 1, minWidth: isMobile ? "100%" : undefined, minHeight: useFlexHeight ? "300px" : (component.size?.height diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 6eeeb4e1..d22a9c14 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -475,6 +475,7 @@ export default function ScreenDesigner({ // 테이블 데이터 const [tables, setTables] = useState([]); + const [tableRefreshCounter, setTableRefreshCounter] = useState(0); const [searchTerm, setSearchTerm] = useState(""); // 🆕 검색어로 필터링된 테이블 목록 @@ -1434,8 +1435,16 @@ export default function ScreenDesigner({ selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath, + tableRefreshCounter, ]); + // 필드 타입 변경 시 테이블 컬럼 정보 갱신 (화면 디자이너에서 input_type 변경 반영) + useEffect(() => { + const handler = () => setTableRefreshCounter((c) => c + 1); + window.addEventListener("table-columns-refresh", handler); + return () => window.removeEventListener("table-columns-refresh", handler); + }, []); + // 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출 const handleTableSelect = useCallback( async (tableName: string) => { diff --git a/frontend/components/screen/config-panels/AlertConfigPanel.tsx b/frontend/components/screen/config-panels/AlertConfigPanel.tsx index f842789e..8bae4f44 100644 --- a/frontend/components/screen/config-panels/AlertConfigPanel.tsx +++ b/frontend/components/screen/config-panels/AlertConfigPanel.tsx @@ -1,70 +1,80 @@ "use client"; import React from "react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { ComponentData } from "@/types/screen"; +import { ConfigPanelBuilder } from "@/lib/registry/components/common/ConfigPanelBuilder"; +import { ConfigSectionDefinition } from "@/lib/registry/components/common/ConfigPanelTypes"; interface AlertConfigPanelProps { - component: ComponentData; - onUpdateProperty: (path: string, value: any) => void; + config?: Record; + onChange?: (key: string, value: any) => void; + component?: any; + onUpdateProperty?: (path: string, value: any) => void; } -export const AlertConfigPanel: React.FC = ({ component, onUpdateProperty }) => { - const config = component.componentConfig || {}; +const sections: ConfigSectionDefinition[] = [ + { + id: "content", + title: "콘텐츠", + fields: [ + { + key: "title", + label: "제목", + type: "text", + placeholder: "알림 제목을 입력하세요", + }, + { + key: "message", + label: "메시지", + type: "textarea", + placeholder: "알림 메시지를 입력하세요", + }, + ], + }, + { + id: "style", + title: "스타일", + fields: [ + { + key: "type", + label: "알림 타입", + type: "select", + options: [ + { label: "정보 (Info)", value: "info" }, + { label: "경고 (Warning)", value: "warning" }, + { label: "성공 (Success)", value: "success" }, + { label: "오류 (Error)", value: "error" }, + ], + }, + { + key: "showIcon", + label: "아이콘 표시", + type: "switch", + }, + ], + }, +]; + +export const AlertConfigPanel: React.FC = ({ + config: directConfig, + onChange: directOnChange, + component, + onUpdateProperty, +}) => { + const config = directConfig || component?.componentConfig || {}; + + const handleChange = (key: string, value: any) => { + if (directOnChange) { + directOnChange(key, value); + } else if (onUpdateProperty) { + onUpdateProperty(`componentConfig.${key}`, value); + } + }; return ( -
-
- - onUpdateProperty("componentConfig.title", e.target.value)} - placeholder="알림 제목을 입력하세요" - /> -
- -
- -