From 3cd3eed71dc01638bde7493a3904ca9d791fc9e6 Mon Sep 17 00:00:00 2001 From: kmh Date: Mon, 20 Apr 2026 16:50:51 +0900 Subject: [PATCH] feat(pop): port production/process screen from legacy POP (Phase A + B-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A — Legacy POP 이식: - Copy WorkOrderList, ProcessWork, AcceptProcessModal, ProcessDetailModal, DefectTypeModal, ProcessTimer to app/(main)/COMPANY_7/pop/_components/production/ - New useProcessData hook: single sync call on mount + 3s throttle + toast errors - Add /COMPANY_7/pop/production/process to usePopSettings URL mapping - Update POP.md work log (legacy POP copy exception for this task) Phase B-1 — ProcessWork 1st section split: - Extract MaterialInputSection (ProcessWork 2993→2637, -356 lines) - Plan revision: TimerPanel removed (dead code confirmed in original) Plan: .claude/plans/pop-process-execution.md Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/app/(main)/COMPANY_7/pop/POP.md | 549 ++++ .../production/AcceptProcessModal.tsx | 184 ++ .../production/DefectTypeModal.tsx | 365 +++ .../production/ProcessDetailModal.tsx | 230 ++ .../_components/production/ProcessTimer.tsx | 243 ++ .../_components/production/ProcessWork.tsx | 2637 +++++++++++++++++ .../_components/production/WorkOrderList.tsx | 1855 ++++++++++++ .../sections/MaterialInputSection.tsx | 365 +++ .../_components/production/useProcessData.ts | 212 ++ .../COMPANY_7/pop/production/process/page.tsx | 258 ++ frontend/hooks/pop/usePopSettings.ts | 2 + 11 files changed, 6900 insertions(+) create mode 100644 frontend/app/(main)/COMPANY_7/pop/POP.md create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/DefectTypeModal.tsx create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessDetailModal.tsx create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessTimer.tsx create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/WorkOrderList.tsx create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/sections/MaterialInputSection.tsx create mode 100644 frontend/app/(main)/COMPANY_7/pop/_components/production/useProcessData.ts create mode 100644 frontend/app/(main)/COMPANY_7/pop/production/process/page.tsx diff --git a/frontend/app/(main)/COMPANY_7/pop/POP.md b/frontend/app/(main)/COMPANY_7/pop/POP.md new file mode 100644 index 00000000..6393d700 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/POP.md @@ -0,0 +1,549 @@ +# POP 구조 설계 문서 + +--- + +## 0. 작업 규칙 (POP 영역 작업 시작 전 필독) + +### 0-1. 이 문서 참조 의무 +- `frontend/app/(main)/COMPANY_7/pop/` 하위 파일을 읽거나 수정하기 전에 **이 POP.md를 반드시 먼저 Read** +- 세션 내 이전에 한 번 읽었더라도, POP 영역 작업에 **새로 진입할 때마다** 다시 Read (기억 의존 금지) +- 이 규칙은 매 작업 지시에 자동 적용 — 사용자가 재공지하지 않아도 준수 + +### 0-2. 스코프 제한 지시어 (절대 넘지 않음) +사용자가 아래 표현을 쓰면 **UI 껍데기만** 가져오고 로직 일절 이식 금지: +- "UI 구조만", "UI만 따와", "껍데기만", "뼈대만", "구조만 클론", "DB 연동 제외" + +**이식 금지 대상**: +- `apiClient` / `dataApi` / `fetch` 호출 전부 +- `useCartSync` 등 DB 동기화 훅 +- 채번 / 저장 / 삭제 / 확정 비즈니스 로직 +- 로직 포함 모달 (InspectionModal, NumberPadModal 등) + +**허용**: JSX + Tailwind + 정적 상수/아이콘. 데이터는 빈 배열/mock, 핸들러는 `() => {}` 또는 `// TODO: API 연결` 주석만. + +### 0-3. POP 파일별 상태 (추측 금지 — 아래 명시된 상태대로 취급) + +| 파일 | 상태 | 비고 | +|---|---|---| +| `_components/inbound/PurchaseInbound.tsx` | **원본 · DB 연동 O** | 유일한 실연동 컴포넌트. 다른 입고는 이걸 UI만 클론한 것 | +| `_components/inbound/InboundCartPage.tsx` | **현역 · DB 연동 O** | 실제 사용 중인 풀스크린 장바구니 | +| `_components/inbound/InboundManage.tsx` | **현역 · DB 연동 O** | 입고관리 화면. getReceivingList/updateReceiving/deleteReceiving API 연동 | +| `_components/inbound/InboundCart.tsx` | **구버전 · 미사용** | 어디서도 import 안 됨. 분석/참조 대상 아님 (사용자가 명시 언급할 때만) | +| `_components/inbound/{나머지 Pascal}Inbound.tsx` | **UI 클론 · DB 미연동** | `fetchAllSuppliers`/`fetchOrders` 빈 배열 반환만. 로직 추가하려면 먼저 확인 | +| `inbound/{slug}/page.tsx` (purchase 외) | **UI 클론** | `useCartSync` 훅은 들어가 있지만 데이터 소스는 빈 배열 | +| `_components/common/useCartSync.ts` | re-export | 실제 구현은 `@/hooks/pop/useCartSync.ts` | +| `_components/outbound/OutboundCartPage.tsx` | **현역 · DB 연동 O** | 출고 장바구니. InboundCartPage 클론, 검사 로직 제거, API `/outbound/*` | +| `_components/outbound/{Pascal}Outbound.tsx` | **UI 클론 · DB 미연동** | `fetchAllCustomers`/`fetchOrders` 빈 배열. 로직 추가하려면 먼저 확인 | +| `outbound/{slug}/page.tsx` | **UI 클론** | `useCartSync("outbound")` 사용, 데이터 소스는 빈 배열 | + +### 0-4. 작업 로그 업데이트 의무 +POP 영역에 파일 생성/수정/삭제가 발생하면 이 문서의 `## 작업 로그` 섹션에 날짜별 항목 추가. +사용자가 별도 지시하지 않아도 자동으로 기록. + +--- + +## 1. 개요 + +POP(생산현장관리) 화면을 업체별로 독립 개발하기 위한 구조 설계. +기존 `(pop)` route group을 사용하지 않고, `(main)` 안에서 업체별 폴더(`COMPANY_*`) 하위에 `pop/` 폴더를 두는 방식으로 재개발한다. + +### 재개발 배경 +- 업체별로 컬럼 라벨, 요구사항, 비즈니스 성격이 다름 +- 기존 공통 POP 구조로는 업체별 커스터마이징에 한계 +- 업체 폴더 안에서 독립적으로 개발하면 충돌 없이 유지보수 가능 + +## 2. 핵심 결정사항 + +### 2-1. (main) layout 조건 분기 + +POP는 터치 풀스크린 UI이므로 ERP의 사이드바/탭바/메신저가 불필요하다. +별도 route group을 만드는 대신, `(main)/layout.tsx`에서 pathname 기반 조건 분기로 해결했다. + +```tsx +// frontend/app/(main)/layout.tsx +"use client"; + +import { usePathname } from "next/navigation"; + +export default function MainLayout({ children }) { + const pathname = usePathname(); + const isPop = pathname.includes("/pop/") || pathname.endsWith("/pop"); + + if (isPop) { + return <>{children}; // POP: layout 없이 children만 렌더링 + } + + return ( + + + + {children} // ERP: 기존 layout 그대로 + ... + + + + ); +} +``` + +### 2-2. 문제점 검토 결과 + +| 항목 | 영향 | 이유 | +|------|------|------| +| useAuth (인증) | 없음 | 독립 훅, Context 의존 아님 | +| MenuProvider (메뉴) | 없음 | POP에서 useMenu 미사용 | +| MessengerProvider (메신저) | 없음 | POP에서 useMessenger 미사용 | +| 클라이언트 컴포넌트 전환 | 없음 | 자식 컴포넌트가 이미 전부 "use client" | +| metadata export | 없음 | (main)/layout.tsx에 metadata 없음 | + +## 3. 폴더 구조 + +### URL 규칙 +``` +ERP: /COMPANY_7/sales/order -> (main) layout 적용 +POP: /COMPANY_7/pop/inbound/purchase -> layout 무시 (children만) +``` + +`/pop/`이 경로에 포함되면 자동으로 POP 모드가 된다. + +### 디렉토리 구조 +``` +frontend/app/(main)/COMPANY_7/ + ├── sales/ <- 기존 ERP 화면 + ├── production/ + ├── logistics/ + ├── purchase/ + ├── quality/ + ├── equipment/ + ├── mold/ + ├── outsourcing/ + ├── design/ + ├── monitoring/ + ├── master-data/ + └── pop/ <- POP 화면 (layout 무시) + ├── home/ + ├── inbound/ + │ ├── purchase/ 구매입고 + │ ├── production/ 생산입고 + │ ├── subcontractor/ 외주입고 + │ ├── supplied/ 사급입고 + │ ├── return-external/ 반품입고 (외부) + │ ├── return-internal/ 반납입고 (내부) + │ ├── recovery/ 외주자재회수 + │ ├── change/ 교환입고 + │ ├── error/ 불량입고 + │ ├── shipment/ 출하입고 + │ └── cart/ 입고 카트 + ├── outbound/ + │ ├── sales/ 판매출고 + │ ├── production/ 생산출고 + │ ├── subcontractor/ 외주출고 + │ ├── supplied/ 사급출고 + │ ├── return/ 반품출고 + │ ├── etc/ 기타출고 + │ ├── transfer/ 이관출고 + │ └── cart/ 출고 카트 + ├── production/ + │ ├── process/ 공정선택 + │ └── work/[processId] 공정작업 + ├── inventory/ + │ ├── move/ 재고이동 + │ ├── transfer/ 재고이관 + │ ├── history/ 재고이력 + │ └── adjust-history/ 재고조정이력 + ├── equipment/ + │ ├── inspection/ 설비점검 + │ └── management/ 설비관리 + └── quality/ + └── inspection/ 품질검사 +``` + +### 다른 업체 추가 시 +``` +frontend/app/(main)/COMPANY_10/pop/ <- 동일 구조, 독립 개발 +frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유 +``` + +## 4. POP 전용 layout + +각 업체의 `pop/` 하위에 `layout.tsx`를 만들어 POP 전용 레이아웃을 적용한다. +`(main)` layout이 무시되므로 이 layout이 최상위가 된다. + +``` +(main)/layout.tsx <- isPop이면 children만 반환 + └── COMPANY_7/pop/ + └── layout.tsx <- POP 전용 레이아웃 (터치 헤더, 풀스크린) + └── page.tsx +``` + +--- + +## 작업 로그 + +### 2026-04-15 +- `frontend/app/(main)/layout.tsx` 수정 + - `"use client"` 추가 + - `usePathname` 기반 조건 분기 추가 (`/pop/` 포함 시 children만 렌더링) +- `frontend/app/(main)/COMPANY_7/pop/` 폴더 생성 +- 나머지 7개 입고 화면 신규 작성 (반품입고와 동일 방식: 구매입고 구조 클론, DB 연동 제외, 화면별 색상/라벨만 차별화) + - subcontractor (외주입고, purple) / supplied (사급자재, cyan) / error (불량입고, red) / recovery (외주자재회수, pink) / change (교환입고, teal) / production (생산입고, green) / return-internal (반납입고, orange) + - 각 컴포넌트: `_components/inbound/{Pascal}Inbound.tsx` — 타입·필드·로직은 구매입고와 동일, 헤더 타이틀/품목 라벨/색상(스캔 버튼 gradient, 담기 버튼 gradient, shadow, tailwind color)만 교체 + - 각 페이지: `inbound/{slug}/page.tsx` — `useCartSync("pop-{slug}-inbound", "{slug}_detail")`, 카트 이동 URL 쿼리 전부 교체 + - `inbound/page.tsx`의 7개 메뉴 `href: "#"` → 실제 라우트로 연결 (재고이동 `transfer`만 `#` 유지) +- 반품입고 화면 신규 작성 (구매입고 구조 클론, DB 연동 제외) + - `_components/inbound/ReturnExternalInbound.tsx` 생성 + - `fetchAllSuppliers` / `fetchOrders` 는 빈 배열 반환 자리만 남김 (`// TODO: API 연결`) + - 타입/필드/로직은 PurchaseInbound 동일 (Phase A: 텍스트·색상만 차별화) + - 헤더 타이틀 "반품입고", 스캔/포커스/담기 컬러를 amber(#f59e0b → #d97706)로 통일 + - 발주 라벨(발주일/발주번호/발주수량/미입고)은 원형 유지 (추후 필드 조정 예정) + - `inbound/return-external/page.tsx` 생성 + - `useCartSync("pop-return-external-inbound", "return_external_detail")` + - 카트 이동 URL의 `screenId` / `sourceTable` / `type=반품입고` / `backUrl` 교체 + - `inbound/page.tsx` 수정 + - 반품입고 메뉴 `href: "#"` → `/COMPANY_7/pop/inbound/return-external` + +### 2026-04-16 +- **POP layout 도입: PopShell을 layout.tsx로 이관** + - `frontend/app/(main)/COMPANY_7/pop/layout.tsx` 신규 생성 + - `{children}` — pathname `/pop/main` 일 때만 공지 배너 표시 + - 타이틀: `title` prop 미전달 → PopShell 기본값 `user?.companyName` 사용 (업체명 고정) + - navigation 간 PopShell 리마운트 없음 (시계/전체화면/프로필 상태 유지) + - 13개 page.tsx에서 `` 래핑 및 import 일괄 제거 + - `main/page.tsx`, `inbound/page.tsx`, `inbound/{9종}/page.tsx`, `inbound/cart/page.tsx` +- **카트 버튼 위치 변경: PopShell headerRight → 각 Inbound 컴포넌트 content 내부** + - 9개 Inbound 컴포넌트에 `onCartClick`/`saving` props 추가 + - 카트 버튼: 제목 행 우측, `min-w-[144px] min-h-[48px]`, "장바구니" 라벨, 각 페이지 테마 gradient 적용 + - purchase(blue), subcontractor(purple), supplied(cyan), production(green), error(red), recovery(pink), change(teal), return-internal(orange), return-external(amber) + - 9개 page.tsx에서 `headerRight` prop 제거, `onCartClick`/`saving` prop 전달로 변경 +- **출고 메뉴 페이지 신규 작성** + - `outbound/page.tsx` 생성 — 입고 메뉴(`inbound/page.tsx`) 구조 클론, 출고 용어로 전환 + - 외부 출고 5종: 판매출고(green), 반품출고(slate), 외주출고(purple), 사급출고(cyan), 기타출고(dark) + - 내부 출고 2종: 생산출고(orange-red), 재고이동(orange, `#` 준비 중) + - KPI 캐러셀 3슬라이드 (금일 출고/출고 대기/완료, 완료/판매출고/외주출고, 금일 수량/출고율/반품) + - 최근 출고 mock 데이터 2건 + - Back URL: `/COMPANY_7/pop/main` +- **출고 컴포넌트 6종 신규 작성** (`_components/outbound/`) + - 입고 UI 클론(ReturnExternalInbound.tsx 패턴) 기반, DB 미연동 + - `fetchAllCustomers` / `fetchOrders` 빈 배열 반환 (`// TODO: API 연결`) + - 공통 변경: supplier→customer, purchase_no→reference_no, inbound_type→outbound_type, 입고→출고, 발주→주문, 미입고→미출고 + - Props: `outboundType`, `sourceTable` (3인자 addItem) + - 파일별 색상: + - `SalesOutbound.tsx` — 판매출고, green (#22c55e→#15803d) + - `ReturnOutbound.tsx` — 반품출고, slate (#64748b→#334155) + - `SubcontractorOutbound.tsx` — 외주출고, purple (#8b5cf6→#6d28d9) + - `SuppliedOutbound.tsx` — 사급출고, cyan (#06b6d4→#0e7490) + - `EtcOutbound.tsx` — 기타출고, dark (#475569→#1e293b) + - `ProductionOutbound.tsx` — 생산출고, orange (#f97316→#c2410c) +- **OutboundCartPage 신규 작성** (`_components/outbound/OutboundCartPage.tsx`) + - InboundCartPage 클론, 출고용 변경: + - `useCartSync("outbound")`, 타이틀 "출고 장바구니" + - 검사(InspectionModal) 관련 로직 전체 제거 + - API: `GET /outbound/warehouses`, `GET /outbound/generate-number`, `POST /outbound` + - Payload: outbound_number/date/type/qty, customer_code/name (supplier 대신) + - outbound_type 배지 표시, 혼합 시 "혼합출고" +- **출고 page.tsx 7개 신규 작성** (`outbound/{slug}/page.tsx`) + - `cart/page.tsx` — `backUrl`만 쿼리, `` + - `sales/page.tsx` — 판매출고, `shipment_instruction_detail` + - `return/page.tsx` — 반품출고, `return_outbound_detail` + - `subcontractor/page.tsx` — 외주출고, `outsource_outbound_detail` + - `supplied/page.tsx` — 사급출고, `supplied_outbound_detail` + - `etc/page.tsx` — 기타출고, `etc_outbound_detail` + - `production/page.tsx` — 생산출고, `production_outbound_detail` +- **장바구니 구조 개편 (입고/출고 공통)** + - `useCartSync` 훅 시그니처 변경: `(screenId, sourceTable)` → `(category: 'inbound' | 'outbound')` + - DB 필터: `screen_id` 제거 → `cart_type='pop_inbound'` / `'pop_outbound'`로 카테고리 분리 + - `addItem` 3번째 인자로 `sourceTable` 전달 (항목별 sourceTable) + - 레거시 오버로드 유지 (PopCardListComponent 등 기존 호출 호환) + - 입고 page.tsx 9개: `useCartSync("inbound")`, 카트 이동 URL 쿼리 `?backUrl=...`만 + - 입고 컴포넌트 9개: `inboundType`/`sourceTable` props 추가, addItem에 `row.inbound_type` 포함, 공급사 검증 + - InboundCartPage: props `{ backUrl }` 단순화, 타이틀 "입고 장바구니", inbound_type 배지, 혼합 시 "혼합입고" + - 카트 라우트: `screenId`/`sourceTable`/`type` 쿼리 제거, `backUrl`만 유지 + - 백엔드 receivingController: 혼합 inbound_type 처리 추가 +- **검증 완료** + - `npm run build` (frontend): 성공 + - `tsc --noEmit` (backend): 성공 + - 브라우저 입고 테스트: 구매입고 품목 담기 → 카트 진입 → "입고 장바구니" 타이틀 + "구매입고" 배지 + 품목/수량 정상 + - 브라우저 카트 공유 테스트: 구매입고에서 담은 품목이 생산입고 화면 배지(1)에 표시, 카트 페이지에서도 동일 품목 확인 + - 브라우저 출고 테스트: 출고 메뉴 페이지(외부 5종 + 내부 2종), 판매출고 화면, 출고 카트 페이지 정상 렌더링 + - 출고 카트 더미 테스트: DB에 pop_outbound 2건(판매출고 50EA + 생산출고 30EA) 삽입 → 카트에서 혼합 표시 확인 → 더미 삭제 완료 + +### 2026-04-16 (2차) +- **수량 입력 모달 분리: NumberPadModal → SimpleKeypadModal + 장바구니 포장단위** + - `_components/common/SimpleKeypadModal.tsx` 신규 생성 + - 숫자 키패드 + 확인 버튼만 (포장단위 선택 없음) + - props: `open`, `onClose`, `onConfirm(qty)`, `maxQty`, `itemName`, `initialQty` + - 입고 컴포넌트 9개: `NumberPadModal` → `SimpleKeypadModal` 교체 + - PurchaseInbound, SubcontractorInbound, ProductionInbound, ReturnExternalInbound, ReturnInternalInbound, SuppliedInbound, ErrorInbound, ChangeInbound, RecoveryInbound + - `handleNumpadConfirm` 시그니처: `(qty, packages)` → `(qty)` 단순화 + - 카드 내 포장정보 표시 블록 제거 + - 출고 컴포넌트 6개: 동일 교체 + - SalesOutbound, ReturnOutbound, SubcontractorOutbound, SuppliedOutbound, EtcOutbound, ProductionOutbound + - `InboundCartPage.tsx`: 품목별 "포장단위" 버튼 추가 + - 수량 편집: `SimpleKeypadModal` (숫자만) + - 포장등록: `NumberPadModal` (기존 4단계 포장 플로우) + - 포장 완료 시 버튼 색상 green, 미등록 시 amber + - `OutboundCartPage.tsx`: 동일 구조 적용 + - `tsc --noEmit`: 새 에러 없음 (기존 에러만 존재) +- **입고/출고 컴포넌트 자동저장 추가** + - 입고 9개 + 출고 6개 컴포넌트: `cart.addItem` / `cart.removeItem` 후 `setTimeout(() => cart.saveToDb(), 300)` 추가 + - 화면 이동 시 카트 데이터 소실 방지 +- **InboundCartPage 장바구니 카드 레이아웃 개편** + - 카드 그리드: `flex-col` → `grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3` + - 유형별 구분선: 유형 1개여도 항상 라벨 + 구분선 표시 + - 헤더 행: 체크박스 + 품번 + 품목명 + 검사필수 버튼 (빨간색, `min-w-[80px]`, `py-[6px]`) + - 품목명: 15자 초과 시 `cartMarquee` 애니메이션 자동 슬라이드 (3초, container query `cqi` 기반 자동 계산) + - 액션 컬럼: 수량 → 삭제 (검사 버튼은 헤더로 이동) + - 포장단위 버튼: 카드 하단 전체 너비, 아이콘 포함 + - 기존 검사 영역(카드 하단 full-width bar) 제거 → 헤더 배지로 대체 + - 포장정보(포장완료 시 상세) 유지 + - 카드 선택 표시: 좌측 바 → `ring-2 ring-blue-500` 전체 테두리 +- **OutboundCartPage 동일 적용** + - 유형별 구분선 항상 표시 + - 카드 선택 시 `ring-2 ring-blue-500` 전체 테두리 (좌측 바 제거) + - 품목명 marquee 애니메이션 (15자 초과 시, `cartMarquee` 3초) + - 포장단위 버튼 카드 하단 전체 너비 + 아이콘 + - `tsc --noEmit`: 에러 없음 + +### 2026-04-16 (3차) +- **포장단위 하드코딩 → DB 조회로 변경** + - 백엔드: `GET /api/packaging/pkg-units-by-item/:itemNumber` 신규 API + - `pkg_unit_item` JOIN `pkg_unit`으로 품목별 매칭 포장단위 조회 + - `PackagingModal.tsx`: 하드코딩 6개 배열 제거, `itemNumber` prop → DB 조회, 로딩/빈 상태 처리 + - `NumberPadModal.tsx`: 동일 DB 조회 적용, `direct-qty` 단계 제거 (포장등록 전용) + - 스텝: 포장선택 → 개당수량 → 포장개수 → 확인 (4단계) + - `pkg_qty` 자동 세팅 (DB 등록값 → 개당수량 초기값) + - `initialQty` prop 제거, 건너뛰기 버튼 제거 + - `InboundCartPage.tsx` / `OutboundCartPage.tsx`: NumberPadModal에 `itemNumber` prop 전달, `initialQty` 제거 + - `frontend/lib/api/packaging.ts`: `getPkgUnitsByItem()` + `PkgUnitByItem` 타입 추가 + - 더미데이터: pkg_unit 3건(박스/포대/파렛트) + pkg_unit_item 5건 등록, 브라우저 검증 완료 +- **NumberPadModal 복수 포장 지원 + 나머지 자동 계산** + - 플로우: 포장선택 → 개당수량 → 포장개수 → (나머지 있으면) 나머지 안내 → 나머지 포장선택 → 확인 + - MAX 버튼: 개당수량=maxQty, 포장개수=floor(maxQty/개당수량) 자동 계산 + - 나머지 포장: 포장단위 선택 시 1개 x 나머지수량으로 자동 세팅 → 바로 확인 + - 나머지 단계 헤더 amber 색상으로 시각 구분 + - confirm에서 1차 포장(green) + 나머지 포장(amber) + 합계 표시 + - `PackageEntry[]` 배열로 복수 엔트리 반환 (기존 호환) + - `initialPackages` 복수 엔트리 복원 지원 +- **적재함(loading_unit) 선택 단계 추가** + - 백엔드: `GET /api/packaging/loading-units-by-pkg/:pkgCode` 신규 API + - `loading_unit_pkg` JOIN `loading_unit`으로 포장코드별 매칭 적재함 조회 + - `frontend/lib/api/packaging.ts`: `getLoadingUnitsByPkg()` + `LoadingUnitByPkg` 타입 추가 + - `NumberPadModal.tsx`: 포장 완료 → 적재함 선택 → 확인 플로우 + - 적재함 단계 헤더 purple 색상 + - 적재함 목록: 이름, 코드, 타입, 최대적재수 표시 + - "건너뛰기 (적재함 없음)" 버튼 + - 매칭 적재함 없으면 자동 skip → confirm + - confirm에서 적재함 정보 purple 카드로 표시 + - `onConfirm` 시그니처 확장: `(qty, packages, loadingUnit?)` — 3번째 인자로 적재함 전달 + - `InboundCartPage.tsx` / `OutboundCartPage.tsx`: `handlePackagingConfirm`에서 `loadingUnit` → `cart.updateItemRow` 저장 + - 더미데이터: loading_unit 2건(목재파렛트/20ft컨테이너) + loading_unit_pkg 3건 등록, 브라우저 검증 완료 + +### 2026-04-17 (1차) +- **입고/출고 장바구니 거래처 필터 드롭다운 추가** + - `InboundCartPage.tsx`: + - `selectedSupplierFilter` state 추가 + - `supplierList` useMemo (items에서 supplier_code/name 중복 제거) + - 거래처 1개 시 자동 선택 useEffect + - `filteredItems` useMemo (선택/확정 로직은 전체 items 기반 유지) + - Info banner: supplier 배지 제거 → `` 드롭다운 추가 (입고일자/창고/입고번호와 동일 크기, 4칸 grid) + - 거래처 목록: 장바구니에 담긴 품목에서 supplier_code/customer_code 자동 추출 + - 거래처 1개면 자동 선택, "전체" 옵션 없음 + - 기존 supplierName/customerName 배지 제거, filteredItems useMemo로 품목 필터링 + - 확정/선택 로직은 전체 items 기반 유지 (필터 영향 없음) + +### 2026-04-17 (3차) +- **입고관리/출고관리 페이지 신규 생성 (UI 껍데기, DB 미연동)** + - `_components/inbound/InboundManage.tsx` 신규 생성 + - 입고 내역 조회/수정/삭제 UI, 시작일/종료일/키워드 검색 필터 + - 카드형 목록 (체크박스 선택, 수정/삭제 버튼), 테마 blue + - MOCK_RECORDS 빈 배열 (`// TODO: API 연결`) + - `_components/inbound/OutboundManage.tsx` 신규 생성 + - 출고 내역 조회/수정/삭제 UI, 동일 구조, 테마 emerald + - supplier→customer 용어 변경 + - `inbound/inbound-manage/page.tsx` 신규 생성 + - `inbound/outbound-manage/page.tsx` 신규 생성 + - `inbound/page.tsx` 수정 + - INTERNAL_ITEMS에 "입고관리"(blue), "출고관리"(emerald) 버튼 2개 추가 (재고이동 우측) + +### 2026-04-17 (4차) +- **출고 메뉴 연결**: `main/page.tsx` 출고 버튼 `href: "#"` → `/COMPANY_7/pop/outbound` +- **채번 로직 구 POP 의존 제거**: InboundCartPage/OutboundCartPage에서 `screens/6527/layout-pop` 대신 `numbering-rules/by-column` API 직접 조회로 변경 + +### 2026-04-20 +- **입고관리 화면 API 연동 (UI 껍데기 → 실연동)** + - `_components/inbound/InboundManage.tsx` 전면 개편 + - MOCK_RECORDS 빈 배열 → `getReceivingList` API 연동 (날짜 범위, 거래처, 키워드 필터) + - 삭제: `deleteReceiving` API 연동 (복수 선택 → 헤더 ID 중복 제거 → 순차 삭제, 재고 롤백 포함) + - 수정: 전체 필드 수정 모달 구현 (`updateReceiving` API) + - 기본 정보: 입고일, 입고상태(드롭다운) + - 수량/금액: 수량, 단가, 금액(자동계산) + - 입고 상세: LOT번호, 창고(DB 드롭다운), 위치, 검사상태, 검사자, 담당자 + - 메모 + - 카드별 수정 아이콘(연필) 추가 — 클릭 시 바로 수정 모달 열림 + - 상단 수정 버튼: 1건 선택 시만 활성화 + - 창고 목록: `getReceivingWarehouses` API 연동 + - 검색: Enter 키 + 검색 버튼 지원, 로딩 스피너 + - 백엔드/API 클라이언트 수정 없음 (기존 구현 활용) + - `tsc --noEmit`: InboundManage 관련 새 에러 없음 + - `npm run build`: 성공 + - 브라우저 검증: 조회 15건 정상 표시, 수정 모달 정상 렌더링, 카드 선택/버튼 활성화 확인 +- **입고관리 필터 변경** + - 시작일/종료일 (날짜 범위) → 입고일 (단일 날짜) 변경 + - 입고유형 카테고리 드롭다운 추가 (전체 + 10개 유형) +- **[알려진 이슈] 입고 수정 시 헤더 필드 공유 문제** + - `inbound_date`, `warehouse_code`, `location_code`, `inbound_status`, `inspector`, `manager`, `memo`는 헤더(`inbound_mng`) 필드 + - 같은 입고번호(예: RCV-2026-0010) 안에 품목이 여러 건일 때, 한 품목에서 입고일 등 헤더 필드를 수정하면 동일 입고번호의 **모든 품목에 반영됨** + - 원인: `inbound_detail` 테이블에 `inbound_date` 컬럼 없음 — 헤더에만 존재 + - 수량/단가/LOT/검사상태 등 디테일 필드는 품목 1건만 변경됨 (정상) + - 해결하려면 `inbound_detail`에 `inbound_date` 컬럼 추가 필요 (미적용) + +### 2026-04-20 (2차) +- **생산입고 화면 DB 연동 (UI 껍데기 → 실연동)** + - 백엔드: `GET /api/receiving/source/production-results?processCode=XXX` 신규 추가 + - `work_order_process` + `work_instruction` + `item_info` JOIN + - 필터: `process_code` 일치, `good_qty > 0` (실적 등록됨), `target_warehouse_id IS NULL` (미입고), `parent_process_id IS NULL` (마스터만), 리워크 제외 + - 반환 필드: `work_instruction_no`, `order_date`, `process_code/name`, `item_code/name`, `spec`, `material`, `order_qty`(=good_qty+concession_qty), `remain_qty`, `source_table='work_order_process'`, `inspection_type`, `image` + - 파일: `backend-node/src/controllers/receivingController.ts`, `backend-node/src/routes/receivingRoutes.ts` + - 백엔드: `receivingController.create` 소스 업데이트 분기 리팩터 + - 기존: `inbound_type === '구매입고'` 문자열 체크 + - 변경: `source_table` 기준 if-else-if 체인 (`purchase_order_mng` / `purchase_detail` / `work_order_process`) + - 생산입고 처리: `work_order_process.target_warehouse_id` 세팅 (이중 입고 방지) + - 미처리 소스 테이블: `logger.warn`으로 기록 (추후 업데이트 로직 추가 필요 시 추적용) + - 파일: `backend-node/src/controllers/receivingController.ts` + - 프론트: `_components/inbound/ProductionInbound.tsx` + - `fetchOrders`: 빈 배열 → `apiClient.get("/receiving/source/production-results", { params: { processCode, keyword } })` + - 공정 선택 시 자동 재조회 (selectedSupplier 변경 감지) + - `editedQtys` 패턴 도입 (PurchaseInbound 동일): numpad 확인 → 로컬 수량만 변경, 담기 버튼 → 카트에 추가 + - `saveToDbRef` 추가 (stale closure 방지) + - 필드 라벨 "지시수량" → "양품수량" + - 필터링: supplier 기반 → API가 이미 processCode로 필터링하므로 키워드 필터만 유지 + - 검증: `tsc --noEmit` (backend), `npm run build` (frontend) 성공 + - 브라우저 검증: 미수행 (실제 실적 데이터 필요) + +### 2026-04-20 (3차) +- **생산관리 메뉴 페이지 신규 생성 (UI 껍데기, DB 미연동)** + - `production/page.tsx` 신규 생성 — 입고 메뉴(`inbound/page.tsx`) 구조 클론, 생산 용어로 전환 + - 뒤로가기 + "생산관리" 타이틀 + "메뉴를 선택하세요" 서브텍스트 + - KPI 캐러셀 3슬라이드 (금일 생산/진행 중/완료, 작업지시/공정완료/불량, 금일 수량/달성률/불량률) — 전부 `0` + - 생산 메뉴: **공정실행** 1개만 (amber gradient, `href: "#"` — 준비 중) + - 최근 생산활동: 빈 상태 ("최근 생산활동 내역이 없습니다") + - `main/page.tsx` 수정 — 생산 버튼 `href: "#"` → `/COMPANY_7/pop/production` + - 타입 체크(tsc --noEmit): 생산 관련 새 에러 없음 + - 브라우저 검증: 미수행 + +### 2026-04-20 (4차) +- **공정실행 페이지 신규 생성 (스크린샷 기반 UI 껍데기, 구 POP 참조 X)** + - `production/process/page.tsx` 신규 생성 — 구 POP 컴포넌트 복사/import 없이 스크린샷만 보고 직접 구성 + - 상단 row: 카드 열 버튼(1열/2열/3열, 2열 기본 선택) + 새로고침 버튼(blue border) + - 필터 row: 공정 선택 드롭다운(톱니 아이콘) + 설비 선택 드롭다운(깃발 아이콘) — 핸들러 stub + - 탭 바: 접수가능(amber) / 진행중(blue) / 대기(gray) / 완료(green) — 하드코딩 건수(59/3/11/17) + - 빈 상태: clipboard 아이콘(amber) + "공정을 선택해주세요" + - 전부 로컬 state만, `apiClient`/`dataApi`/`useAuth` 사용 없음, 핸들러는 `// TODO: API 연결` 주석 + - `production/page.tsx` 수정 — 공정실행 버튼 `href: "#"` → `/COMPANY_7/pop/production/process` + - `work/[processId]/page.tsx`는 아직 미생성 (사용자 지시: 이번엔 공정실행 1화면만 구성) + - 타입 체크(tsc --noEmit): 새 에러 없음 + - 브라우저 검증: 미수행 + +### 2026-04-20 (5차) +- **공정실행 좌측 공정 선택 드롭다운 DB 연결** + - `production/process/page.tsx` 수정 — 좌측 공정용 `FilterSelect` 버튼 → `SupplierModal` 트리거 버튼으로 교체 + - `PROCESS_SOURCE` 상수 추가 (`process_mng` / `process_code` / `process_name`) + - `selectedProcess` state 타입: `string` → `Supplier | null` (공정코드+이름 동시 보관, 추후 탭/데이터 필터링용) + - `processModalOpen` boolean state 신규 추가 (모달 open/close 제어) + - `SupplierModal` 렌더: `title="공정 선택"`, `searchPlaceholder="공정명 또는 코드 검색..."` + - 우측 설비 드롭다운은 `FilterSelect` 그대로 유지 (변경 없음) + - 구현 방식: 생산입고(`ProductionInbound.tsx`)와 동일 패턴 — `SupplierModal` 재사용, 신규 모달 파일 생성 없음 + - 타입 체크(tsc --noEmit): 새 에러 없음 + - `npm run build`: 컴파일 성공, post-compile 단계에서 Turbopack 캐시 경고(변경 무관) + - 브라우저 검증: 미수행 — dev 서버 다운, 백엔드 재시작 금지 규칙 + +### 2026-04-20 (6차) +- **공정실행 우측 설비 선택 드롭다운 DB 연결 (공정별 필터링)** + - `_components/common/EquipmentModal.tsx` 신규 생성 — `SupplierModal` UI 클론, 데이터는 props로 주입받는 방식 + - props: `items`, `loading`, `open`, `onClose`, `onSelect`, `title`, `searchPlaceholder` + - 초성 그룹핑 + 가나다/ABC 정렬 + 검색 — SupplierModal `getChosung` 재사용 + - `production/process/page.tsx` 수정 + - `getProcessEquipments` from `lib/api/processInfo` import — 공정별 등록 설비 조회 API + - `selectedEquipment` state 타입: `string` → `EquipmentItem | null` (코드+이름 보관) + - `equipments`, `equipmentLoading`, `equipmentModalOpen` state 신규 추가 + - `selectedProcess?.customer_code` 변경 감지 `useEffect` — 공정 선택 시 `getProcessEquipments` 호출, 공정 해제 시 설비 목록 초기화 + - 우측 `FilterSelect` 버튼 → `EquipmentModal` 트리거 버튼으로 교체 (공정 미선택 시 disabled) + - 버튼 라벨: 선택됨 → 설비명 / 공정 미선택 → "공정 선택 후 설비 선택" / 공정만 선택 → "설비를 선택하세요" + - 데이터 흐름: `process_equipment` JOIN `equipment_mng` — `process_code` + `company_code` 필터 + - 타입 체크(tsc --noEmit): 새 에러 없음 + - `npm run build`: 미수행 (방금 전 5차에서 확인) + - 브라우저 검증: 미수행 — dev 서버 다운, 백엔드 재시작 금지 규칙 + +### 2026-04-20 (8차) — Phase A 완료 (공정실행 구 POP 이식) +- **작업 범위**: 플랜 `.claude/plans/pop-process-execution.md` Phase A 전 항목 (A-1 ~ A-8) +- **POP.md 0-2 원칙 예외 적용**: 이번 공정실행 작업에 한해 "구 POP 컴포넌트 복사/import 금지" 예외 허용 (사용자 승인). 0-2 본문은 유지 — 다른 화면에는 계속 적용. +- **파일 변경** + - 복사: `components/pop/hardcoded/production/` 의 WorkOrderList/ProcessWork/AcceptProcessModal/ProcessDetailModal/DefectTypeModal/ProcessTimer → `_components/production/` (6개) + - 신규: `_components/production/useProcessData.ts` — 1회 sync + 3초 쿨다운 + sonner toast + 동시호출 방지(inFlight) + - 수정: `WorkOrderList.tsx` — 내부 fetchAll/syncAndFetch/localStorage/필터 UI/FilterSelectorModal 제거, props 기반으로 전환, mutation 후 `refetch()` 사용 + - 수정: `production/process/page.tsx` — useProcessData 연결, 새로고침 버튼(ArrowPath SVG + animate-spin + blue border), 카드 열 localStorage `pop-new-workorder-cols` (기본 2열, 구 POP `workorder-card-cols`와 독립), `WorkOrderList` 렌더 + - 수정: `hooks/pop/usePopSettings.ts` — `/COMPANY_7/pop/production/process` URL → screenId 7, settingsKey `processExecution` 매핑 추가 + - 재사용: `_components/common/ConfirmModal.tsx` 기존 파일 그대로 사용 (복사 생략) +- **데이터 갱신 정책 (구 POP 대비 개선)** + - 구 POP의 이중 sync POST 제거: 진입 시 1회 + 수동 새로고침 시 1회 + 3초 쿨다운 + - mutation(accept-process/cancel-accept) 성공 후 `sync` 없이 `refetch`만 수행 + - sonner toast 경유 에러 노출 (기존 `silent catch` 제거) +- **브라우저 E2E 검증 (playwright, topseal_admin / COMPANY_7)** + - 진입 sync POST 1회 / 재진입 1회 / 연타 3회 → 1회(쿨다운) / 3초 경과 후 1회 추가 → 정상 + - 필터(공정/설비) 조작: sync POST 증가 0회 + - 리워크 접수(accept-process POST) → sync POST 증가 0회 → 진행중 탭 이동 확인 + - 접수취소(cancel-accept POST, ConfirmModal 경유) → sync POST 증가 0회 → 원복 확인 + - 콘솔 에러 0, tsc 신규 에러 0 (baseline 3143 → 3090), `npm run build` 성공 + - 카드 열 토글: 기본 2열 확인, 3열 변경 → localStorage `pop-new-workorder-cols="3"` 저장 → 페이지 재진입 유지 확인, 구 POP 키 미생성 +- **[알려진 이슈 — Phase C에서 처리 예정, 이번 Phase A 작업 범위 아님]** + - **이슈 #1 — `work_order_process.status` routing 미반영**: 서버 `sync-work-instructions`가 작업지시 생성 시 모든 seq의 공정을 초기 `status='acceptable'`로 한꺼번에 insert함. 전공정(이전 seq) 완료 여부에 따른 `waiting → acceptable` 전이 로직 없음. 결과적으로 UI 접수가능 탭에 "전공정양품=0, 접수가능=0"인 카드(예: CODE-00010 포장 공정, 직전 배합 공정 미완료 상태)가 항상 표시됨. 구 POP도 동일한 DB 상태로 동작했으므로 Phase A는 "동등 재현" 원칙 유지 (클라이언트 필터 임시 패치 없음). + - **이슈 #2 — 중복 마스터 레코드**: 같은 `wo_id + seq_no + batch_id` 조합의 `parent_process_id IS NULL` 마스터 레코드가 복수 존재. 예: CODE-00010의 seq 1~3이 각각 2개씩 (batch `B_1002A_005`, status 동일 `acceptable`). `sync-work-instructions` POST 재실행 시 UPSERT가 아닌 INSERT가 누적되는 것으로 추정. + - **이슈 #3 — 리워크 카드 공정 필터 무시로 인한 오노출**: WorkOrderList 복사본(원본 L1300-1301)의 "재작업 카드는 공정 필터 무시 — 모든 공정에서 표시" 로직. 예: CODE-00011은 `제조반_계량` 공정에서 불량이 발생한 리워크인데, 사용자가 `제조반_포장` 공정을 필터링 중일 때도 접수가능 탭에 노출됨. 리워크 카드는 원래 불량이 발생한 공정(또는 routing상 지정된 별도 재작업 공정)과 연결되어 표시되어야 할 가능성. 정확한 비즈니스 룰 확인 필요. +- **Phase C 플랜 반영 예정**: 위 3개 이슈를 `.claude/plans/pop-process-execution.md` Phase C(서버 응답 필드 보강) 섹션에 추가 예정 + +### 2026-04-20 (7차) +- **공정실행 화면 상단에 뒤로가기 + 타이틀 추가** + - `production/process/page.tsx` 수정 — 최상단에 뒤로가기 + "공정실행" 타이틀 행 추가 + - 뒤로가기 버튼(`w-10 h-10 rounded-xl`, 흰 배경 + gray-200 border) → `/COMPANY_7/pop/production` + - 타이틀 "공정실행" (서브텍스트 없음, 사용자 지시) + - `useRouter` import 추가 + - 타입 체크(tsc --noEmit): 새 에러 없음 + - 브라우저 검증: 미수행 + +### 2026-04-20 (9차) +- **출고관리 화면 API 연동 (UI 껍데기 → 실연동, 입고관리 로직 포팅)** + - `_components/outbound/OutboundManage.tsx` 전면 rewrite + - InboundManage.tsx 로직 그대로 포팅, 테이블 연결만 출고용으로 교체 + - 사용자 지시: "입고 로직에서 테이블 연결만 건들고 나머지는 그대로, 색상은 기존 emerald 유지" + - API 전환: `getReceivingList/updateReceiving/deleteReceiving/getReceivingWarehouses` → `getOutboundList/updateOutbound/deleteOutbound/getOutboundWarehouses` + - 필드 매핑: `inbound_*` → `outbound_*`, `supplier_*` → `customer_*`, `manager` → `manager_id` + - 타입 옵션 교체: INBOUND_TYPE_OPTIONS(10개) → OUTBOUND_TYPE_OPTIONS(판매/생산/외주/사급/반품/기타/재고이동) + - 상태 옵션: "입고완료/부분입고/대기" → "출고완료/부분출고/대기" + - **검사 필드 제거**: `inspection_status`, `inspector` — OutboundItem 타입에 없음 (출고에 검사 개념 없음) + - SupplierModal 재사용 (`customer_code/customer_name` 기반, props 타입 호환) + - 색상 테마: 기존 출고 emerald 유지 (blue 계열 전부 emerald로, gradient `#60a5fa→#2563eb` → `#34d399→#059669`) + - 네비게이션: `router.push("/COMPANY_7/pop/outbound")` (뒤로가기) + - 수정 모달 필드: 출고일, 출고상태, 수량, 단가, 금액(자동계산), LOT번호, 창고(DB 드롭다운), 위치, 담당자, 메모 + - 삭제 로직: 복수 선택 → 헤더 ID 중복 제거 → 순차 `deleteOutbound` (재고 롤백 메시지 유지) + - 검증: + - `tsc --noEmit`: OutboundManage 관련 새 에러 없음 + - `npm run build`: 성공 + - 브라우저 검증: 미수행 — 실제 출고 데이터 필요 +- 백엔드/API 클라이언트 수정 없음 (기존 `outboundRoutes.ts` + `lib/api/outbound.ts` 그대로 활용) diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx new file mode 100644 index 00000000..40287832 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface AcceptProcessModalProps { + open: boolean; + onClose: () => void; + onConfirm: (qty: number) => void; + maxQty: number; + processName: string; + seqNo: string; + loading?: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Numpad Keys */ +/* ------------------------------------------------------------------ */ + +const KEYS = [ + { label: "7", action: "7" }, + { label: "8", action: "8" }, + { label: "9", action: "9" }, + { label: "\u2190", action: "backspace" }, + { label: "4", action: "4" }, + { label: "5", action: "5" }, + { label: "6", action: "6" }, + { label: "C", action: "clear" }, + { label: "1", action: "1" }, + { label: "2", action: "2" }, + { label: "3", action: "3" }, + { label: "MAX", action: "max" }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function AcceptProcessModal({ + open, + onClose, + onConfirm, + maxQty, + processName, + seqNo, + loading = false, +}: AcceptProcessModalProps) { + const [qty, setQty] = useState("0"); + + useEffect(() => { + if (open) { + setQty("0"); + } + }, [open]); + + const qtyNum = parseInt(qty, 10) || 0; + const isOverMax = qtyNum > maxQty; + + const handleKey = useCallback( + (key: string) => { + setQty((prev) => { + switch (key) { + case "backspace": + return prev.length <= 1 ? "0" : prev.slice(0, -1); + case "clear": + return "0"; + case "max": + return String(maxQty); + default: { + const next = prev === "0" ? key : prev + key; + const num = parseInt(next, 10); + if (isNaN(num)) return prev; + return next; + } + } + }); + }, + [maxQty] + ); + + const handleConfirm = () => { + const finalQty = Math.min(qtyNum, maxQty); + if (finalQty <= 0) return; + onConfirm(finalQty); + }; + + if (!open) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+ 공정 접수 + + 최대 {maxQty.toLocaleString()} EA + +
+

+ {seqNo}공정: {processName} +

+
+ + {/* Body */} +
+

+ 접수 수량 입력 +

+

+ 접수할 수량(EA)을 입력하세요 +

+ + {/* Display */} + + + {isOverMax && ( +

+ 접수가능량({maxQty.toLocaleString()})을 초과합니다 +

+ )} + + {/* Numpad */} +
+ {KEYS.map((key) => ( + + ))} + + {/* Bottom row */} + + +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/DefectTypeModal.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/DefectTypeModal.tsx new file mode 100644 index 00000000..5956288a --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/DefectTypeModal.tsx @@ -0,0 +1,365 @@ +"use client"; + +import React, { useState, useEffect } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface DefectType { + id: string; + defect_code: string; + defect_name: string; + defect_type: string; + severity: string; +} + +export interface DefectEntry { + defect_code: string; + defect_name: string; + qty: string; + disposition: "scrap" | "rework" | "accept"; +} + +interface DefectTypeModalProps { + open: boolean; + onClose: () => void; + onConfirm: (entries: DefectEntry[]) => void; + defectTypes: DefectType[]; + maxQty: number; + initialEntries?: DefectEntry[]; +} + +/* ------------------------------------------------------------------ */ +/* Simple Number Input with Stepper */ +/* ------------------------------------------------------------------ */ + +function QtyInput({ + value, + onChange, + max, +}: { + value: number; + onChange: (v: number) => void; + max: number; +}) { + const [padOpen, setPadOpen] = useState(false); + const [padValue, setPadValue] = useState(String(value)); + + const handlePadOpen = () => { + setPadValue(""); + setPadOpen(true); + }; + + const handlePadKey = (key: string) => { + if (key === "backspace") { + setPadValue((prev) => prev.length > 1 ? prev.slice(0, -1) : "0"); + } else if (key === "clear") { + setPadValue("0"); + } else if (key === "max") { + setPadValue(String(max)); + } else { + setPadValue((prev) => prev === "0" ? key : prev + key); + } + }; + + const handlePadConfirm = () => { + const num = Math.min(Math.max(0, parseInt(padValue, 10) || 0), max); + onChange(num); + setPadOpen(false); + }; + + return ( + <> +
+ + + +
+ + {/* 숫자 키패드 모달 */} + {padOpen && ( +
+
setPadOpen(false)} /> +
+
+

불량 수량 (최대 {max})

+

{padValue || "0"}

+
+
+ {["1","2","3","4","5","6","7","8","9"].map((k) => ( + + ))} + + + +
+ +
+ + +
+
+
+ )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Disposition labels */ +/* ------------------------------------------------------------------ */ + +const DISPOSITIONS: { value: DefectEntry["disposition"]; label: string; color: string }[] = [ + { value: "scrap", label: "폐기", color: "bg-red-100 text-red-700 border-red-200" }, + { value: "rework", label: "재작업", color: "bg-amber-100 text-amber-700 border-amber-200" }, + { value: "accept", label: "특채", color: "bg-blue-100 text-blue-700 border-blue-200" }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function DefectTypeModal({ + open, + onClose, + onConfirm, + defectTypes, + maxQty, + initialEntries, +}: DefectTypeModalProps) { + const [entries, setEntries] = useState< + Array<{ defect_code: string; defect_name: string; qty: number; disposition: DefectEntry["disposition"] }> + >([]); + + useEffect(() => { + if (open) { + if (initialEntries && initialEntries.length > 0) { + setEntries( + initialEntries.map((e) => ({ + ...e, + qty: parseInt(e.qty, 10) || 0, + })) + ); + } else { + setEntries([]); + } + } + }, [open, initialEntries]); + + const totalDefectQty = entries.reduce((sum, e) => sum + e.qty, 0); + + const addEntry = (dt: DefectType) => { + // If already exists, just increment + const existing = entries.find((e) => e.defect_code === dt.defect_code); + if (existing) { + setEntries((prev) => + prev.map((e) => + e.defect_code === dt.defect_code ? { ...e, qty: Math.min(e.qty + 1, maxQty) } : e + ) + ); + } else { + setEntries((prev) => [ + ...prev, + { + defect_code: dt.defect_code, + defect_name: dt.defect_name, + qty: 1, + disposition: "scrap" as const, + }, + ]); + } + }; + + const removeEntry = (code: string) => { + setEntries((prev) => prev.filter((e) => e.defect_code !== code)); + }; + + const updateQty = (code: string, qty: number) => { + setEntries((prev) => + prev.map((e) => (e.defect_code === code ? { ...e, qty } : e)) + ); + }; + + const updateDisposition = (code: string, disposition: DefectEntry["disposition"]) => { + setEntries((prev) => + prev.map((e) => (e.defect_code === code ? { ...e, disposition } : e)) + ); + }; + + const handleConfirm = () => { + const validEntries = entries.filter((e) => e.qty > 0); + onConfirm( + validEntries.map((e) => ({ + defect_code: e.defect_code, + defect_name: e.defect_name, + qty: String(e.qty), + disposition: e.disposition, + })) + ); + onClose(); + }; + + if (!open) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Panel */} +
+ {/* Header */} +
+ 불량 유형 선택 +
+ + 총 {totalDefectQty}개 + + +
+
+ + {/* Body */} +
+ {/* Defect type selection */} +

불량 유형 선택

+
+ {defectTypes.map((dt) => { + const isSelected = entries.some((e) => e.defect_code === dt.defect_code); + return ( + + ); + })} + {defectTypes.length === 0 && ( +
+ 등록된 불량 유형이 없습니다 +
+ )} +
+ + {/* Selected entries */} + {entries.length > 0 && ( + <> +

불량 상세

+
+ {entries.map((entry) => ( +
+
+ {entry.defect_name} + +
+ + {/* Qty */} +
+ 수량 + updateQty(entry.defect_code, v)} + max={maxQty} + /> +
+ + {/* Disposition */} +
+ {DISPOSITIONS.map((d) => ( + + ))} +
+
+ ))} +
+ + )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessDetailModal.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessDetailModal.tsx new file mode 100644 index 00000000..26022c4a --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessDetailModal.tsx @@ -0,0 +1,230 @@ +"use client"; + +import React from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface ProcessStep { + no: number; + name: string; + code: string; + status: "completed" | "in_progress" | "waiting" | "acceptable"; + inputQty: number; + goodQty: number; + defectQty: number; + planQty: number; + availableQty: number; +} + +interface ProcessDetailModalProps { + open: boolean; + onClose: () => void; + workInstructionNo: string; + totalQty: number; + steps: ProcessStep[]; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function ProcessDetailModal({ + open, + onClose, + workInstructionNo, + totalQty, + steps, +}: ProcessDetailModalProps) { + if (!open) return null; + + const completedCount = steps.filter((s) => s.status === "completed").length; + const overallPct = steps.length > 0 ? Math.round((completedCount / steps.length) * 100) : 0; + + return ( +
+
+ {/* Header */} +
+
+

공정 순서 상세

+

{workInstructionNo}

+
+ +
+ + {/* Summary bar */} +
+
+
+ 작업지시 총량 + + {totalQty.toLocaleString()} EA + +
+
+
+
+
+ + {completedCount}/{steps.length} 공정 + +
+
+
+ + {/* Steps */} +
+ {steps.map((s) => { + const borderColor = + s.status === "completed" + ? "border-green-400 bg-green-50" + : s.status === "in_progress" + ? "border-blue-400 bg-blue-50" + : s.status === "acceptable" + ? "border-amber-400 bg-amber-50" + : "border-gray-200 bg-gray-50"; + + const dotColor = + s.status === "completed" + ? "bg-green-500" + : s.status === "in_progress" + ? "bg-blue-500" + : s.status === "acceptable" + ? "bg-amber-500" + : "bg-gray-400"; + + const badge = + s.status === "completed" ? ( + 완료 + ) : s.status === "in_progress" ? ( + 진행중 + ) : s.status === "acceptable" ? ( + 접수가능 + ) : ( + 대기 + ); + + const barColor = + s.status === "completed" + ? "bg-green-500" + : s.status === "in_progress" + ? "bg-blue-500" + : s.status === "acceptable" + ? "bg-amber-500" + : "bg-gray-300"; + + const barTextColor = + s.status === "completed" + ? "text-green-600" + : s.status === "in_progress" + ? "text-blue-600" + : s.status === "acceptable" + ? "text-amber-600" + : "text-gray-400"; + + const pct = s.planQty > 0 ? Math.round((s.inputQty / s.planQty) * 100) : 0; + const unaccept = s.planQty - s.inputQty; + + return ( +
+ {/* Header */} +
+
+ {s.no} +
+
+
+ {s.name} + {badge} +
+ {s.code} +
+
+ + {/* Progress bar */} +
+
+
+
+
+ {pct}% +
+
+ + {/* Qty grid */} +
+
+
지시
+
{s.planQty.toLocaleString()}
+
+
+
접수
+
{s.inputQty.toLocaleString()}
+
+
+
양품
+
{s.goodQty.toLocaleString()}
+
+ {s.status === "completed" || s.status === "in_progress" ? ( +
+
불량
+
{s.defectQty.toLocaleString()}
+
+ ) : ( +
+
미접수
+
{Math.max(0, unaccept).toLocaleString()}
+
+ )} +
+ + {/* Additional accept qty */} + {s.status === "in_progress" && s.availableQty > 0 && ( +
+ + 추가접수가능 {s.availableQty.toLocaleString()} + +
+ )} + {s.status === "acceptable" && s.availableQty > 0 && ( +
+ + 접수가능 {s.availableQty.toLocaleString()} + +
+ )} +
+ ); + })} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessTimer.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessTimer.tsx new file mode 100644 index 00000000..65a2bec0 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessTimer.tsx @@ -0,0 +1,243 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type TimerStatus = "idle" | "running" | "paused" | "completed"; + +interface ProcessTimerProps { + status: TimerStatus; + /** ISO string or epoch — when the timer was first started */ + startedAt: string | null; + /** ISO string or epoch — when paused (null if not paused) */ + pausedAt: string | null; + /** Total paused seconds accumulated before current pause */ + totalPausedSeconds: number; + /** Completed at timestamp */ + completedAt: string | null; + /** Actual work time in seconds (from server, used when completed) */ + actualWorkTime: number | null; + onStart: () => void; + onPause: () => void; + onResume: () => void; + onComplete: () => void; + disabled?: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function formatTime(totalSeconds: number): string { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function ProcessTimer({ + status, + startedAt, + pausedAt, + totalPausedSeconds, + completedAt, + actualWorkTime, + onStart, + onPause, + onResume, + onComplete, + disabled = false, +}: ProcessTimerProps) { + const [elapsed, setElapsed] = useState(0); + const intervalRef = useRef(null); + + const calcElapsed = useCallback(() => { + if (!startedAt) return 0; + const start = new Date(startedAt).getTime(); + const now = Date.now(); + let pausedMs = totalPausedSeconds * 1000; + + // If currently paused, add time since pause started + if (pausedAt) { + const pauseStart = new Date(pausedAt).getTime(); + pausedMs += now - pauseStart; + } + + return Math.max(0, Math.floor((now - start - pausedMs) / 1000)); + }, [startedAt, pausedAt, totalPausedSeconds]); + + useEffect(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + if (status === "completed" && actualWorkTime !== null) { + setElapsed(actualWorkTime); + return; + } + + if (status === "running") { + setElapsed(calcElapsed()); + intervalRef.current = setInterval(() => { + setElapsed(calcElapsed()); + }, 1000); + } else if (status === "paused") { + setElapsed(calcElapsed()); + } else if (status === "idle") { + setElapsed(0); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [status, startedAt, pausedAt, totalPausedSeconds, actualWorkTime, calcElapsed]); + + /* Color by status */ + const colorMap: Record = { + idle: { bg: "bg-gray-50", text: "text-gray-400", border: "border-gray-200", ring: "ring-gray-200" }, + running: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200", ring: "ring-blue-300" }, + paused: { bg: "bg-amber-50", text: "text-amber-600", border: "border-amber-200", ring: "ring-amber-300" }, + completed: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200", ring: "ring-green-300" }, + }; + + const colors = colorMap[status]; + const statusLabels: Record = { + idle: "대기", + running: "진행중", + paused: "일시정지", + completed: "완료", + }; + + return ( +
+ {/* Status badge */} +
+ + {statusLabels[status]} + + {status === "running" && ( + + + 작업중 + + )} +
+ + {/* Timer display */} +
+

+ {formatTime(elapsed)} +

+ {startedAt && ( +

+ 시작: {new Date(startedAt).toLocaleTimeString("ko-KR")} + {completedAt && ` | 종료: ${new Date(completedAt).toLocaleTimeString("ko-KR")}`} +

+ )} +
+ + {/* Buttons */} +
+ {status === "idle" && ( + + )} + + {status === "running" && ( + <> + + + + )} + + {status === "paused" && ( + <> + + + + )} + + {status === "completed" && ( +
+ + + + 작업 완료 +
+ )} +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx new file mode 100644 index 00000000..a339593b --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx @@ -0,0 +1,2637 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { usePopSettings } from "@/hooks/pop/usePopSettings"; +import { apiClient } from "@/lib/api/client"; +import { dataApi } from "@/lib/api/data"; +import { ConfirmModal } from "../common/ConfirmModal"; +import { + type DefectEntry, + type DefectType, + DefectTypeModal, +} from "./DefectTypeModal"; +import { ProcessTimer, type TimerStatus } from "./ProcessTimer"; +import { MaterialInputSection } from "./sections/MaterialInputSection"; + +/* ================================================================== */ +/* Types */ +/* ================================================================== */ + +interface ProcessData { + id: string; + wo_id: string; + seq_no: string; + process_code: string; + process_name: string; + status: string; + plan_qty: string; + input_qty: string; + good_qty: string; + defect_qty: string; + concession_qty: string; + total_production_qty: string; + parent_process_id: string | null; + result_status: string; + result_note: string; + started_at: string | null; + paused_at: string | null; + total_paused_time: string | null; + completed_at: string | null; + actual_work_time: string | null; + accepted_at: string | null; + accepted_by: string | null; + defect_detail: string | null; + target_warehouse_id: string | null; + target_location_code: string | null; + is_rework: string; + routing_detail_id: string | null; + batch_id?: string | null; +} + +interface WorkInstructionInfo { + work_instruction_no: string; + item_name: string; + item_code: string; + qty: number; +} + +interface ChecklistItem { + id: string; + work_order_process_id: string; + source_work_item_id: string; + source_detail_id: string; + work_phase: string; + item_title: string; + item_sort_order: string; + detail_content: string; + detail_type: string; + detail_label: string | null; + detail_sort_order: string; + is_required: string | null; + result_value: string | null; + is_passed: string | null; + status: string; + spec_value: string | null; + inspection_code: string | null; + inspection_method: string | null; + unit: string | null; + lower_limit: string | null; + upper_limit: string | null; + input_type: string | null; + group_started_at: string | null; + group_paused_at: string | null; + group_total_paused_time: string | null; + group_completed_at: string | null; + recorded_by: string | null; + recorded_at: string | null; + started_at: string | null; +} + +interface Warehouse { + id: string; + warehouse_code: string; + warehouse_name: string; +} + +interface WarehouseLocation { + id: string; + location_code: string; + location_name: string; +} + +interface BatchHistoryItem { + seq: number; + batch_qty: number; + batch_good: number; + batch_defect: number; + accumulated_total: number; + changed_at: string; + changed_by: string | null; +} + +type ActiveSection = "checklist" | "result" | "inventory" | "material"; + +const PHASE_ORDER: Record = { PRE: 1, IN: 2, POST: 3 }; +const PHASE_LABELS: Record = { + PRE: "작업 전", + IN: "작업 중", + POST: "작업 후", +}; + +/* ================================================================== */ +/* ISA-101 Design Tokens */ +/* ================================================================== */ + +const DESIGN = { + bg: { + page: "#F5F5F5", + card: "#FFFFFF", + header: "#1a1a2e", + infoBar: "#1a1a2e", + }, + sidebar: { width: 280 }, + timer: { fontSize: 48 }, + button: { height: 60, touchMin: 48 }, + input: { height: 52 }, + footer: { height: 64 }, +} as const; + +/* ================================================================== */ +/* Numpad Modal */ +/* ================================================================== */ + +const KEYS = [ + { label: "7", action: "7" }, + { label: "8", action: "8" }, + { label: "9", action: "9" }, + { label: "\u2190", action: "backspace" }, + { label: "4", action: "4" }, + { label: "5", action: "5" }, + { label: "6", action: "6" }, + { label: "C", action: "clear" }, + { label: "1", action: "1" }, + { label: "2", action: "2" }, + { label: "3", action: "3" }, + { label: "MAX", action: "max" }, +]; + +function SimpleNumpadModal({ + open, + onClose, + onConfirm, + maxQty, + title, + subtitle, +}: { + open: boolean; + onClose: () => void; + onConfirm: (qty: number) => void; + maxQty: number; + title: string; + subtitle: string; +}) { + const [qty, setQty] = useState("0"); + + useEffect(() => { + if (open) setQty("0"); + }, [open]); + + const qtyNum = parseInt(qty, 10) || 0; + + const handleKey = useCallback( + (key: string) => { + setQty((prev) => { + switch (key) { + case "backspace": + return prev.length <= 1 ? "0" : prev.slice(0, -1); + case "clear": + return "0"; + case "max": + return String(maxQty); + default: { + const next = prev === "0" ? key : prev + key; + const num = parseInt(next, 10); + if (isNaN(num)) return prev; + return next; + } + } + }); + }, + [maxQty], + ); + + if (!open) return null; + + return ( +
+
+
+
+ {title} +

{subtitle}

+
+
+ +
+ {KEYS.map((key) => ( + + ))} + + +
+
+
+
+ ); +} + +/* ================================================================== */ +/* Checklist Group */ +/* ================================================================== */ + +interface ChecklistGroup { + phase: string; + title: string; + itemId: string; + sortOrder: number; + items: ChecklistItem[]; + completed: number; + total: number; + timerStarted: boolean; + timerCompleted: boolean; + timerPaused: boolean; + timerState: { + startedAt: string | null; + pausedAt: string | null; + totalPausedTime: number; + completedAt: string | null; + }; +} + +/* ================================================================== */ +/* Helper: formatTime */ +/* ================================================================== */ + +function formatTime(totalSeconds: number): string { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function calcGroupWorkSeconds(timer: ChecklistGroup["timerState"]): number { + if (!timer.startedAt) return 0; + const now = Date.now(); + const start = new Date(timer.startedAt).getTime(); + const end = timer.completedAt ? new Date(timer.completedAt).getTime() : now; + let pausedMs = timer.totalPausedTime * 1000; + if (timer.pausedAt && !timer.completedAt) { + pausedMs += now - new Date(timer.pausedAt).getTime(); + } + return Math.max(0, Math.floor((end - start - pausedMs) / 1000)); +} + +/* ================================================================== */ +/* Main Component */ +/* ================================================================== */ + +interface ProcessWorkProps { + processId: string; +} + +export function ProcessWork({ processId }: ProcessWorkProps) { + const router = useRouter(); + const contentRef = useRef(null); + + /* ---- Core State ---- */ + const { settings: popSettings } = usePopSettings(); + const peSettings = popSettings.screens.processExecution; + const [process, setProcess] = useState(null); + const [wiInfo, setWiInfo] = useState(null); + const [checklist, setChecklist] = useState([]); + const [defectTypes, setDefectTypes] = useState([]); + const [processList, setProcessList] = useState< + Array<{ process_code: string; process_name: string }> + >([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + /* ---- Navigation State ---- */ + const [activeSection, setActiveSection] = + useState("checklist"); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + /* ---- Timer tick ---- */ + const [tick, setTick] = useState(Date.now()); + + /* ---- Production Input ---- */ + const [productionQty, setProductionQty] = useState(0); + const [defectEntries, setDefectEntries] = useState([]); + const [resultNote, setResultNote] = useState(""); + + /* ---- Modals ---- */ + const [prodQtyModal, setProdQtyModal] = useState(false); + const [defectModal, setDefectModal] = useState(false); + const [confirmModalState, setConfirmModalState] = useState<{ + open: boolean; + message: string; + title?: string; + confirmText?: string; + variant?: "primary" | "danger" | "success"; + onConfirm: () => void; + }>({ open: false, message: "", onConfirm: () => {} }); + const askConfirm = ( + message: string, + onConfirm: () => void, + opts?: { + title?: string; + confirmText?: string; + variant?: "primary" | "danger" | "success"; + }, + ) => { + setConfirmModalState({ + open: true, + message, + title: opts?.title, + confirmText: opts?.confirmText, + variant: opts?.variant, + onConfirm: () => { + setConfirmModalState((s) => ({ ...s, open: false })); + onConfirm(); + }, + }); + }; + + /* ---- Last Process / Warehouse ---- */ + const [isLastProcess, setIsLastProcess] = useState(false); + const [warehouses, setWarehouses] = useState([]); + const [warehouseLocations, setWarehouseLocations] = useState< + WarehouseLocation[] + >([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + const [selectedLocation, setSelectedLocation] = useState(""); + const [packageUnit, setPackageUnit] = useState(""); + const [inboundDone, setInboundDone] = useState(false); + + /* ---- Batch Badge (단일/다중품목) ---- */ + const [batchBadge, setBatchBadge] = useState<{ + isMulti: boolean; + index: number; + total: number; + itemType: string; + } | null>(null); + + /* ---- Batch History ---- */ + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + + /* ================================================================ */ + /* Data Fetch */ + /* ================================================================ */ + + const fetchProcess = useCallback(async () => { + setLoading(true); + try { + // 1. Fetch process data + const procRes = await dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: processId }, + }); + const procData = (procRes.data?.[0] ?? null) as ProcessData | null; + if (procData) { + setProcess(procData); + + // 2. Fetch work instruction info + if (procData.wo_id) { + try { + const wiRes = await dataApi.getTableData("work_instruction", { + size: 1, + filters: { id: procData.wo_id }, + }); + const wi = wiRes.data?.[0] as Record | undefined; + if (wi) { + let itemName = String(wi.item_name || ""); + let itemCode = String(wi.item_code || wi.item_number || ""); + + // item_name이 비어있고 item_id가 있으면 item_info에서 조회 + if (!itemName && wi.item_id) { + try { + const itemRes = await apiClient.get( + `/data/item_info/${wi.item_id}`, + ); + const item = itemRes.data?.data; + if (item) { + itemName = String(item.item_name || ""); + itemCode = String(item.item_number || item.item_code || ""); + } + } catch { + /* non-critical */ + } + } + + // batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원) + let batchItemType = ""; + if (procData.batch_id) { + try { + const batchItemRes = await dataApi.getTableData("item_info", { + size: 1, + filters: { item_number: procData.batch_id }, + }); + const batchItem = batchItemRes.data?.[0] as Record | undefined; + if (batchItem) { + itemName = String(batchItem.item_name || procData.batch_id); + itemCode = String(batchItem.item_number || procData.batch_id); + batchItemType = String(batchItem.type || ""); + } else { + itemName = procData.batch_id; + itemCode = procData.batch_id; + } + } catch { + itemName = procData.batch_id; + itemCode = procData.batch_id; + } + } + // item_type이 없으면 WI의 item_number로 조회 + if (!batchItemType && wi.item_number) { + try { + const wiItemRes = await dataApi.getTableData("item_info", { + size: 1, + filters: { item_number: String(wi.item_number) }, + }); + const wiItem = wiItemRes.data?.[0] as Record | undefined; + if (wiItem) { + batchItemType = String(wiItem.type || ""); + } + } catch { + /* non-critical */ + } + } + // batchItemType을 임시 저장 (step 6에서 사용) + (procData as unknown as Record)._itemType = batchItemType; + + setWiInfo({ + work_instruction_no: String(wi.work_instruction_no || ""), + item_name: itemName, + item_code: itemCode, + qty: parseInt(String(wi.qty), 10) || 0, + }); + } + } catch { + // Non-critical + } + } + } + + // 3. Fetch checklist (process_work_result) + const checkRes = await dataApi.getTableData("process_work_result", { + size: 500, + filters: { work_order_process_id: processId }, + }); + setChecklist((checkRes.data ?? []) as ChecklistItem[]); + + // 4. Defect types + try { + const dtRes = await apiClient.get("/pop/production/defect-types"); + setDefectTypes(dtRes.data?.data || []); + } catch { + setDefectTypes([]); + } + + // 5. Is last process + try { + const lpRes = await apiClient.get( + `/pop/production/is-last-process/${processId}`, + ); + const lpData = lpRes.data?.data; + setIsLastProcess(lpData?.isLast || false); + if (lpData?.targetWarehouseId) { + setSelectedWarehouse(lpData.targetWarehouseId); + setSelectedLocation(lpData.targetLocationCode || ""); + setInboundDone(true); + } + } catch { + setIsLastProcess(false); + } + + // 6. 같은 작업지시의 공정 목록 (재작업 공정 지정용) + if (procData?.wo_id) { + try { + const plRes = await dataApi.getTableData("work_order_process", { + size: 100, + filters: { wo_id: procData.wo_id }, + }); + const allSiblings = (plRes.data ?? []) as ProcessData[]; + const masters = allSiblings + .filter((p) => !p.parent_process_id) + .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); + // 중복 제거 + const seen = new Set(); + setProcessList( + masters.map((p) => ({ + process_code: p.process_code, + process_name: p.process_name, + })).filter((m) => { + if (seen.has(m.process_code)) return false; + seen.add(m.process_code); + return true; + }), + ); + // 다중품목 판단: 마스터 공정의 DISTINCT batch_id + const uniqueBatches: string[] = []; + for (const p of masters) { + const bid = p.batch_id || ""; + if (bid && !uniqueBatches.includes(bid)) { + uniqueBatches.push(bid); + } + } + const currentBid = procData?.batch_id || ""; + const isMultiBatch = uniqueBatches.length > 1; + const bIdx = currentBid ? uniqueBatches.indexOf(currentBid) + 1 : 1; + const fetchedItemType = String((procData as unknown as Record)?._itemType || ""); + setBatchBadge({ + isMulti: isMultiBatch, + index: Math.max(bIdx, 1), + total: Math.max(uniqueBatches.length, 1), + itemType: fetchedItemType, + }); + } catch { + setProcessList([]); + } + } + + // 7. Warehouses + try { + const whRes = await apiClient.get("/pop/production/warehouses"); + setWarehouses(whRes.data?.data || []); + } catch { + setWarehouses([]); + } + } catch (error) { + console.error("[ProcessWork] fetch error:", error); + } finally { + setLoading(false); + } + }, [processId]); + + useEffect(() => { + fetchProcess(); + }, [fetchProcess]); + + /* ---- Batch History ---- */ + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + try { + const res = await apiClient.get("/pop/production/result-history", { + params: { work_order_process_id: processId }, + }); + if (res.data?.success) { + setHistory(res.data.data || []); + } + } catch { + /* ignore */ + } finally { + setHistoryLoading(false); + } + }, [processId]); + + useEffect(() => { + loadHistory(); + }, [loadHistory]); + + /* ---- Timer tick for group timers ---- */ + useEffect(() => { + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + /* ================================================================ */ + /* Checklist Groups */ + /* ================================================================ */ + + const groups = useMemo(() => { + const map = new Map(); + for (const item of checklist) { + const key = item.source_work_item_id; + if (!map.has(key)) { + map.set(key, { + phase: item.work_phase, + title: item.item_title, + itemId: key, + sortOrder: parseInt(item.item_sort_order || "0", 10), + items: [], + completed: 0, + total: 0, + timerStarted: !!item.group_started_at, + timerCompleted: !!item.group_completed_at, + timerPaused: !!item.group_paused_at, + timerState: { + startedAt: item.group_started_at ?? null, + pausedAt: item.group_paused_at ?? null, + totalPausedTime: parseInt(item.group_total_paused_time || "0", 10), + completedAt: item.group_completed_at ?? null, + }, + }); + } + const g = map.get(key)!; + g.items.push(item); + g.total++; + if (item.status === "recorded" || item.status === "completed") + g.completed++; + // Update timer state (any row may have timer data) + if (item.group_started_at) { + g.timerStarted = true; + g.timerState.startedAt = item.group_started_at; + } + if (item.group_completed_at) { + g.timerCompleted = true; + g.timerState.completedAt = item.group_completed_at; + } + if (item.group_paused_at) { + g.timerPaused = true; + g.timerState.pausedAt = item.group_paused_at; + } + if (item.group_total_paused_time) { + g.timerState.totalPausedTime = + parseInt(item.group_total_paused_time, 10) || 0; + } + } + + return Array.from(map.values()).sort( + (a, b) => + (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || + a.sortOrder - b.sortOrder, + ); + }, [checklist]); + + const groupsByPhase = useMemo(() => { + const result: Record = {}; + for (const g of groups) { + if (!result[g.phase]) result[g.phase] = []; + result[g.phase].push(g); + } + return result; + }, [groups]); + + const availablePhases = useMemo(() => { + const phases: string[] = []; + if (groupsByPhase["PRE"]?.length) phases.push("PRE"); + if (groupsByPhase["IN"]?.length) phases.push("IN"); + if (groupsByPhase["POST"]?.length) phases.push("POST"); + return phases; + }, [groupsByPhase]); + + // Auto-select first group + useEffect(() => { + if (groups.length > 0 && !selectedGroupId) { + setSelectedGroupId(groups[0].itemId); + } + }, [groups, selectedGroupId]); + + const selectedGroup = useMemo( + () => groups.find((g) => g.itemId === selectedGroupId) || null, + [groups, selectedGroupId], + ); + + const currentItems = useMemo( + () => + selectedGroup?.items.sort( + (a, b) => + parseInt(a.detail_sort_order || "0", 10) - + parseInt(b.detail_sort_order || "0", 10), + ) || [], + [selectedGroup], + ); + + /* ================================================================ */ + /* Timer Handlers */ + /* ================================================================ */ + + const timerStatus: TimerStatus = (() => { + if (!process) return "idle"; + if (process.status === "completed") return "completed"; + if (process.paused_at) return "paused"; + if (process.started_at) return "running"; + return "idle"; + })(); + + const handleTimerAction = async ( + action: "start" | "pause" | "resume" | "complete", + ) => { + // 낙관적 업데이트: UI 즉시 반영 + setProcess((prev) => { + if (!prev) return prev; + const now = new Date().toISOString(); + if (action === "start") + return { + ...prev, + status: "in_progress", + started_at: now, + paused_at: null, + }; + if (action === "pause") return { ...prev, paused_at: now }; + if (action === "resume") { + const pausedSec = prev.paused_at + ? Math.floor((Date.now() - new Date(prev.paused_at).getTime()) / 1000) + : 0; + return { + ...prev, + paused_at: null, + total_paused_time: String( + (parseInt(prev.total_paused_time || "0", 10) || 0) + pausedSec, + ), + }; + } + if (action === "complete") + return { + ...prev, + status: "completed", + completed_at: now, + paused_at: null, + }; + return prev; + }); + + // API 백그라운드 호출 + try { + const body: Record = { + work_order_process_id: processId, + action, + }; + if (action === "complete") { + body.good_qty = process?.good_qty || "0"; + body.defect_qty = process?.defect_qty || "0"; + } + await apiClient.post("/pop/production/timer", body); + // 서버 데이터로 동기화 (조용히) + fetchProcess(); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + alert(err.response?.data?.message || "타이머 오류"); + fetchProcess(); // 실패 시 서버 상태로 복원 + } + }; + + /* ---- Item Timer (개별 행 단위 낙관적 업데이트) ---- */ + const handleItemTimerAction = ( + action: "start" | "pause" | "resume" | "complete", + itemId: string, + ) => { + // 로컬 체크리스트 상태 즉시 업데이트 (해당 id 1개만) + const now = new Date().toISOString(); + setChecklist((prev) => + prev.map((item) => { + if (item.id !== itemId) return item; + if (action === "start") + return { ...item, group_started_at: now, group_paused_at: null }; + if (action === "pause") return { ...item, group_paused_at: now }; + if (action === "resume") { + const pausedSec = item.group_paused_at + ? Math.floor( + (Date.now() - new Date(item.group_paused_at).getTime()) / 1000, + ) + : 0; + const prev_total = + parseInt(item.group_total_paused_time || "0", 10) || 0; + return { + ...item, + group_paused_at: null, + group_total_paused_time: String(prev_total + pausedSec), + }; + } + if (action === "complete") + return { ...item, group_completed_at: now, group_paused_at: null }; + return item; + }), + ); + + // API 백그라운드 (결과 무시, 실패 시만 동기화) + apiClient + .post("/pop/production/group-timer", { + item_id: itemId, + work_order_process_id: processId, + action, + }) + .catch(() => { + fetchProcess(); + }); + }; + + /* ---- Group timer display (removed: now per-item) ---- */ + + /* ================================================================ */ + /* Checklist Save */ + /* ================================================================ */ + + const handleChecklistSave = async ( + itemId: string, + resultValue: string, + isPassed: string | null, + ) => { + try { + const now = new Date().toISOString(); + await apiClient.post("/pop/execute-action", { + tasks: [ + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "result_value", + operationType: "assign", + valueSource: "fixed", + fixedValue: resultValue, + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "status", + operationType: "assign", + valueSource: "fixed", + fixedValue: "recorded", + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + ...(isPassed !== null + ? [ + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "is_passed", + operationType: "assign", + valueSource: "fixed", + fixedValue: isPassed, + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + ] + : []), + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "recorded_at", + operationType: "assign", + valueSource: "fixed", + fixedValue: now, + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + ], + data: { items: [{ id: itemId }], fieldValues: {} }, + }); + // Update local state + setChecklist((prev) => + prev.map((c) => + c.id === itemId + ? { + ...c, + result_value: resultValue, + is_passed: isPassed, + status: "recorded", + recorded_at: now, + } + : c, + ), + ); + } catch { + alert("체크리스트 저장 실패"); + } + }; + + /* ================================================================ */ + /* Save Result (batch-cumulative) */ + /* ================================================================ */ + + const handleSaveResult = async () => { + if (productionQty <= 0) { + alert("생산수량을 입력해주세요."); + return; + } + setSaving(true); + try { + const totalDefect = defectEntries.reduce( + (s, e) => s + parseInt(e.qty, 10), + 0, + ); + const res = await apiClient.post("/pop/production/save-result", { + work_order_process_id: processId, + production_qty: String(productionQty), + good_qty: String(productionQty - totalDefect), + defect_qty: String(totalDefect), + defect_detail: defectEntries.length > 0 ? defectEntries : undefined, + result_note: resultNote || undefined, + }); + if (res.data?.success) { + const d = res.data.data; + setProcess((prev) => { + if (!prev) return prev; + return { ...prev, ...d }; + }); + setProductionQty(0); + setDefectEntries([]); + setResultNote(""); + loadHistory(); + if (d?.status === "completed") { + alert("모든 수량이 완료되어 자동 확정되었습니다."); + } else { + alert("실적이 저장되었습니다."); + } + } else { + alert(res.data?.message || "저장 실패"); + } + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + alert(err.response?.data?.message || "실적 저장 중 오류"); + } finally { + setSaving(false); + } + }; + + /* ---- Confirm Result ---- */ + const handleConfirmResult = () => { + askConfirm( + "실적을 확정하시겠습니까?\n확정 후에는 추가 등록이 불가합니다.", + async () => { + setSaving(true); + try { + const res = await apiClient.post("/pop/production/confirm-result", { + work_order_process_id: processId, + }); + if (res.data?.success) { + await fetchProcess(); + alert("실적이 확정되었습니다."); + } else { + alert(res.data?.message || "확정 실패"); + } + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + alert(err.response?.data?.message || "확정 중 오류"); + } finally { + setSaving(false); + } + }, + { title: "실적 확정", confirmText: "확정", variant: "success" }, + ); + }; + + /* ================================================================ */ + /* Inventory Inbound */ + /* ================================================================ */ + + const fetchLocations = useCallback(async (warehouseId: string) => { + if (!warehouseId) { + setWarehouseLocations([]); + return; + } + try { + const res = await apiClient.get( + `/pop/production/warehouse-locations/${warehouseId}`, + ); + setWarehouseLocations(res.data?.data || []); + } catch { + setWarehouseLocations([]); + } + }, []); + + const handleInbound = () => { + if (!selectedWarehouse) { + alert("창고를 선택해주세요."); + return; + } + askConfirm( + "생산입고를 진행하시겠습니까?", + async () => { + setSaving(true); + try { + const wh = warehouses.find((w) => w.id === selectedWarehouse); + const warehouseCode = wh?.warehouse_code || selectedWarehouse; + const res = await apiClient.post( + "/pop/production/inventory-inbound", + { + work_order_process_id: processId, + warehouse_code: warehouseCode, + location_code: selectedLocation || undefined, + }, + ); + if (res.data?.success) { + setInboundDone(true); + alert(`재고 입고 완료: ${res.data.data?.qty || 0}개`); + } else { + alert(res.data?.message || "입고 실패"); + } + } catch (error: unknown) { + const err = error as { + response?: { data?: { message?: string }; status?: number }; + }; + const msg = err.response?.data?.message; + if (err.response?.status === 409) { + setInboundDone(true); + alert(msg || "이미 입고 완료"); + } else { + alert(msg || "입고 중 오류"); + } + } finally { + setSaving(false); + } + }, + { title: "생산 입고", confirmText: "입고", variant: "primary" }, + ); + }; + + /* ================================================================ */ + /* Computed Values */ + /* ================================================================ */ + + const totalDefectQty = defectEntries.reduce( + (s, e) => s + parseInt(e.qty, 10), + 0, + ); + const goodQtyThisBatch = productionQty - totalDefectQty; + const inputQty = parseInt(process?.input_qty || "0", 10); + const totalProduced = parseInt(process?.total_production_qty || "0", 10); + const accumulatedGood = parseInt(process?.good_qty || "0", 10); + const accumulatedDefect = parseInt(process?.defect_qty || "0", 10); + const remaining = Math.max(0, inputQty - totalProduced); + const isCompleted = process?.status === "completed"; + const isConfirmed = process?.result_status === "confirmed"; + const hasChecklist = checklist.length > 0; + + /* ================================================================ */ + /* Loading / Error */ + /* ================================================================ */ + + if (loading) { + return ( +
+
+
+ 공정 정보 로딩중... +
+
+ ); + } + + if (!process) { + return ( +
+

공정을 찾을 수 없습니다

+ +
+ ); + } + + /* ================================================================ */ + /* Render */ + /* ================================================================ */ + + return ( +
+ {/* ============================================================ */} + {/* Info Bar (Dark Header) */} + {/* ============================================================ */} +
+
+
+ {wiInfo && ( +
+ 작업지시 + + {wiInfo.work_instruction_no} + +
+ )} + {wiInfo && ( +
+ 품목 + + {wiInfo.item_name} + +
+ )} + {batchBadge && ( +
+ {batchBadge.isMulti ? ( + + 다중 {batchBadge.index}/{batchBadge.total}{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""} + + ) : ( + + 단일{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""} + + )} +
+ )} +
+ 공정 + + {process.seq_no ? `${process.seq_no}. ` : ""} + {process.process_name || "공정"} + +
+
+ 지시 + + {parseInt(process.plan_qty || "0", 10).toLocaleString()} + +
+
+ 접수 + + {inputQty.toLocaleString()} + +
+
+ {/* Status badge */} + + {isCompleted + ? "완료" + : process.status === "in_progress" + ? "진행중" + : process.status} + + {process.is_rework === "Y" && ( + + 재작업 + + )} +
+
+ + {/* ============================================================ */} + {/* Main Layout: Sidebar(left) + Timer+Content(right) */} + {/* ============================================================ */} + {(hasChecklist || !isConfirmed || (isLastProcess && !inboundDone)) && ( +
+ {/* ========================================================= */} + {/* Left Sidebar (always visible) */} + {/* ========================================================= */} +
+
+ {/* Checklist groups by phase */} + {availablePhases.map((phase) => { + const phaseGroups = groupsByPhase[phase] || []; + const phaseDone = phaseGroups.reduce( + (s, g) => s + g.completed, + 0, + ); + const phaseTotal = phaseGroups.reduce((s, g) => s + g.total, 0); + const allDone = phaseDone >= phaseTotal && phaseTotal > 0; + + return ( +
+
+
+ + + +
+ + {PHASE_LABELS[phase] || phase} + + + {phaseDone}/{phaseTotal} + +
+ {phaseGroups.map((g) => { + const isSelected = + selectedGroupId === g.itemId && + activeSection === "checklist"; + const isDone = g.completed >= g.total && g.total > 0; + return ( + + ); + })} +
+ ); + })} + + {/* Result section link */} + {!isConfirmed && ( +
+ {/* 자재 투입 (설정으로 제어) */} + {peSettings.materialInput && ( + + )} + + +
+ )} + + {/* Inventory section link */} + {isLastProcess && ( +
+ +
+ )} +
+
+ + {/* ========================================================= */} + {/* Mobile Tabs (hidden — sidebar always visible) */} + {/* ========================================================= */} +
+ {availablePhases.map((phase) => { + const phaseGroups = groupsByPhase[phase] || []; + const phaseDone = phaseGroups.reduce( + (s, g) => s + g.completed, + 0, + ); + const phaseTotal = phaseGroups.reduce((s, g) => s + g.total, 0); + const isActive = + activeSection === "checklist" && + selectedGroup && + selectedGroup.phase === phase; + return ( + + ); + })} + {!isConfirmed && ( + + )} + {isLastProcess && ( + + )} +
+ + {/* ========================================================= */} + {/* Right Column: Timer + Content */} + {/* ========================================================= */} +
+ {/* Group Summary Bar (요약 정보만 표시) */} +
+
+ + {selectedGroup?.title || "그룹 선택"} + + {selectedGroup && ( + = selectedGroup.total && + selectedGroup.total > 0 + ? "bg-green-100 text-green-700" + : "bg-gray-100 text-gray-500" + }`} + > + 완료 {selectedGroup.completed}/{selectedGroup.total} + + )} +
+
+ + {/* Content Area (scrollable) */} +
+ {/* Checklist Content */} + {activeSection === "checklist" && selectedGroup && ( +
+ {/* Group header with timer */} +
+
+

+ {PHASE_LABELS[selectedGroup.phase] || + selectedGroup.phase} +

+

+ {selectedGroup.title} +

+
+ = selectedGroup.total && + selectedGroup.total > 0 + ? "bg-green-100 text-green-700" + : selectedGroup.timerStarted + ? "bg-blue-100 text-blue-700" + : "bg-gray-100 text-gray-500" + }`} + > + {selectedGroup.completed}/{selectedGroup.total} + +
+ + {/* 그룹 타이머는 상단 통합 타이머로 이동 */} + + {/* Mobile group navigation (sidebar not visible) */} +
+ {(groupsByPhase[selectedGroup.phase] || []).map((g) => { + const isSelected = g.itemId === selectedGroupId; + const isDone = g.completed >= g.total && g.total > 0; + return ( + + ); + })} +
+ + {/* Checklist items */} +
+ {currentItems.map((item) => ( + + ))} + {currentItems.length === 0 && ( +

+ 체크리스트 항목이 없습니다 +

+ )} +
+
+ )} + + {/* ====== Material Input Content (설정) ====== */} + {activeSection === "material" && peSettings.materialInput && ( + + )} + + {/* ====== Result Content ====== */} + {activeSection === "result" && !isConfirmed && ( +
+

+ + + + 이번 차수 실적 입력 + {remaining > 0 && ( + + 잔여: {remaining.toLocaleString()} + + )} +

+ +
+ {/* Production Qty */} + + + {/* Good Qty (자동 계산) */} +
+ + 양품 + + + {goodQtyThisBatch > 0 + ? `${goodQtyThisBatch.toLocaleString()} EA` + : "0 EA"} + +
+ + {/* Defect */} + + + {/* Defect entries summary */} + {defectEntries.length > 0 && ( +
+ {defectEntries.map((de) => ( + + {de.defect_name}: {de.qty}개 ( + {de.disposition === "scrap" + ? "폐기" + : de.disposition === "rework" + ? "재작업" + : "특채"} + ) + + ))} +
+ )} + + {/* 누적 현황 */} + {totalProduced > 0 && ( +
+ 누적: {totalProduced}/{inputQty} ( + {Math.round((totalProduced / inputQty) * 100)}%) +
+ )} + + {/* Note */} +