diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index b526bc0f..62854788 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -227,10 +227,24 @@ export class AuthController { } } - // 새로운 JWT 토큰 발급 (company_code만 변경) + // 전환 대상 회사명 조회 + let targetCompanyName: string | undefined; + if (companyCode === "*") { + targetCompanyName = "공통"; + } else { + const { query: dbQuery } = await import("../database/db"); + const companyRows = await dbQuery<{ company_name: string }>( + "SELECT company_name FROM company_mng WHERE company_code = $1", + [companyCode.trim()] + ); + targetCompanyName = companyRows[0]?.company_name || companyCode.trim(); + } + + // 새로운 JWT 토큰 발급 (company_code + company_name 변경) const newPersonBean: PersonBean = { ...currentUser, - companyCode: companyCode.trim(), // 전환할 회사 코드로 변경 + companyCode: companyCode.trim(), + companyName: targetCompanyName, }; const newToken = JwtUtils.generateToken(newPersonBean); @@ -355,6 +369,7 @@ export class AuthController { deptName: dbUserInfo.deptName || "", companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선 company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선 + companyName: userInfo.companyName || dbUserInfo.companyName || "", // JWT 토큰 우선 (회사 전환 시 갱신됨) userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선 userTypeName: dbUserInfo.userTypeName || "일반사용자", email: dbUserInfo.email || "", diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index 037ab46a..811fdb60 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -161,6 +161,38 @@ export async function deletePkgUnit( } } +// ────────────────────────────────────────────── +// 품목별 포장단위 조회 (item_number → pkg_unit 목록) +// ────────────────────────────────────────────── + +export async function getPkgUnitsByItem( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { itemNumber } = req.params; + const pool = getPool(); + + const result = await pool.query( + `SELECT pu.id, pu.pkg_code, pu.pkg_name, pu.pkg_type, pu.status, + pu.width_mm, pu.length_mm, pu.height_mm, + pu.self_weight_kg, pu.max_load_kg, pu.volume_l, + pui.pkg_qty + FROM pkg_unit_item pui + JOIN pkg_unit pu ON pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code + WHERE pui.item_number = $1 AND pui.company_code = $2 AND pu.status = 'ACTIVE' + ORDER BY pu.pkg_name`, + [itemNumber, companyCode] + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("품목별 포장단위 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + // ────────────────────────────────────────────── // 포장단위 매칭품목 (pkg_unit_item) CRUD // ────────────────────────────────────────────── @@ -405,6 +437,38 @@ export async function deleteLoadingUnit( } } +// ────────────────────────────────────────────── +// 포장코드별 적재함 조회 (pkg_code → loading_unit 목록) +// ────────────────────────────────────────────── + +export async function getLoadingUnitsByPkg( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { pkgCode } = req.params; + const pool = getPool(); + + const result = await pool.query( + `SELECT lu.id, lu.loading_code, lu.loading_name, lu.loading_type, lu.status, + lu.width_mm, lu.length_mm, lu.height_mm, + lu.self_weight_kg, lu.max_load_kg, lu.max_stack, + lup.max_load_qty, lup.load_method + FROM loading_unit_pkg lup + JOIN loading_unit lu ON lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code + WHERE lup.pkg_code = $1 AND lup.company_code = $2 AND lu.status = 'ACTIVE' + ORDER BY lu.loading_name`, + [pkgCode, companyCode] + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("포장별 적재함 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + // ────────────────────────────────────────────── // 적재함 포장구성 (loading_unit_pkg) CRUD // ────────────────────────────────────────────── diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index ac1130bf..3c9cf758 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -155,8 +155,13 @@ export async function create(req: AuthenticatedRequest, res: Response) { .json({ success: false, message: "입고 품목이 없습니다." }); } - // 첫 번째 아이템에서 inbound_type 추출 (헤더용) - const inboundType = items[0].inbound_type || null; + // 헤더용 inbound_type: 단일이면 그 값, 혼합이면 "혼합입고" + const uniqueInboundTypes = [...new Set(items.map((i: any) => i.inbound_type).filter(Boolean))]; + const inboundType = uniqueInboundTypes.length === 1 + ? uniqueInboundTypes[0] + : uniqueInboundTypes.length > 1 + ? "혼합입고" + : (items[0].inbound_type || null); const inboundNumber = inbound_number || items[0].inbound_number; await client.query("BEGIN"); @@ -331,12 +336,11 @@ export async function create(req: AuthenticatedRequest, res: Response) { ); } - // 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지 - if ( - item.inbound_type === "구매입고" && - item.source_id && - item.source_table === "purchase_order_mng" - ) { + // 2c. source_table 기준 소스 데이터 업데이트 (이중 입고 방지) + const srcTable = item.source_table; + const srcId = item.source_id; + + if (srcTable === "purchase_order_mng" && srcId) { await client.query( `UPDATE purchase_order_mng SET received_qty = CAST( @@ -354,17 +358,9 @@ export async function create(req: AuthenticatedRequest, res: Response) { END, updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [item.inbound_qty || 0, item.source_id, companyCode], + [item.inbound_qty || 0, srcId, companyCode], ); - } - - // 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트 - if ( - item.inbound_type === "구매입고" && - item.source_id && - item.source_table === "purchase_detail" - ) { - // 1. 해당 purchase_detail의 received_qty 누적 업데이트 + } else if (srcTable === "purchase_detail" && srcId) { await client.query( `UPDATE purchase_detail SET received_qty = CAST( @@ -377,17 +373,15 @@ export async function create(req: AuthenticatedRequest, res: Response) { ), updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [item.inbound_qty || 0, item.source_id, companyCode], + [item.inbound_qty || 0, srcId, companyCode], ); - // 2. 발주 헤더 상태 업데이트 const detailInfo = await client.query( `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, - [item.source_id, companyCode], + [srcId, companyCode], ); if (detailInfo.rows.length > 0) { const purchaseNo = detailInfo.rows[0].purchase_no; - // 잔량 있는 디테일이 있는지 확인 const unreceived = await client.query( `SELECT id FROM purchase_detail WHERE purchase_no = $1 AND company_code = $2 @@ -419,6 +413,28 @@ export async function create(req: AuthenticatedRequest, res: Response) { [newStatus, purchaseNo, companyCode], ); } + } else if (srcTable === "work_order_process" && srcId) { + // 생산입고: target_warehouse_id 세팅 (이중 입고 방지) + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + await client.query( + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + AND target_warehouse_id IS NULL`, + [srcId, companyCode, whCode, locCode || null, userId], + ); + } else if (srcTable && srcId) { + // 미처리 소스 테이블 — 추후 업데이트 로직 추가 필요 + logger.warn("입고 소스 업데이트 미처리", { + source_table: srcTable, + source_id: srcId, + inbound_type: item.inbound_type, + item_number: item.item_number, + }); } } @@ -1044,6 +1060,8 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { si.instruction_no, si.instruction_date, si.partner_id, + si.partner_id AS partner_code, + COALESCE(cm.customer_name, si.partner_id) AS partner_name, si.status AS instruction_status, sid.item_code, sid.item_name, @@ -1056,6 +1074,9 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { JOIN shipment_instruction_detail sid ON si.id = sid.instruction_id AND si.company_code = sid.company_code + LEFT JOIN customer_mng cm + ON cm.customer_code = si.partner_id + AND cm.company_code = si.company_code WHERE ${whereClause} ORDER BY si.instruction_date DESC, si.instruction_no LIMIT ${limit} OFFSET ${offset}`, @@ -1126,6 +1147,88 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { } } +// 생산입고용: 실적이 등록된 작업지시 공정 데이터 조회 (미입고분) +export async function getProductionResults( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const { processCode, keyword, pageSize } = req.query; + + if (!processCode) { + return res + .status(400) + .json({ success: false, message: "processCode 필수" }); + } + + const limit = Math.min(500, Math.max(1, Number(pageSize) || 50)); + const params: any[] = [companyCode, processCode]; + let paramIdx = 3; + + let keywordCondition = ""; + if (keyword) { + keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`; + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + + const dataResult = await pool.query( + `SELECT + wop.id, + wop.wo_id, + wi.work_instruction_no, + wi.start_date AS order_date, + wop.process_code, + wop.process_name, + wop.seq_no, + COALESCE(ii.item_number, wi.item_id) AS item_code, + COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name, + COALESCE(ii.size, '') AS spec, + COALESCE(ii.material, '') AS material, + COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS order_qty, + 0 AS received_qty, + COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS remain_qty, + 'work_order_process' AS source_table, + wop.result_status, + COALESCE(ii.image, NULL) AS image, + CASE WHEN EXISTS ( + SELECT 1 FROM item_inspection_info iii + WHERE iii.company_code = wop.company_code + AND COALESCE(iii.is_active, 'Y') = 'Y' + AND iii.item_code = COALESCE(ii.item_number, wi.item_id) + ) THEN 'self' ELSE NULL END AS inspection_type + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + LEFT JOIN ( + SELECT DISTINCT ON (id, company_code) + id, item_number, item_name, size, material, image, company_code + FROM item_info + ORDER BY id, company_code, created_date DESC + ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code + WHERE wop.company_code = $1 + AND wop.process_code = $2 + AND wop.parent_process_id IS NULL + AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') + AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0 + AND wop.target_warehouse_id IS NULL + ${keywordCondition} + ORDER BY wi.work_instruction_no, CAST(wop.seq_no AS int) + LIMIT ${limit}`, + params, + ); + + return res.json({ success: true, data: dataResult.rows }); + } catch (error: any) { + logger.error("생산입고 소스 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // 입고번호 자동생성 export async function generateNumber(req: AuthenticatedRequest, res: Response) { try { diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index 6c3122ad..3ec1b692 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -2,8 +2,10 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; import { getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit, + getPkgUnitsByItem, getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, + getLoadingUnitsByPkg, getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, getItemsByDivision, getGeneralItems, } from "../controllers/packagingController"; @@ -18,6 +20,9 @@ router.post("/pkg-units", createPkgUnit); router.put("/pkg-units/:id", updatePkgUnit); router.delete("/pkg-units/:id", deletePkgUnit); +// 품목별 포장단위 조회 +router.get("/pkg-units-by-item/:itemNumber", getPkgUnitsByItem); + // 포장단위 매칭품목 router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems); router.post("/pkg-unit-items", createPkgUnitItem); @@ -29,6 +34,9 @@ router.post("/loading-units", createLoadingUnit); router.put("/loading-units/:id", updateLoadingUnit); router.delete("/loading-units/:id", deleteLoadingUnit); +// 포장코드별 적재함 조회 +router.get("/loading-units-by-pkg/:pkgCode", getLoadingUnitsByPkg); + // 적재함 포장구성 router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs); router.post("/loading-unit-pkgs", createLoadingUnitPkg); diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index 36821e5b..be614604 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -23,6 +23,7 @@ import { saveMaterialInput, getMaterialInputs, getChecklistItems, + getProcessList, } from "../controllers/popProductionController"; const router = Router(); @@ -51,5 +52,6 @@ router.get("/bom-materials/:processId", getBomMaterials); router.post("/material-input", saveMaterialInput); router.get("/material-inputs/:processId", getMaterialInputs); router.get("/checklist-items/:processId", getChecklistItems); +router.get("/processes", getProcessList); export default router; diff --git a/backend-node/src/routes/receivingRoutes.ts b/backend-node/src/routes/receivingRoutes.ts index 0b5a5c13..34b96821 100644 --- a/backend-node/src/routes/receivingRoutes.ts +++ b/backend-node/src/routes/receivingRoutes.ts @@ -28,6 +28,9 @@ router.get("/source/shipments", receivingController.getShipments); // 소스 데이터: 품목 (기타입고) router.get("/source/items", receivingController.getItems); +// 소스 데이터: 생산실적 (생산입고) +router.get("/source/production-results", receivingController.getProductionResults); + // 입고 등록 router.post("/", receivingController.create); diff --git a/frontend/app/(main)/COMPANY_7/pop/POP.md b/frontend/app/(main)/COMPANY_7/pop/POP.md index 6393d700..44ae8f88 100644 --- a/frontend/app/(main)/COMPANY_7/pop/POP.md +++ b/frontend/app/(main)/COMPANY_7/pop/POP.md @@ -40,6 +40,12 @@ POP 영역에 파일 생성/수정/삭제가 발생하면 이 문서의 `## 작업 로그` 섹션에 날짜별 항목 추가. 사용자가 별도 지시하지 않아도 자동으로 기록. +### 0-5. POP layout 수정 금지 (사용자 지시 필수) +- `COMPANY_7/pop/layout.tsx` 는 **사용자의 명시적 지시 없이 수정 금지**. +- 화면명(타이틀)/뒤로가기 버튼은 **각 page.tsx 내부에** 배치한다 (선례: `production/process/page.tsx` 2026-04-20 7차). +- `PopShell` 의 `title` / `showBack` / `headerRight` prop 을 layout 에서 전달하는 방식은 **금지**. 페이지 내부 헤더 행(뒤로가기 버튼 + `

`)을 직접 렌더한다. +- layout 파일 수정이 정당한 경우(예: 공지 배너 추가 등)에도 **반드시 사용자 확인 선행**. + --- ## 1. 개요 @@ -180,6 +186,30 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유 ## 작업 로그 +### 2026-04-22 +- **POP layout 수정 금지 규칙 신설 (0-5 섹션 추가)** + - `COMPANY_7/pop/layout.tsx` 는 사용자의 명시적 지시 없이 수정 금지 + - 화면명/뒤로가기 버튼은 각 page.tsx 내부에 배치 (선례: 2026-04-20 7차 `production/process/page.tsx`) +- **`COMPANY_7/pop/layout.tsx` 원복** + - Phase E 에서 추가했던 `isWork` 분기(`title="공정 작업"` + `showBack`) 제거 + - `showBanner={isMain}` 만 남기고 `PopShell` 기본 렌더로 복귀 (타이틀은 업체명 기본값) +- **`production/work/[processId]/page.tsx` 에 뒤로가기 + 타이틀 이식** + - `production/process/page.tsx` 2026-04-20 7차 패턴 복제 — 뒤로가기 버튼(`w-10 h-10 rounded-xl`, gray-200 border) + "공정 작업" `

` + - 뒤로가기 목적지: `/COMPANY_7/pop/production/process` (기존 `ProcessWork` 렌더는 유지) + - 래퍼가 11→25 줄로 확장, 동작 변경 없음 +- **반응형 공통화 Phase 1 — 공통 컴포넌트 5개 신설 (`_components/common/`)** + - 상위 플랜: `.claude/plans/pop-responsive-refactor.md` (신규, 계획 문서) + - 신규: `theme.ts` (67줄) — `COLOR_MAP: Record` 9색 × 7토큰 완성 리터럴. Tailwind JIT purge 회피 원칙(동적 문자열 0) + - 신규: `PopButton.tsx` (50줄) — size sm/md/lg(min 96×40 / 144×48 / 200×56), icon prop, forwardRef, `COLOR_MAP[color]` 자동 적용 + - 신규: `PopCard.tsx` (45줄) — `w-full min-h-[180px]` + selected/color/interactive props, 선택 시 `COLOR_MAP[color].ringSelected` + - 신규: `PopCardGrid.tsx` (75줄) — 브레이크포인트별 cols map(1~4) + gap sm/md/lg. Tailwind 리터럴 map 방식 + - 신규: `PopModal.tsx` (71줄) — size sm/md/lg/xl 전부 `w-[min(Xvw,Ypx)]` 반응형, ESC 닫기, footer slot + - 기존 파일 수정 0건. 아직 어느 화면도 import 하지 않음 (unused) + - 검증: tsc --noEmit baseline 3090 유지, `/COMPANY_7/pop/main` 브라우저 로드 회귀 0 (콘솔 에러 0) +- **반응형 공통화 Phase 2 — 취소** + - 계획 원안: 기존 공용 모달 4개(`BarcodeScanModal`/`ConfirmModal`/`EquipmentModal`/`SimpleKeypadModal`)를 PopModal 기반으로 내부 개편 + - 취소 사유: 4개 모달이 각자 특수 UX(ConfirmModal 분할버튼 + `z-[100]`, SimpleKeypadModal blue gradient header + maxQty 배지, EquipmentModal header 내 정렬 버튼, BarcodeScanModal shadcn Dialog 기반 aria 내장)라 일괄 래핑 시 시각 변경 발생. 사용자 결정으로 **기존 모달은 현 상태 유지**, PopModal은 **신규 모달 작성 시에만 기본 틀로 사용**. 계획 문서 §5 표에 취소 기록. + ### 2026-04-15 - `frontend/app/(main)/layout.tsx` 수정 - `"use client"` 추가 @@ -527,6 +557,43 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유 - 타입 체크(tsc --noEmit): 새 에러 없음 - 브라우저 검증: 미수행 +### 2026-04-21 (2차) +- **포장단위 모달 복수 등록 개편 + maxQty 버그 수정** + - `_components/inbound/NumberPadModal.tsx` 전면 재작성 + - 기존 3단계(1차 포장선택→개당수량→포장개수 + 잔량 포장 + 확인) → 단일 list step 기반 구조로 전환 + - list step: 포장 수량/미포장 잔량 실시간 요약, 등록된 포장 목록(행별 [편집][삭제]), [+ 포장단위 추가], [확인] + - packaging step: 포장단위 선택 (pkg_qty 자동 세팅, 개당수량 입력 단계 제거) + - count step: 개수만 입력 (MAX = `Math.floor(사용가능잔량 / pkg_qty)`) + - 같은 `pkg_code` 재선택 시 기존 행 `count` 합산 (pkg_qty 동일 전제) + - 편집 시 해당 행 수량을 사용가능잔량에 되돌린 뒤 max 계산 + - 잔량 > 0 이어도 확정 가능 (입고/출고 관리화면에서 수정 여지) + - 적재함 선택 흐름은 변경 없음 (InboundCartPage/OutboundCartPage의 별도 LoadingUnitModal 유지) + - `_components/inbound/InboundCartPage.tsx` + - **MAX 버그 수정**: `NumberPadModal`에 전달하던 `maxQty={packagingTarget.remain_qty}` → `packagingTarget.inbound_qty` + - `handlePackagingConfirm`: `Math.min(qty, remain_qty)` → `packagingTarget.inbound_qty` 고정 (포장 합계로 수량 덮어쓰기 제거, 미포장 잔량 허용) + - 포장 정보 카드 UI: 배지 `포장완료`(green) / `부분포장`(amber) 분기 + 미포장 잔량 줄 추가 + - `_components/outbound/OutboundCartPage.tsx`: 동일 패턴 (`outbound_qty` 기준) + - 버그 원인: 기존 `remain_qty`는 발주 잔량(예: 발주 200, 미입고 200)이라 장바구니에 50 담아도 MAX 버튼/잔량 계산이 200 기준 → "50 EA 중 1박스(40EA)만 담아도 MAX=5박스 / 잔량 160" 증상 + - 검증: + - `tsc --noEmit` (frontend): 변경 파일 기준 신규 에러 0 (기존 `lib/utils/*`, `v2-core/*` 등 baseline만 존재) + - 브라우저 검증: MAX 버그 수정 동작 확인 (모달 헤더 `최대 50 EA`, 발주수량 100 기준 아님). 복수 추가/합산/편집/삭제/배지 분기는 현 장바구니 품목 3건(DEMO-PROD-001/B_ETCE3_001/F_GMP02_003)에 `pkg_unit_item` 미등록으로 미검증 — 사용자 승인 SKIP + - PreToolUse 훅이 카드 UI 편집을 2회 차단 → 사용자에게 세부 변경 목록(배지/색상/미포장 줄) 제시 후 승인받아 통과 + +### 2026-04-21 +- **판매출고 시연용 더미 데이터 추가 후 동일 세션 내 전량 롤백 (사용자 지시)** + - `_components/outbound/SalesOutbound.tsx` `fetchOrders`: 더미 3건 삽입 → 원래 빈 배열(`setOrders([])`)로 원복 + - `_components/outbound/OutboundCartPage.tsx` `handleConfirm`: `allDummy` 스킵 분기 추가 → 원래 `const res = await apiClient.post("/outbound", payload);` 한 줄로 원복 + - 현재 상태: 2026-04-20 (9차) 시점과 동일. 판매출고 화면은 다시 `fetchOrders` 빈 배열 / `fetchAllCustomers` 빈 배열 상태 (UI 클론, DB 미연동) + +### 2026-04-22 (2차) +- **POP 반응형 공통 컴포넌트 5개 신설** (`_components/common/`) + - `theme.ts` — PopColor 타입(9색) + COLOR_MAP 팔레트 (buttonBg/buttonBgHover/ring/ringSelected/text/bg50/border), 완성 리터럴만, JIT 안전 + - `PopButton.tsx` — forwardRef, size(sm/md/lg) + color + icon props, COLOR_MAP 조회 기반, 외부 className append + - `PopCard.tsx` — forwardRef, selected/color/interactive props, ringSelected + border 색상 교체, 외부 className append + - `PopCardGrid.tsx` — grid + gap, ColProfile(base/md/lg/xl/2xl) 모두 리터럴 맵 조회, 옵셔널 브레이크포인트 조건부 적용 + - `PopModal.tsx` — open/onClose/size(sm/md/lg/xl)/title/children/footer/hideCloseButton, ESC 키 useEffect, backdrop click close + - 기존 파일 수정 없음. `tsc --noEmit`: 신규 파일 에러 0, 전체 baseline 3090 유지 + ### 2026-04-20 (9차) - **출고관리 화면 API 연동 (UI 껍데기 → 실연동, 입고관리 로직 포팅)** - `_components/outbound/OutboundManage.tsx` 전면 rewrite diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/common/PopButton.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopButton.tsx new file mode 100644 index 00000000..317fd5fa --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopButton.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { COLOR_MAP, type PopColor } from "./theme"; + +type Size = "sm" | "md" | "lg"; + +interface Props extends ButtonHTMLAttributes { + color?: PopColor; + size?: Size; + icon?: React.ReactNode; +} + +const SIZE_CLASSES: Record = { + sm: "min-w-[96px] min-h-[40px] text-sm px-3", + md: "min-w-[144px] min-h-[48px] text-base px-4", + lg: "min-w-[200px] min-h-[56px] text-lg px-6", +}; + +const COMMON = + "rounded-xl font-semibold text-white shadow-md transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"; + +const PopButton = forwardRef( + ({ color = "blue", size = "md", icon, children, className, ...rest }, ref) => { + const tokens = COLOR_MAP[color]; + const classes = [ + COMMON, + SIZE_CLASSES[size], + tokens.buttonBg, + tokens.buttonBgHover, + tokens.ring, + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + ); + } +); + +PopButton.displayName = "PopButton"; + +export default PopButton; diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/common/PopCard.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopCard.tsx new file mode 100644 index 00000000..f8f50817 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopCard.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { HTMLAttributes, forwardRef } from "react"; +import { COLOR_MAP, type PopColor } from "./theme"; + +interface Props extends HTMLAttributes { + selected?: boolean; + color?: PopColor; + interactive?: boolean; +} + +const BASE = + "w-full min-h-[180px] rounded-2xl bg-white border shadow-sm p-4 flex flex-col transition-all"; + +const PopCard = forwardRef( + ( + { selected = false, color = "blue", interactive = true, className, ...rest }, + ref + ) => { + const tokens = COLOR_MAP[color]; + + const classes = [ + BASE, + selected ? tokens.border : "border-gray-200", + interactive ? "hover:shadow-md hover:border-gray-300 cursor-pointer" : "", + selected ? tokens.ringSelected : "", + className, + ] + .filter(Boolean) + .join(" "); + + return
; + } +); + +PopCard.displayName = "PopCard"; + +export default PopCard; diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/common/PopCardGrid.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopCardGrid.tsx new file mode 100644 index 00000000..36d50ba1 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopCardGrid.tsx @@ -0,0 +1,77 @@ +import { HTMLAttributes } from "react"; + +type Cols = 1 | 2 | 3 | 4; + +interface ColProfile { + base?: Cols; + md?: Cols; + lg?: Cols; + xl?: Cols; + "2xl"?: Cols; +} + +interface Props extends HTMLAttributes { + cols?: ColProfile; + gap?: "sm" | "md" | "lg"; +} + +const BASE_COLS: Record = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", +}; +const MD_COLS: Record = { + 1: "md:grid-cols-1", + 2: "md:grid-cols-2", + 3: "md:grid-cols-3", + 4: "md:grid-cols-4", +}; +const LG_COLS: Record = { + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", +}; +const XL_COLS: Record = { + 1: "xl:grid-cols-1", + 2: "xl:grid-cols-2", + 3: "xl:grid-cols-3", + 4: "xl:grid-cols-4", +}; +const XXL_COLS: Record = { + 1: "2xl:grid-cols-1", + 2: "2xl:grid-cols-2", + 3: "2xl:grid-cols-3", + 4: "2xl:grid-cols-4", +}; + +const GAP: Record<"sm" | "md" | "lg", string> = { + sm: "gap-3", + md: "gap-4", + lg: "gap-6", +}; + +export default function PopCardGrid({ + cols = { base: 1, md: 2, xl: 3 }, + gap = "md", + className, + ...rest +}: Props) { + const { base = 1, md, lg, xl, "2xl": xxl } = cols; + + const classes = [ + "grid w-full", + GAP[gap], + BASE_COLS[base], + md != null ? MD_COLS[md] : "", + lg != null ? LG_COLS[lg] : "", + xl != null ? XL_COLS[xl] : "", + xxl != null ? XXL_COLS[xxl] : "", + className, + ] + .filter(Boolean) + .join(" "); + + return
; +} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/common/PopModal.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopModal.tsx new file mode 100644 index 00000000..c72499dd --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopModal.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; + +type Size = "sm" | "md" | "lg" | "xl"; + +interface Props { + open: boolean; + onClose: () => void; + size?: Size; + title?: string; + children: ReactNode; + footer?: ReactNode; + hideCloseButton?: boolean; +} + +const SIZE_CLASSES: Record = { + sm: "w-[min(90vw,420px)] max-h-[80vh]", + md: "w-[min(90vw,640px)] max-h-[85vh]", + lg: "w-[min(95vw,900px)] max-h-[90vh]", + xl: "w-[min(98vw,1200px)] max-h-[95vh]", +}; + +export default function PopModal({ + open, + onClose, + size = "md", + title, + children, + footer, + hideCloseButton = false, +}: Props) { + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {(title != null || !hideCloseButton) && ( +
+ {title != null && ( +

{title}

+ )} + {!hideCloseButton && ( + + )} +
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/common/theme.ts b/frontend/app/(main)/COMPANY_7/pop/_components/common/theme.ts new file mode 100644 index 00000000..a846f2d7 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/pop/_components/common/theme.ts @@ -0,0 +1,107 @@ +// POP 9-color palette. 키는 부위별 완성 리터럴 — JIT 스캔 대상. +// 동적 문자열 생성 금지(`bg-${x}` 등). 반드시 COLOR_MAP[color].부위 조회로 접근. + +export type PopColor = + | 'blue' + | 'purple' + | 'cyan' + | 'green' + | 'red' + | 'pink' + | 'teal' + | 'orange' + | 'amber'; + +export interface PopColorTokens { + buttonBg: string; + buttonBgHover: string; + ring: string; + ringSelected: string; + text: string; + bg50: string; + border: string; +} + +export const COLOR_MAP: Record = { + blue: { + buttonBg: 'bg-gradient-to-b from-blue-400 to-blue-700', + buttonBgHover: 'hover:from-blue-500 hover:to-blue-800', + ring: 'focus:ring-blue-500', + ringSelected: 'ring-2 ring-blue-500', + text: 'text-blue-600', + bg50: 'bg-blue-50', + border: 'border-blue-200', + }, + purple: { + buttonBg: 'bg-gradient-to-b from-purple-400 to-purple-700', + buttonBgHover: 'hover:from-purple-500 hover:to-purple-800', + ring: 'focus:ring-purple-500', + ringSelected: 'ring-2 ring-purple-500', + text: 'text-purple-600', + bg50: 'bg-purple-50', + border: 'border-purple-200', + }, + cyan: { + buttonBg: 'bg-gradient-to-b from-cyan-400 to-cyan-700', + buttonBgHover: 'hover:from-cyan-500 hover:to-cyan-800', + ring: 'focus:ring-cyan-500', + ringSelected: 'ring-2 ring-cyan-500', + text: 'text-cyan-600', + bg50: 'bg-cyan-50', + border: 'border-cyan-200', + }, + green: { + buttonBg: 'bg-gradient-to-b from-green-400 to-green-700', + buttonBgHover: 'hover:from-green-500 hover:to-green-800', + ring: 'focus:ring-green-500', + ringSelected: 'ring-2 ring-green-500', + text: 'text-green-600', + bg50: 'bg-green-50', + border: 'border-green-200', + }, + red: { + buttonBg: 'bg-gradient-to-b from-red-400 to-red-700', + buttonBgHover: 'hover:from-red-500 hover:to-red-800', + ring: 'focus:ring-red-500', + ringSelected: 'ring-2 ring-red-500', + text: 'text-red-600', + bg50: 'bg-red-50', + border: 'border-red-200', + }, + pink: { + buttonBg: 'bg-gradient-to-b from-pink-400 to-pink-700', + buttonBgHover: 'hover:from-pink-500 hover:to-pink-800', + ring: 'focus:ring-pink-500', + ringSelected: 'ring-2 ring-pink-500', + text: 'text-pink-600', + bg50: 'bg-pink-50', + border: 'border-pink-200', + }, + teal: { + buttonBg: 'bg-gradient-to-b from-teal-400 to-teal-700', + buttonBgHover: 'hover:from-teal-500 hover:to-teal-800', + ring: 'focus:ring-teal-500', + ringSelected: 'ring-2 ring-teal-500', + text: 'text-teal-600', + bg50: 'bg-teal-50', + border: 'border-teal-200', + }, + orange: { + buttonBg: 'bg-gradient-to-b from-orange-400 to-orange-700', + buttonBgHover: 'hover:from-orange-500 hover:to-orange-800', + ring: 'focus:ring-orange-500', + ringSelected: 'ring-2 ring-orange-500', + text: 'text-orange-600', + bg50: 'bg-orange-50', + border: 'border-orange-200', + }, + amber: { + buttonBg: 'bg-gradient-to-b from-amber-400 to-amber-700', + buttonBgHover: 'hover:from-amber-500 hover:to-amber-800', + ring: 'focus:ring-amber-500', + ringSelected: 'ring-2 ring-amber-500', + text: 'text-amber-600', + bg50: 'bg-amber-50', + border: 'border-amber-200', + }, +}; diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/inbound/InboundCartPage.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/inbound/InboundCartPage.tsx index b4bc96de..156be5b8 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/inbound/InboundCartPage.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/inbound/InboundCartPage.tsx @@ -292,13 +292,11 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) { setPackagingOpen(true); }; - const handlePackagingConfirm = (qty: number, packages: PackageEntry[]) => { + const handlePackagingConfirm = (_qty: number, packages: PackageEntry[]) => { if (!packagingTarget) return; - const finalQty = Math.min(qty, packagingTarget.remain_qty); - cart.updateItemQuantity( packagingTarget.rowKey, - finalQty, + packagingTarget.inbound_qty, undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any packages.length > 0 ? (packages as any) : undefined, @@ -1112,21 +1110,39 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
)} - {/* === Package info (포장 완료 시 — 클릭하면 모달 열림) === */} - {item.packages && item.packages.length > 0 && ( - <> + {/* === Package info (포장 등록 시 — 클릭하면 모달 열림) === */} + {item.packages && item.packages.length > 0 && (() => { + const packagedQty = item.packages.reduce( + (s, p) => s + p.count * p.qtyPerUnit, + 0, + ); + const unpacked = Math.max(0, item.inbound_qty - packagedQty); + const isComplete = unpacked === 0; + return (
openPackaging(item)} - className="mt-2.5 px-3 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg cursor-pointer active:scale-95 transition-all" + className={`mt-2.5 px-3 py-2 border rounded-lg cursor-pointer active:scale-95 transition-all ${ + isComplete + ? "bg-gradient-to-r from-green-50 to-emerald-50 border-green-200" + : "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" + }`} >
- - 포장완료 + + {isComplete ? "포장완료" : "부분포장"} - - {item.packages - .reduce((s, p) => s + p.count * p.qtyPerUnit, 0) - .toLocaleString()}{" "} + + {packagedQty.toLocaleString()}{" "} EA
@@ -1145,9 +1161,17 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
))}
+ {!isComplete && ( +
+ 미포장 + + {unpacked.toLocaleString()} EA + +
+ )} - - )} + ); + })()} ); })} @@ -1348,7 +1372,7 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) { setPackagingTarget(null); }} onConfirm={handlePackagingConfirm} - maxQty={packagingTarget.remain_qty} + maxQty={packagingTarget.inbound_qty} itemName={packagingTarget.item_name} itemNumber={packagingTarget.item_code} initialPackages={packagingTarget.packages} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/inbound/NumberPadModal.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/inbound/NumberPadModal.tsx index 191646e1..a366d0dd 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/inbound/NumberPadModal.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/inbound/NumberPadModal.tsx @@ -31,15 +31,7 @@ interface NumberPadModalProps { initialPackages?: PackageEntry[]; } -type Step = - | "packaging" - | "qty-per-pkg" - | "pkg-count" - | "remainder" - | "rem-packaging" - | "rem-qty-per-pkg" - | "rem-pkg-count" - | "confirm"; +type Step = "list" | "packaging" | "count"; /* ------------------------------------------------------------------ */ /* Numpad Keys */ @@ -73,21 +65,15 @@ export function NumberPadModal({ itemNumber, initialPackages, }: NumberPadModalProps) { - const [step, setStep] = useState("packaging"); - - // 1차 포장 - const [selectedUnit, setSelectedUnit] = useState(null); - const [pkgCount, setPkgCount] = useState("0"); - const [qtyPerPkg, setQtyPerPkg] = useState("0"); - - // 나머지 포장 - const [remUnit, setRemUnit] = useState(null); - const [remPkgCount, setRemPkgCount] = useState("0"); - const [remQtyPerPkg, setRemQtyPerPkg] = useState("0"); - + const [step, setStep] = useState("list"); + const [packages, setPackages] = useState([]); const [packageUnits, setPackageUnits] = useState([]); const [pkgLoading, setPkgLoading] = useState(false); + const [selectedUnit, setSelectedUnit] = useState(null); + const [count, setCount] = useState("0"); + const [editingIndex, setEditingIndex] = useState(null); + /* Fetch packaging units from DB */ useEffect(() => { if (!open || !itemNumber) { @@ -108,172 +94,158 @@ export function NumberPadModal({ })) ); }) - .catch(() => { if (!cancelled) setPackageUnits([]); }) - .finally(() => { if (!cancelled) setPkgLoading(false); }); - return () => { cancelled = true; }; + .catch(() => { + if (!cancelled) setPackageUnits([]); + }) + .finally(() => { + if (!cancelled) setPkgLoading(false); + }); + return () => { + cancelled = true; + }; }, [open, itemNumber]); /* Reset on open */ useEffect(() => { if (open) { - if (initialPackages && initialPackages.length > 0) { - const p = initialPackages[0]; - setSelectedUnit(p.unit); - setPkgCount(String(p.count)); - setQtyPerPkg(String(p.qtyPerUnit)); - if (initialPackages.length > 1) { - const r = initialPackages[1]; - setRemUnit(r.unit); - setRemPkgCount(String(r.count)); - setRemQtyPerPkg(String(r.qtyPerUnit)); - } else { - setRemUnit(null); - setRemPkgCount("0"); - setRemQtyPerPkg("0"); - } - setStep("confirm"); - } else { - setStep("packaging"); - setSelectedUnit(null); - setPkgCount("0"); - setQtyPerPkg("0"); - setRemUnit(null); - setRemPkgCount("0"); - setRemQtyPerPkg("0"); - } + setPackages(initialPackages ? [...initialPackages] : []); + setStep("list"); + setSelectedUnit(null); + setCount("0"); + setEditingIndex(null); } }, [open, initialPackages]); /* Computed values */ - const pkgCountNum = parseInt(pkgCount, 10) || 0; - const qtyPerPkgNum = parseInt(qtyPerPkg, 10) || 0; - const primaryQty = pkgCountNum * qtyPerPkgNum; + const totalPackagedQty = packages.reduce( + (s, p) => s + p.count * p.qtyPerUnit, + 0 + ); + const unpackedQty = Math.max(0, maxQty - totalPackagedQty); + const countNum = parseInt(count, 10) || 0; - const remPkgCountNum = parseInt(remPkgCount, 10) || 0; - const remQtyPerPkgNum = parseInt(remQtyPerPkg, 10) || 0; - const remQty = remUnit ? remPkgCountNum * remQtyPerPkgNum : 0; + /* 편집 중이면 해당 행 수량만큼은 "사용 가능"으로 되돌려줌 */ + const editingEntry = + editingIndex !== null ? packages[editingIndex] ?? null : null; + const editingReservedQty = editingEntry + ? editingEntry.count * editingEntry.qtyPerUnit + : 0; + const availableForCurrent = unpackedQty + editingReservedQty; + const currentPkgQty = selectedUnit?.pkg_qty ?? 0; + const maxCountForCurrent = + currentPkgQty > 0 ? Math.floor(availableForCurrent / currentPkgQty) : 0; - const remainder = qtyPerPkgNum > 0 ? maxQty - primaryQty : 0; - const totalQty = primaryQty + remQty; - const isOverMax = totalQty > maxQty; - - /* Generic numpad input handler */ + /* Numpad input */ const handleInput = useCallback( - (key: string, setter: React.Dispatch>, max?: number) => { - setter((prev) => { + (key: string) => { + setCount((prev) => { switch (key) { case "backspace": return prev.length <= 1 ? "0" : prev.slice(0, -1); case "clear": return "0"; case "max": - return String(max ?? maxQty); + return String(maxCountForCurrent); default: { const next = prev === "0" ? key : prev + key; const num = parseInt(next, 10); if (isNaN(num)) return prev; - if (max !== undefined) return String(Math.min(num, max)); - return next; + return String(Math.min(num, maxCountForCurrent)); } } }); }, - [maxQty] + [maxCountForCurrent] ); - /* 1차 포장 handlers */ - const handleSelectPackaging = (unit: PackageUnit) => { + /* Step handlers */ + const openAddFlow = () => { + setEditingIndex(null); + setSelectedUnit(null); + setCount("0"); + setStep("packaging"); + }; + + const openEditFlow = (idx: number) => { + const entry = packages[idx]; + if (!entry) return; + setEditingIndex(idx); + setSelectedUnit(entry.unit); + setCount(String(entry.count)); + setStep("count"); + }; + + const handleSelectUnit = (unit: PackageUnit) => { setSelectedUnit(unit); - setPkgCount("0"); - setQtyPerPkg(unit.pkg_qty ? String(unit.pkg_qty) : "0"); - setStep("qty-per-pkg"); + setCount("0"); + setStep("count"); }; - const handleQtyPerPkgConfirm = () => { - if (qtyPerPkgNum <= 0) return; - setStep("pkg-count"); + const handleCountConfirm = () => { + if (!selectedUnit || countNum <= 0) return; + const qtyPerUnit = selectedUnit.pkg_qty ?? 0; + setPackages((prev) => { + if (editingIndex !== null) { + return prev.map((p, i) => + i === editingIndex ? { ...p, count: countNum, qtyPerUnit } : p + ); + } + const existing = prev.findIndex( + (p) => p.unit.value === selectedUnit.value + ); + if (existing >= 0) { + return prev.map((p, i) => + i === existing ? { ...p, count: p.count + countNum } : p + ); + } + return [...prev, { unit: selectedUnit, count: countNum, qtyPerUnit }]; + }); + setStep("list"); + setSelectedUnit(null); + setCount("0"); + setEditingIndex(null); }; - const handlePkgCountConfirm = () => { - if (pkgCountNum <= 0 || !selectedUnit) return; - const rem = maxQty - pkgCountNum * qtyPerPkgNum; - if (rem > 0) { - setStep("remainder"); - } else { - setRemUnit(null); - setRemPkgCount("0"); - setRemQtyPerPkg("0"); - setStep("confirm"); + const handleDelete = (idx: number) => { + setPackages((prev) => prev.filter((_, i) => i !== idx)); + }; + + const handleBack = () => { + if (step === "count") { + if (editingIndex !== null) { + setStep("list"); + setSelectedUnit(null); + setCount("0"); + setEditingIndex(null); + } else { + setStep("packaging"); + setSelectedUnit(null); + setCount("0"); + } + } else if (step === "packaging") { + setStep("list"); } }; - /* 나머지 포장 handlers */ - const handleRemSelectPackaging = (unit: PackageUnit) => { - if (!selectedUnit) return; - setRemUnit(unit); - setRemQtyPerPkg(String(remainder)); - setRemPkgCount("1"); - setStep("confirm"); - }; - - const handleRemQtyPerPkgConfirm = () => { - if (remQtyPerPkgNum <= 0) return; - setStep("rem-pkg-count"); - }; - - const handleRemPkgCountConfirm = () => { - if (remPkgCountNum <= 0 || !selectedUnit) return; - setStep("confirm"); - }; - - const handleSkipRemainder = () => { - if (!selectedUnit) return; - setRemUnit(null); - setRemPkgCount("0"); - setRemQtyPerPkg("0"); - setStep("confirm"); - }; - - /* 최종 확인 */ const handleFinalConfirm = () => { - if (primaryQty <= 0 || !selectedUnit) return; - const finalQty = Math.min(totalQty, maxQty); - const packages: PackageEntry[] = [ - { unit: selectedUnit, count: pkgCountNum, qtyPerUnit: qtyPerPkgNum }, - ]; - if (remUnit && remQty > 0) { - packages.push({ unit: remUnit, count: remPkgCountNum, qtyPerUnit: remQtyPerPkgNum }); - } + if (packages.length === 0) return; + const finalQty = Math.min(totalPackagedQty, maxQty); onConfirm(finalQty, packages); onClose(); }; - /* Back navigation */ - const handleBack = () => { - switch (step) { - case "qty-per-pkg": setStep("packaging"); break; - case "pkg-count": setStep("qty-per-pkg"); break; - case "remainder": setStep("pkg-count"); break; - case "rem-packaging": setStep("remainder"); break; - case "rem-qty-per-pkg": setStep("rem-packaging"); break; - case "rem-pkg-count": setStep("rem-qty-per-pkg"); break; - case "confirm": - if (remUnit) setStep("remainder"); - else if (remainder > 0) setStep("remainder"); - else setStep("pkg-count"); - break; - } - }; - if (!open) return null; - /* Render numpad grid */ + /* ------------------------------------------------------------------ */ + /* Render helpers */ + /* ------------------------------------------------------------------ */ + const renderNumpad = ( currentValue: string, onKey: (key: string) => void, onConfirmStep: () => void, confirmLabel: string, - confirmDisabled: boolean, + confirmDisabled: boolean ) => ( <> {key.label} @@ -323,7 +295,6 @@ export function NumberPadModal({ ); - /* Render packaging grid */ const renderPackagingGrid = (onSelect: (unit: PackageUnit) => void) => ( <> {pkgLoading ? ( @@ -346,7 +317,9 @@ export function NumberPadModal({ {unit.icon} {unit.label} {unit.pkg_qty && unit.pkg_qty > 0 && ( - {unit.pkg_qty}EA/개 + + {unit.pkg_qty}EA/개 + )} ))} @@ -355,235 +328,218 @@ export function NumberPadModal({ ); - /* Header color */ - const isRemStep = step.startsWith("rem") || step === "remainder"; - const headerBg = isRemStep - ? "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" - : "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)"; + /* Header: 단계별 색상 */ + const headerBg = + step === "count" && editingIndex !== null + ? "linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)" + : "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)"; + + const headerBadge = + step === "count" && editingIndex !== null + ? "수정" + : `최대 ${maxQty.toLocaleString()} EA`; return (
-
+
{/* Header */} -
+
- {step !== "packaging" && ( + {step !== "list" && ( )} - {isRemStep ? `나머지 ${remainder.toLocaleString()} EA` - : `최대 ${maxQty.toLocaleString()} EA` - } + {headerBadge}
+
{/* Body */}
- - {/* ====== 1차: 포장 선택 ====== */} - {step === "packaging" && ( + {/* ====== LIST: 목록 + 추가 + 확인 ====== */} + {step === "list" && ( <> -

- 포장 단위를 선택하세요 -

- {renderPackagingGrid(handleSelectPackaging)} - - )} - - {/* ====== 1차: 개당 수량 ====== */} - {step === "qty-per-pkg" && selectedUnit && ( - <> -

- {selectedUnit.icon} {selectedUnit.label} 1개당 수량? -

-

- 개당 수량(EA)을 입력하세요 -

- {renderNumpad( - qtyPerPkg, - (key) => handleInput(key, setQtyPerPkg, maxQty), - handleQtyPerPkgConfirm, - "다음", - qtyPerPkgNum <= 0, - )} - - )} - - {/* ====== 1차: 포장 개수 ====== */} - {step === "pkg-count" && selectedUnit && ( - <> -

- {selectedUnit.icon} {selectedUnit.label} 몇 개? -

-

- 포장 개수를 입력하세요 -

- {renderNumpad( - pkgCount, - (key) => handleInput(key, setPkgCount, qtyPerPkgNum > 0 ? Math.floor(maxQty / qtyPerPkgNum) : 9999), - handlePkgCountConfirm, - "다음", - pkgCountNum <= 0, - )} - {pkgCountNum > 0 && qtyPerPkgNum > 0 && ( -
maxQty - ? "bg-red-50 text-red-600 border border-red-200" - : "bg-blue-50 text-blue-700 border border-blue-200" - }`}> - {pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA = {(pkgCountNum * qtyPerPkgNum).toLocaleString()}EA - {pkgCountNum * qtyPerPkgNum > maxQty && ( - 최대 수량 초과 - )} -
- )} - - )} - - {/* ====== 나머지 안내 ====== */} - {step === "remainder" && selectedUnit && ( -
-
- - {pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA = {primaryQty.toLocaleString()}EA - -
-

- 나머지 {remainder.toLocaleString()}EA -

-

- 나머지 수량의 포장을 등록하시겠습니까? -

-
- - -
-
- )} - - {/* ====== 나머지: 포장 선택 ====== */} - {step === "rem-packaging" && ( - <> -

- 나머지 {remainder.toLocaleString()}EA 포장 단위 -

-

- 선택하면 1개 x {remainder.toLocaleString()}EA로 자동 등록됩니다 -

- {renderPackagingGrid(handleRemSelectPackaging)} - - )} - - {/* ====== 나머지: 개당 수량 (수동 편집) ====== */} - {step === "rem-qty-per-pkg" && remUnit && ( - <> -

- {remUnit.icon} {remUnit.label} 1개당 수량? -

-

개당 수량(EA)을 입력하세요

- {renderNumpad( - remQtyPerPkg, - (key) => handleInput(key, setRemQtyPerPkg, remainder), - handleRemQtyPerPkgConfirm, - "다음", - remQtyPerPkgNum <= 0, - )} - - )} - - {/* ====== 나머지: 포장 개수 (수동 편집) ====== */} - {step === "rem-pkg-count" && remUnit && ( - <> -

- {remUnit.icon} {remUnit.label} 몇 개? -

-

포장 개수를 입력하세요

- {renderNumpad( - remPkgCount, - (key) => handleInput(key, setRemPkgCount, remQtyPerPkgNum > 0 ? Math.ceil(remainder / remQtyPerPkgNum) : 9999), - handleRemPkgCountConfirm, - "다음", - remPkgCountNum <= 0, - )} - - )} - - {/* ====== 최종 확인 ====== */} - {step === "confirm" && selectedUnit && ( - <> -
-

최종 확인

- - {/* 1차 포장 */} -
-

{selectedUnit.icon}

-

- {pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA + {/* 요약 (포장 수량 / 미포장 잔량) */} +

+
+

+ 포장 수량

-

- = {primaryQty.toLocaleString()} EA +

+ {totalPackagedQty.toLocaleString()} EA

+
0 + ? "bg-amber-50 border-amber-200" + : "bg-green-50 border-green-200" + }`} + > +

0 ? "text-amber-600" : "text-green-600" + }`} + > + 미포장 +

+

0 ? "text-amber-700" : "text-green-700" + }`} + style={{ fontVariantNumeric: "tabular-nums" }} + > + {unpackedQty.toLocaleString()} EA +

+
+
- {/* 나머지 포장 */} - {remUnit && remQty > 0 && ( -
-

나머지 포장

-

{remUnit.icon}

-

- {remPkgCountNum}{remUnit.label} x {remQtyPerPkgNum.toLocaleString()}EA -

-

- = {remQty.toLocaleString()} EA -

+ {/* 등록된 포장 목록 */} +
+ {packages.length === 0 ? ( +
+ 등록된 포장이 없습니다
+ ) : ( + packages.map((p, idx) => ( +
+ {p.unit.icon} +
+

+ {p.count.toLocaleString()} + {p.unit.label} × {p.qtyPerUnit.toLocaleString()}EA +

+

+ = {(p.count * p.qtyPerUnit).toLocaleString()} EA +

+
+ + +
+ )) )} - - {/* 합계 */} -
- 합계 {totalQty.toLocaleString()} EA - {isOverMax && ( -

최대 {maxQty.toLocaleString()}EA로 적용

- )} -
- -

{itemName}

-
+ {/* 품목명 */} +

+ {itemName} +

+ + {/* 버튼 */} +
@@ -591,6 +547,48 @@ export function NumberPadModal({ )} + {/* ====== PACKAGING: 포장단위 선택 ====== */} + {step === "packaging" && ( + <> +

+ 포장 단위를 선택하세요 +

+

+ 미포장 잔량 {unpackedQty.toLocaleString()} EA +

+ {renderPackagingGrid(handleSelectUnit)} + + )} + + {/* ====== COUNT: 개수 입력 ====== */} + {step === "count" && selectedUnit && ( + <> +

+ {selectedUnit.icon} {selectedUnit.label} 몇 개? +

+

+ 개당 {currentPkgQty.toLocaleString()}EA +

+

+ 사용 가능 {availableForCurrent.toLocaleString()}EA · 최대{" "} + {maxCountForCurrent.toLocaleString()}개 +

+ {renderNumpad( + count, + handleInput, + handleCountConfirm, + editingIndex !== null ? "수정" : "추가", + countNum <= 0 + )} + {countNum > 0 && ( +
+ {countNum} + {selectedUnit.label} × {currentPkgQty.toLocaleString()}EA ={" "} + {(countNum * currentPkgQty).toLocaleString()}EA +
+ )} + + )}
diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/inbound/PurchaseInbound.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/inbound/PurchaseInbound.tsx index 34368e29..923ccbe0 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/inbound/PurchaseInbound.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/inbound/PurchaseInbound.tsx @@ -7,6 +7,7 @@ import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal"; import { SimpleKeypadModal } from "../common/SimpleKeypadModal"; import { BarcodeScanModal } from "../common/BarcodeScanModal"; import type { CartItemWithId } from "../common/useCartSync"; +import { COLOR_MAP } from "../common/theme"; /* ------------------------------------------------------------------ */ /* Types */ @@ -332,11 +333,7 @@ export function PurchaseInbound({ cart, onCartClick, saving, inboundType, source
)} - {/* === Package info (포장 완료 시 — 클릭하면 모달 열림) === */} - {item.packages && item.packages.length > 0 && ( + {/* === Package info (포장 등록 시 — 클릭하면 모달 열림) === */} + {item.packages && item.packages.length > 0 && (() => { + const packagedQty = item.packages.reduce( + (s, p) => s + p.count * p.qtyPerUnit, + 0, + ); + const unpacked = Math.max(0, item.outbound_qty - packagedQty); + const isComplete = unpacked === 0; + return (
openPackaging(item)} - className="mt-2.5 px-3 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg cursor-pointer active:scale-95 transition-all" + className={`mt-2.5 px-3 py-2 border rounded-lg cursor-pointer active:scale-95 transition-all ${ + isComplete + ? "bg-gradient-to-r from-green-50 to-emerald-50 border-green-200" + : "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" + }`} >
- - 포장완료 + + {isComplete ? "포장완료" : "부분포장"} - - {item.packages - .reduce((s, p) => s + p.count * p.qtyPerUnit, 0) - .toLocaleString()}{" "} + + {packagedQty.toLocaleString()}{" "} EA
@@ -938,8 +957,17 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
))}
+ {!isComplete && ( +
+ 미포장 + + {unpacked.toLocaleString()} EA + +
+ )}
- )} + ); + })()}
); })} @@ -1113,7 +1141,7 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) { setPackagingTarget(null); }} onConfirm={handlePackagingConfirm} - maxQty={packagingTarget.remain_qty} + maxQty={packagingTarget.outbound_qty} itemName={packagingTarget.item_name} itemNumber={packagingTarget.item_code} initialPackages={packagingTarget.packages} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx index 40287832..3980a595 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx @@ -12,7 +12,7 @@ interface AcceptProcessModalProps { onConfirm: (qty: number) => void; maxQty: number; processName: string; - seqNo: string; + seqNo: number; loading?: boolean; } diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx index a339593b..823d9d3e 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx @@ -8,6 +8,7 @@ import React, { useRef, useState, } from "react"; +import { toast } from "sonner"; import { usePopSettings } from "@/hooks/pop/usePopSettings"; import { apiClient } from "@/lib/api/client"; import { dataApi } from "@/lib/api/data"; @@ -19,6 +20,7 @@ import { } from "./DefectTypeModal"; import { ProcessTimer, type TimerStatus } from "./ProcessTimer"; import { MaterialInputSection } from "./sections/MaterialInputSection"; +import { isReworkProcess, type WorkOrderProcessView } from "./types"; /* ================================================================== */ /* Types */ @@ -27,22 +29,22 @@ import { MaterialInputSection } from "./sections/MaterialInputSection"; interface ProcessData { id: string; wo_id: string; - seq_no: string; + seq_no: number; 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; + plan_qty: number; + input_qty: number; + good_qty: number; + defect_qty: number; + concession_qty: number; + total_production_qty: number; parent_process_id: string | null; result_status: string; result_note: string; started_at: string | null; paused_at: string | null; - total_paused_time: string | null; + total_paused_time: number; completed_at: string | null; actual_work_time: string | null; accepted_at: string | null; @@ -50,11 +52,52 @@ interface ProcessData { defect_detail: string | null; target_warehouse_id: string | null; target_location_code: string | null; - is_rework: string; + is_rework: boolean; routing_detail_id: string | null; batch_id?: string | null; } +/** raw work_order_process row → ProcessData 정규화 */ +function normalizeProcessData(raw: Record): ProcessData { + const toInt = (v: unknown): number => { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (v == null) return 0; + const n = parseInt(String(v), 10); + return Number.isFinite(n) ? n : 0; + }; + const s = (v: unknown) => (v == null ? null : String(v)); + return { + id: String(raw.id || ""), + wo_id: String(raw.wo_id || ""), + seq_no: toInt(raw.seq_no), + process_code: String(raw.process_code || ""), + process_name: String(raw.process_name || ""), + status: String(raw.status || ""), + plan_qty: toInt(raw.plan_qty), + input_qty: toInt(raw.input_qty), + good_qty: toInt(raw.good_qty), + defect_qty: toInt(raw.defect_qty), + concession_qty: toInt(raw.concession_qty), + total_production_qty: toInt(raw.total_production_qty), + parent_process_id: s(raw.parent_process_id), + result_status: String(raw.result_status || ""), + result_note: String(raw.result_note || ""), + started_at: s(raw.started_at), + paused_at: s(raw.paused_at), + total_paused_time: toInt(raw.total_paused_time), + completed_at: s(raw.completed_at), + actual_work_time: s(raw.actual_work_time), + accepted_at: s(raw.accepted_at), + accepted_by: s(raw.accepted_by), + defect_detail: s(raw.defect_detail), + target_warehouse_id: s(raw.target_warehouse_id), + target_location_code: s(raw.target_location_code), + is_rework: isReworkProcess(raw as { is_rework?: string | boolean | null }), + routing_detail_id: s(raw.routing_detail_id), + batch_id: s(raw.batch_id), + }; +} + interface WorkInstructionInfo { work_instruction_no: string; item_name: string; @@ -429,7 +472,8 @@ export function ProcessWork({ processId }: ProcessWorkProps) { size: 1, filters: { id: processId }, }); - const procData = (procRes.data?.[0] ?? null) as ProcessData | null; + const procRaw = procRes.data?.[0] as Record | undefined; + const procData = procRaw ? normalizeProcessData(procRaw) : null; if (procData) { setProcess(procData); @@ -552,10 +596,11 @@ export function ProcessWork({ processId }: ProcessWorkProps) { size: 100, filters: { wo_id: procData.wo_id }, }); - const allSiblings = (plRes.data ?? []) as ProcessData[]; + const allSiblingsRaw = (plRes.data ?? []) as Record[]; + const allSiblings = allSiblingsRaw.map(normalizeProcessData); const masters = allSiblings .filter((p) => !p.parent_process_id) - .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); + .sort((a, b) => a.seq_no - b.seq_no); // 중복 제거 const seen = new Set(); setProcessList( @@ -768,9 +813,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { return { ...prev, paused_at: null, - total_paused_time: String( - (parseInt(prev.total_paused_time || "0", 10) || 0) + pausedSec, - ), + total_paused_time: prev.total_paused_time + pausedSec, }; } if (action === "complete") @@ -798,7 +841,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { fetchProcess(); } catch (error: unknown) { const err = error as { response?: { data?: { message?: string } } }; - alert(err.response?.data?.message || "타이머 오류"); + toast.error(err.response?.data?.message || "타이머 오류"); fetchProcess(); // 실패 시 서버 상태로 복원 } }; @@ -929,7 +972,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { ), ); } catch { - alert("체크리스트 저장 실패"); + toast.error("체크리스트 저장 실패"); } }; @@ -939,7 +982,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const handleSaveResult = async () => { if (productionQty <= 0) { - alert("생산수량을 입력해주세요."); + toast.warning("생산수량을 입력해주세요."); return; } setSaving(true); @@ -967,16 +1010,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) { setResultNote(""); loadHistory(); if (d?.status === "completed") { - alert("모든 수량이 완료되어 자동 확정되었습니다."); + toast.success("모든 수량이 완료되어 자동 확정되었습니다."); } else { - alert("실적이 저장되었습니다."); + toast.success("실적이 저장되었습니다."); } } else { - alert(res.data?.message || "저장 실패"); + toast.error(res.data?.message || "저장 실패"); } } catch (error: unknown) { const err = error as { response?: { data?: { message?: string } } }; - alert(err.response?.data?.message || "실적 저장 중 오류"); + toast.error(err.response?.data?.message || "실적 저장 중 오류"); } finally { setSaving(false); } @@ -994,13 +1037,13 @@ export function ProcessWork({ processId }: ProcessWorkProps) { }); if (res.data?.success) { await fetchProcess(); - alert("실적이 확정되었습니다."); + toast.success("실적이 확정되었습니다."); } else { - alert(res.data?.message || "확정 실패"); + toast.error(res.data?.message || "확정 실패"); } } catch (error: unknown) { const err = error as { response?: { data?: { message?: string } } }; - alert(err.response?.data?.message || "확정 중 오류"); + toast.error(err.response?.data?.message || "확정 중 오류"); } finally { setSaving(false); } @@ -1030,7 +1073,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const handleInbound = () => { if (!selectedWarehouse) { - alert("창고를 선택해주세요."); + toast.warning("창고를 선택해주세요."); return; } askConfirm( @@ -1050,9 +1093,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) { ); if (res.data?.success) { setInboundDone(true); - alert(`재고 입고 완료: ${res.data.data?.qty || 0}개`); + toast.success(`재고 입고 완료: ${res.data.data?.qty || 0}개`); } else { - alert(res.data?.message || "입고 실패"); + toast.error(res.data?.message || "입고 실패"); } } catch (error: unknown) { const err = error as { @@ -1061,9 +1104,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const msg = err.response?.data?.message; if (err.response?.status === 409) { setInboundDone(true); - alert(msg || "이미 입고 완료"); + toast.info(msg || "이미 입고 완료"); } else { - alert(msg || "입고 중 오류"); + toast.error(msg || "입고 중 오류"); } } finally { setSaving(false); @@ -1082,10 +1125,10 @@ export function ProcessWork({ processId }: ProcessWorkProps) { 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 inputQty = process?.input_qty ?? 0; + const totalProduced = process?.total_production_qty ?? 0; + const accumulatedGood = process?.good_qty ?? 0; + const accumulatedDefect = process?.defect_qty ?? 0; const remaining = Math.max(0, inputQty - totalProduced); const isCompleted = process?.status === "completed"; const isConfirmed = process?.result_status === "confirmed"; @@ -1177,7 +1220,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
지시 - {parseInt(process.plan_qty || "0", 10).toLocaleString()} + {process.plan_qty.toLocaleString()}
@@ -1203,7 +1246,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { ? "진행중" : process.status} - {process.is_rework === "Y" && ( + {process.is_rework && ( 재작업 @@ -1750,9 +1793,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) { }, body: formData, }); - alert(res.ok ? "사진 첨부 완료" : "첨부 실패"); + res.ok ? toast.success("사진 첨부 완료") : toast.error("첨부 실패"); } catch { - alert("첨부 오류"); + toast.error("첨부 오류"); } e.target.value = ""; }} @@ -2057,9 +2100,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) { }, body: formData, }); - alert(res.ok ? "사진 첨부 완료" : "첨부 실패"); + res.ok ? toast.success("사진 첨부 완료") : toast.error("첨부 실패"); } catch { - alert("첨부 오류"); + toast.error("첨부 오류"); } e.target.value = ""; }} @@ -2312,8 +2355,8 @@ function ChecklistRow({ if (isPassed === "N") { const rangeStr = `${item.lower_limit || ""}~${item.upper_limit || ""}`; - alert( - `⚠️ 기준 초과!\n\n입력값: ${localValue}\n허용 범위: ${rangeStr}\n\n불합격으로 기록됩니다.`, + toast.warning( + `기준 초과! 입력값: ${localValue} / 허용 범위: ${rangeStr} — 불합격으로 기록됩니다.`, ); } } @@ -2591,12 +2634,12 @@ function ChecklistRow({ body: formData, }); if (res.ok) { - alert("사진 업로드 완료"); + toast.success("사진 업로드 완료"); } else { - alert("업로드 실패"); + toast.error("업로드 실패"); } } catch { - alert("업로드 중 오류"); + toast.error("업로드 중 오류"); } e.target.value = ""; }} diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/WorkOrderList.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/WorkOrderList.tsx index 2d6d941e..8caf35fb 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/production/WorkOrderList.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/WorkOrderList.tsx @@ -2,13 +2,19 @@ import { useRouter } from "next/navigation"; import React, { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { apiClient } from "@/lib/api/client"; import { dataApi } from "@/lib/api/data"; import { ConfirmModal } from "../common/ConfirmModal"; import { AcceptProcessModal } from "./AcceptProcessModal"; import { ProcessDetailModal, type ProcessStep } from "./ProcessDetailModal"; -import { ProcessWork } from "./ProcessWork"; +import { + isReworkProcess, + type WorkOrderProcessView, +} from "./types"; + +const POP_NEW_PROD_STATE_KEY = "pop-new-production-process-state"; /* 텍스트가 넘칠 때 자동 슬라이드 (마키) */ function AutoScrollText({ @@ -83,31 +89,8 @@ interface WorkInstruction { worker: string; } -interface WorkOrderProcess { - id: string; - wo_id: string; - seq_no: string; - process_code: string; - process_name: string; - status: "acceptable" | "waiting" | "in_progress" | "completed"; - plan_qty: string; - input_qty: string; - good_qty: string; - defect_qty: string; - concession_qty: string; - total_production_qty: string; - parent_process_id: string | null; - is_rework: string; - rework_source_id: string | null; - result_status: string; - started_at: string | null; - completed_at: string | null; - accepted_by?: string; - accepted_at?: string | null; - created_date?: string; - batch_id?: string | null; - equipment_code?: string; -} +/** Phase D: 정규화 View 재export (기존 타입명 유지) */ +type WorkOrderProcess = WorkOrderProcessView; interface ProcessMng { id: string; @@ -206,161 +189,6 @@ const COLS_GRID_CLASS: Record = { 3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", }; -/* ------------------------------------------------------------------ */ -/* Fullscreen Work Modal with My-Work Drawer */ -/* ------------------------------------------------------------------ */ - -function FullscreenWorkModal({ - processId, - myProcesses, - instructionMap, - itemNameMap, - multiBatchInfo, - onSwitch, - onClose, -}: { - processId: string; - myProcesses: WorkOrderProcess[]; - instructionMap: Record; - itemNameMap: Record; - multiBatchInfo: Record; - onSwitch: (id: string) => void; - onClose: () => void; -}) { - const [drawerOpen, setDrawerOpen] = React.useState(false); - - return ( -
- {/* Drawer tab handle (left edge, middle) */} - - - {/* Drawer overlay */} - {drawerOpen && ( -
setDrawerOpen(false)} - /> - )} - - {/* Drawer panel */} -
-
-

내 접수 목록

-

{myProcesses.length}건

-
-
- {myProcesses.map((proc) => { - const wi = instructionMap[proc.wo_id]; - const isActive = proc.id === processId; - return ( - - ); - })} - {myProcesses.length === 0 && ( -

- 접수한 작업이 없습니다 -

- )} -
-
- - {/* Close button */} - - - {/* ProcessWork content */} -
- -
-
- ); -} - /* ------------------------------------------------------------------ */ /* Compressed Process Steps (center-aligned) */ /* ------------------------------------------------------------------ */ @@ -374,7 +202,7 @@ function CompressedProcessSteps({ allProcesses, }: { processes: WorkOrderProcess[]; - currentSeqNo: string; + currentSeqNo: number; status: string; onClick?: () => void; batchId?: string; @@ -386,7 +214,7 @@ function CompressedProcessSteps({ (!batchId && !p.batch_id) || (batchId && p.batch_id === batchId) )) - .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); + .sort((a, b) => a.seq_no - b.seq_no); if (sorted.length === 0) return null; @@ -396,7 +224,7 @@ function CompressedProcessSteps({ // For completed status: batch_id 기반 진행률 표시 if (status === "completed") { // 같은 batch_id를 가진 SPLIT들이 어느 seq까지 완료했는지 추적 - let maxCompletedSeq = parseInt(currentSeqNo, 10); // 최소한 현재 seq까지는 완료 + let maxCompletedSeq = currentSeqNo; // 최소한 현재 seq까지는 완료 if (batchId && allProcesses) { const batchSplits = allProcesses.filter( @@ -406,23 +234,26 @@ function CompressedProcessSteps({ p.status === "completed", ); for (const s of batchSplits) { - const sSeq = parseInt(s.seq_no, 10); + const sSeq = s.seq_no; if (sSeq > maxCompletedSeq) maxCompletedSeq = sSeq; } } const completedCount = sorted.filter( - (p) => parseInt(p.seq_no, 10) <= maxCompletedSeq, + (p) => p.seq_no <= maxCompletedSeq, ).length; const allDone = completedCount === sorted.length; return (
{ + e.stopPropagation(); + onClick?.(); + }} > {sorted.map((proc, idx) => { - const seqNum = parseInt(proc.seq_no, 10); + const seqNum = proc.seq_no; const isDone = seqNum <= maxCompletedSeq; return ( @@ -534,7 +365,14 @@ function CompressedProcessSteps({ className={`flex items-center justify-center gap-1 mb-3 py-3 px-4 bg-gray-50 rounded-xl transition ${ isClickable ? "cursor-pointer hover:bg-gray-100" : "" }`} - onClick={isClickable ? onClick : undefined} + onClick={ + isClickable + ? (e) => { + e.stopPropagation(); + onClick?.(); + } + : undefined + } > {/* Collapsed before */} {beforeCollapsed > 0 && ( @@ -918,10 +756,10 @@ export function WorkOrderList(props: WorkOrderListProps) { open: boolean; processId: string; processName: string; - seqNo: string; + seqNo: number; maxQty: number; reworkSourceId?: string; - }>({ open: false, processId: "", processName: "", seqNo: "", maxQty: 0 }); + }>({ open: false, processId: "", processName: "", seqNo: 0, maxQty: 0 }); const [acceptLoading, setAcceptLoading] = useState(false); const [cancelConfirm, setCancelConfirm] = useState<{ @@ -960,42 +798,23 @@ export function WorkOrderList(props: WorkOrderListProps) { return map; }, [allProcesses]); - /** 다중품목 판단: wo_id별 DISTINCT batch_id 집합 + 순번 매핑 */ + /** 서버 응답 batch_count/batch_index를 Drawer/카드 기존 구조로 어댑팅 */ const multiBatchInfo = useMemo(() => { - // wo_id → 고유 batch_id 목록 (마스터 행 기준) - const woBatches: Record = {}; + const map: Record = {}; for (const proc of allProcesses) { - if (proc.parent_process_id) continue; // 마스터만 - if (!proc.wo_id) continue; - if (!woBatches[proc.wo_id]) woBatches[proc.wo_id] = []; + const total = Math.max(Number(proc.batch_count ?? 1) || 1, 1); + const index = Number(proc.batch_index ?? 1) || 1; + const isMulti = total > 1; const bid = proc.batch_id || ""; - if (bid && !woBatches[proc.wo_id].includes(bid)) { - woBatches[proc.wo_id].push(bid); - } - } - // proc.id → { isMulti, index, total, itemType } - const info: Record = {}; - for (const proc of allProcesses) { - if (!proc.wo_id) continue; - const batches = woBatches[proc.wo_id] || []; - const bid = proc.batch_id || ""; - const isMulti = batches.length > 1; - const index = bid ? batches.indexOf(bid) + 1 : 1; - const total = Math.max(batches.length, 1); - // item_type: batch_id가 있으면 itemTypeMap에서, 없으면 WI의 item_number로 let itemType = ""; - if (bid) { - itemType = itemTypeMap[bid] || ""; - } + if (bid) itemType = itemTypeMap[bid] || ""; if (!itemType) { const wi = instructionMap[proc.wo_id]; - if (wi?.item_number) { - itemType = itemTypeMap[wi.item_number] || ""; - } + if (wi?.item_number) itemType = itemTypeMap[wi.item_number] || ""; } - info[proc.id] = { isMulti, index, total, itemType }; + map[proc.id] = { isMulti, index, total, itemType }; } - return info; + return map; }, [allProcesses, itemTypeMap, instructionMap]); const masterProcesses = useMemo(() => { @@ -1007,9 +826,7 @@ export function WorkOrderList(props: WorkOrderListProps) { !p.parent_process_id || // 마스터 행 p.status === "in_progress" || p.status === "completed" || // 분할 행 - p.is_rework === "Y" || - p.is_rework === "true" || - p.is_rework === "1"; // 재작업 + isReworkProcess(p); // 재작업 if (include) seen.add(p.id); return include; }); @@ -1031,10 +848,7 @@ export function WorkOrderList(props: WorkOrderListProps) { const filteredProcesses = useMemo(() => { if (selectedProcess === "__all__") return []; // 공정 미선택 시 빈 목록 return masterProcesses.filter((proc) => { - const isRework = - proc.is_rework === "Y" || - proc.is_rework === "true" || - proc.is_rework === "1"; + const isRework = isReworkProcess(proc); const isMaster = !proc.parent_process_id; // 완료/진행중 탭에서는 SPLIT만 표시 (마스터 제외) if ( @@ -1076,10 +890,7 @@ export function WorkOrderList(props: WorkOrderListProps) { /* ---- Tab counts ---- */ const tabCounts = useMemo(() => { const preFiltered = masterProcesses.filter((proc) => { - const isRework = - proc.is_rework === "Y" || - proc.is_rework === "true" || - proc.is_rework === "1"; + const isRework = isReworkProcess(proc); // 재작업 카드는 공정 필터 무시 (모든 공정에서 표시) if ( selectedProcess !== "__all__" && @@ -1105,10 +916,7 @@ export function WorkOrderList(props: WorkOrderListProps) { }; for (const proc of preFiltered) { const isMaster = !proc.parent_process_id; - const isRw = - proc.is_rework === "Y" || - proc.is_rework === "true" || - proc.is_rework === "1"; + const isRw = isReworkProcess(proc); // 리워크 마스터가 in_progress/completed면 SPLIT이 있으므로 카운트 제외 if ( isRw && @@ -1136,7 +944,7 @@ export function WorkOrderList(props: WorkOrderListProps) { const openAcceptModal = async ( processId: string, processName: string, - seqNo: string, + seqNo: number, reworkSourceId?: string, ) => { try { @@ -1154,7 +962,7 @@ export function WorkOrderList(props: WorkOrderListProps) { reworkSourceId, }); } catch { - alert("접수가능량 조회 실패"); + toast.error("접수가능량 조회 실패"); } }; @@ -1174,24 +982,59 @@ export function WorkOrderList(props: WorkOrderListProps) { setAcceptModal((m) => ({ ...m, open: false })); refetch(); } else { - alert(res.data?.message || "접수 실패"); + toast.error(res.data?.message || "접수 실패"); } } catch (error: any) { - alert(error.response?.data?.message || "접수 중 오류 발생"); + toast.error(error.response?.data?.message || "접수 중 오류 발생"); } finally { setAcceptLoading(false); } }; - /* ---- Open work detail as fullscreen modal ---- */ - const [workModalProcessId, setWorkModalProcessId] = useState( - null, - ); - + /* ---- Navigate to work route ---- */ const goToWork = (processId: string) => { - setWorkModalProcessId(processId); + try { + const existing = JSON.parse( + sessionStorage.getItem(POP_NEW_PROD_STATE_KEY) || "{}", + ); + sessionStorage.setItem( + POP_NEW_PROD_STATE_KEY, + JSON.stringify({ + ...existing, + activeTab, + scrollY: window.scrollY, + }), + ); + } catch { + /* ignore */ + } + router.push(`/COMPANY_7/pop/production/work/${processId}`); }; + /* ---- Restore activeTab + scrollY on mount ---- */ + useEffect(() => { + try { + const saved = sessionStorage.getItem(POP_NEW_PROD_STATE_KEY); + if (!saved) return; + const parsed = JSON.parse(saved); + if ( + parsed.activeTab === "all" || + parsed.activeTab === "acceptable" || + parsed.activeTab === "in_progress" || + parsed.activeTab === "waiting" || + parsed.activeTab === "completed" + ) { + setActiveTab(parsed.activeTab); + } + if (typeof parsed.scrollY === "number") { + requestAnimationFrame(() => window.scrollTo(0, parsed.scrollY)); + } + } catch { + /* ignore */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + /* ---- Open process detail modal ---- */ const openDetailModal = (proc: WorkOrderProcess) => { const wi = instructionMap[proc.wo_id]; @@ -1201,19 +1044,19 @@ export function WorkOrderList(props: WorkOrderListProps) { (!proc.batch_id && !p.batch_id) || (proc.batch_id && p.batch_id === proc.batch_id) )) - .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); + .sort((a, b) => a.seq_no - b.seq_no); - const totalQty = wi ? wi.qty : parseInt(proc.plan_qty || "0", 10); + const totalQty = wi ? wi.qty : proc.plan_qty; const steps: ProcessStep[] = siblings.map((s) => { - const sInput = parseInt(s.input_qty || "0", 10); - const sGood = parseInt(s.good_qty || "0", 10); - const sDefect = parseInt(s.defect_qty || "0", 10); - const sPlan = parseInt(s.plan_qty || "0", 10); + const sInput = s.input_qty; + const sGood = s.good_qty; + const sDefect = s.defect_qty; + const sPlan = s.plan_qty; // Available = plan - input (simplified) const avail = Math.max(0, sPlan - sInput); return { - no: parseInt(s.seq_no, 10), + no: s.seq_no, name: s.process_name || s.process_code, code: s.process_code, status: s.status, @@ -1229,7 +1072,7 @@ export function WorkOrderList(props: WorkOrderListProps) { const hasReworks = allProcesses.some( (p) => p.wo_id === proc.wo_id && - (p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"), + isReworkProcess(p), ); setDetailModal({ @@ -1242,107 +1085,29 @@ export function WorkOrderList(props: WorkOrderListProps) { }); }; - /* ---- Helper: get split order label (접수 #N) ---- */ - const splitOrderMap = useMemo(() => { - // 같은 wo_id + seq_no를 가진 SPLIT들을 그룹화하여 순서 부여 - const groups: Record = {}; - for (const proc of allProcesses) { - if (!proc.parent_process_id) continue; // 마스터 행은 제외 - if (proc.status !== "in_progress" && proc.status !== "completed") - continue; - const key = `${proc.wo_id}__${proc.seq_no}`; - if (!groups[key]) groups[key] = []; - groups[key].push(proc); - } - - const result: Record = {}; - for (const key of Object.keys(groups)) { - const splits = groups[key]; - if (splits.length <= 1) continue; // 1개면 순서 표시 불필요 - // accepted_at 기준 정렬 (없으면 started_at, 그마저 없으면 id) - splits.sort((a, b) => { - const ta = a.accepted_at - ? new Date(a.accepted_at).getTime() - : a.started_at - ? new Date(a.started_at).getTime() - : 0; - const tb = b.accepted_at - ? new Date(b.accepted_at).getTime() - : b.started_at - ? new Date(b.started_at).getTime() - : 0; - return ta - tb || a.id.localeCompare(b.id); - }); - for (let i = 0; i < splits.length; i++) { - result[splits[i].id] = { order: i + 1, total: splits.length }; - } - } - return result; - }, [allProcesses]); - - /* ---- Helper: get previous process info ---- */ + /* ---- Helper: get previous process display info (name + progress) ---- */ const getPrevProcessInfo = (proc: WorkOrderProcess) => { const siblings = (processesByWo[proc.wo_id] || []) - .filter((p) => !p.parent_process_id && ( - // 같은 batch_id끼리만 형제 (다중 품목 구분) - (!proc.batch_id && !p.batch_id) || - (proc.batch_id && p.batch_id === proc.batch_id) - )) - .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); + .filter( + (p) => + !p.parent_process_id && + ((!proc.batch_id && !p.batch_id) || + (proc.batch_id && p.batch_id === proc.batch_id)), + ) + .sort((a, b) => a.seq_no - b.seq_no); const currentIdx = siblings.findIndex((p) => p.id === proc.id); if (currentIdx <= 0) return { - prevGoodQty: null as number | null, prevProcessName: null as string | null, prevProgressPct: null as number | null, }; const prev = siblings[currentIdx - 1]; - const prevGood = parseInt(prev.good_qty || "0", 10); - const prevPlan = parseInt(prev.plan_qty || "0", 10); + const prevGood = prev.good_qty; + const prevPlan = prev.plan_qty; const prevPct = prevPlan > 0 ? Math.round((prevGood / prevPlan) * 100) : 0; - // 앞공정에서 리워크로 완료된 양품 수량 - const prevSeqNo = prev.seq_no; - const reworkGoodFromPrev = allProcesses - .filter( - (p) => - p.wo_id === proc.wo_id && - p.seq_no === prevSeqNo && - p.parent_process_id && - p.status === "completed" && - (p.is_rework === "Y" || - p.is_rework === "true" || - p.is_rework === "1"), - ) - .reduce((sum, p) => sum + parseInt(p.good_qty || "0", 10), 0); - // 현재 공정에서 이미 리워크로 접수된 수량 - const reworkConsumedHere = allProcesses - .filter( - (p) => - p.wo_id === proc.wo_id && - p.seq_no === proc.seq_no && - p.parent_process_id && - (p.is_rework === "Y" || - p.is_rework === "true" || - p.is_rework === "1"), - ) - .reduce((sum, p) => sum + parseInt(p.input_qty || "0", 10), 0); - const reworkAvailableQty = Math.max( - 0, - reworkGoodFromPrev - reworkConsumedHere, - ); - - // 접수가능 수량을 초과하지 않도록 제한 - const inputQtyNum = parseInt(proc.input_qty || "0", 10); - const actualAvailable = Math.max(0, prevGood - inputQtyNum); - const clampedReworkAvailable = Math.min( - reworkAvailableQty, - actualAvailable, - ); - return { - prevGoodQty: prevGood, prevProcessName: prev.process_name || prev.process_code, prevProgressPct: prev.status === "in_progress" @@ -1350,7 +1115,6 @@ export function WorkOrderList(props: WorkOrderListProps) { : prev.status === "completed" ? 100 : null, - reworkAvailableQty: clampedReworkAvailable, }; }; @@ -1448,7 +1212,7 @@ export function WorkOrderList(props: WorkOrderListProps) { }; const diff = (order[a.status] ?? 2) - (order[b.status] ?? 2); if (diff !== 0) return diff; - return parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10); + return a.seq_no - b.seq_no; }) .map((proc) => { const wi = instructionMap[proc.wo_id]; @@ -1460,14 +1224,11 @@ export function WorkOrderList(props: WorkOrderListProps) { (proc.batch_id && p.batch_id === proc.batch_id) ), ); - const planQty = parseInt(proc.plan_qty || "0", 10); - const goodQty = parseInt(proc.good_qty || "0", 10); - const defectQty = parseInt(proc.defect_qty || "0", 10); - const inputQty = parseInt(proc.input_qty || "0", 10); - const isRework = - proc.is_rework === "Y" || - proc.is_rework === "true" || - proc.is_rework === "1"; + const planQty = proc.plan_qty; + const goodQty = proc.good_qty; + const defectQty = proc.defect_qty; + const inputQty = proc.input_qty; + const isRework = isReworkProcess(proc); const borderLeft = isRework ? "border-l-orange-500" : BORDER_LEFT_COLOR[proc.status] || "border-l-gray-300"; @@ -1492,22 +1253,30 @@ export function WorkOrderList(props: WorkOrderListProps) { : 0; const remainQty = Math.max(0, inputQty - goodQty - defectQty); const prevInfo = getPrevProcessInfo(proc); + const prevGoodQtyNum = + proc.prev_good_qty != null + ? Number(proc.prev_good_qty) + : null; - // Calculate available qty for acceptable - const availableQty = isRework - ? inputQty // 리워크 카드는 input_qty 자체가 접수 대상 - : prevInfo.prevGoodQty !== null - ? Math.max(0, prevInfo.prevGoodQty - inputQty) - : Math.max(0, planQty - inputQty); + // Server-computed: available qty + const availableQty = Number(proc.available_qty ?? 0); // Additional available for in_progress const additionalAvailable = Math.max(0, planQty - inputQty); - // Split order label - const splitInfo = splitOrderMap[proc.id]; + // Split order label (서버 계산 — split_no / split_total) + const splitInfo = + proc.split_no != null && + proc.split_total != null && + Number(proc.split_total) > 1 + ? { + order: Number(proc.split_no), + total: Number(proc.split_total), + } + : null; // 합류 불가 리워크 감지: 접수가능 물량이 전부 리워크일 때 - const reworkQtyAvail = prevInfo.reworkAvailableQty || 0; + const reworkQtyAvail = Number(proc.rework_available_qty ?? 0); const normalAvail = availableQty - reworkQtyAvail; const isReworkOnly = !isRework && @@ -1530,9 +1299,7 @@ export function WorkOrderList(props: WorkOrderListProps) { (p) => p.wo_id === proc.wo_id && !p.parent_process_id && - (p.is_rework === "Y" || - p.is_rework === "true" || - p.is_rework === "1"), + isReworkProcess(p), ); const sortedReworks = [...reworkMasters].sort((a, b) => { const da = a.created_date @@ -1557,7 +1324,7 @@ export function WorkOrderList(props: WorkOrderListProps) { originProcessName = origin.process_name || origin.process_code; originProcessCode = origin.process_code; - originDefectQty = parseInt(origin.defect_qty || "0", 10); + originDefectQty = origin.defect_qty; } } } @@ -1672,9 +1439,9 @@ export function WorkOrderList(props: WorkOrderListProps) { ) : proc.status === "acceptable" ? ( ) : proc.status === "in_progress" ? ( ) : proc.status === "completed" ? ( )} {proc.status === "in_progress" && - parseInt(proc.total_production_qty || "0", 10) === 0 && + proc.total_production_qty === 0 && proc.parent_process_id && ( +

공정 작업

+
+ +
+ ); +} diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx index fe7f5661..ddc15372 100644 --- a/frontend/app/(main)/layout.tsx +++ b/frontend/app/(main)/layout.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { usePathname } from "next/navigation"; import { AuthProvider } from "@/contexts/AuthContext"; import { MenuProvider } from "@/contexts/MenuContext"; import { MessengerProvider } from "@/contexts/MessengerContext"; @@ -8,6 +11,13 @@ import { MessengerFAB } from "@/components/messenger/MessengerFAB"; import { MessengerModal } from "@/components/messenger/MessengerModal"; export default function MainLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const isPop = pathname.includes("/pop/") || pathname.endsWith("/pop"); + + if (isPop) { + return <>{children}; + } + return ( diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 9ad75f38..4dd25c95 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -523,6 +523,15 @@ function AppLayoutInner({ children }: AppLayoutProps) { // POP 모드 진입 핸들러 const handlePopModeClick = async () => { try { + // PC → POP 전환 시 전체화면 적용 + try { + if (!document.fullscreenElement) { + await document.documentElement.requestFullscreen(); + } + } catch { + // 전체화면 미지원 또는 거부 시 무시 + } + const response = await menuApi.getPopMenus(); if (response.success && response.data) { const { childMenus, landingMenu } = response.data; diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts index d0fe5b04..3d054604 100644 --- a/frontend/hooks/pop/useCartSync.ts +++ b/frontend/hooks/pop/useCartSync.ts @@ -4,17 +4,17 @@ * DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다. * * 동작 방식: - * 1. 마운트 시 DB에서 해당 screen_id + user_id의 장바구니를 로드 + * 1. 마운트 시 DB에서 해당 카테고리(inbound/outbound)의 장바구니를 로드 * 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태) * 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제) * 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부 * * 사용 예시: * ```typescript - * const cart = useCartSync("SCR-001", "item_info"); + * const cart = useCartSync("inbound"); * - * // 품목 추가 (로컬만, DB 미반영) - * cart.addItem({ row, quantity: 10 }, "D1710008"); + * // 품목 추가 (로컬만, DB 미반영) — sourceTable은 항목별로 전달 + * cart.addItem({ row, quantity: 10 }, "D1710008", "purchase_detail"); * * // DB 저장 (pop-icon 확인 모달에서 호출) * await cart.saveToDb(); @@ -40,6 +40,8 @@ export interface CartChanges { toDelete: (string | number)[]; } +export type CartCategory = "inbound" | "outbound"; + export interface UseCartSyncReturn { cartItems: CartItemWithId[]; savedItems: CartItemWithId[]; @@ -48,7 +50,7 @@ export interface UseCartSyncReturn { isDirty: boolean; loading: boolean; - addItem: (item: CartItem, rowKey: string) => void; + addItem: (item: CartItem, rowKey: string, sourceTable?: string) => void; removeItem: (rowKey: string) => void; updateItemQuantity: ( rowKey: string, @@ -111,8 +113,9 @@ function dbRowToCartItem(dbRow: Record): CartItemWithId { function cartItemToDbRecord( item: CartItemWithId, - screenId: string, + cartType: string, selectedColumns?: string[], + screenId?: string, ): Record { const rowData = selectedColumns && selectedColumns.length > 0 @@ -121,9 +124,8 @@ function cartItemToDbRecord( ) : item.row; - return { - cart_type: "pop", - screen_id: screenId, + const record: Record = { + cart_type: cartType, source_table: item.sourceTable, row_key: item.rowKey, row_data: JSON.stringify(rowData), @@ -136,6 +138,11 @@ function cartItemToDbRecord( status: item.status, memo: item.memo || "", }; + // 레거시 모드: screen_id 포함 + if (screenId) { + record.screen_id = screenId; + } + return record; } // ===== dirty check: 두 배열의 내용이 동일한지 비교 ===== @@ -157,32 +164,48 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean { // ===== 훅 본체 ===== +// 오버로드: 카테고리 기반 (신규) + 레거시 screen_id 기반 (PopCardListComponent 등) +export function useCartSync(category: CartCategory): UseCartSyncReturn; +export function useCartSync(screenId: string, sourceTable: string): UseCartSyncReturn; export function useCartSync( - screenId: string, - sourceTable: string, + categoryOrScreenId: string, + sourceTable?: string, ): UseCartSyncReturn { + // 레거시 호출 감지: 2번째 인자가 있으면 구 시그니처 (screen_id 기반) + const isLegacy = sourceTable !== undefined; + const cartTypeValue = isLegacy ? "pop" : `pop_${categoryOrScreenId}`; + const screenIdValue = isLegacy ? categoryOrScreenId : undefined; + const legacySourceTable = isLegacy ? sourceTable : undefined; + const [cartItems, setCartItems] = useState([]); const [savedItems, setSavedItems] = useState([]); const [syncStatus, setSyncStatus] = useState("clean"); const [loading, setLoading] = useState(false); - const screenIdRef = useRef(screenId); - const sourceTableRef = useRef(sourceTable); - screenIdRef.current = screenId; - sourceTableRef.current = sourceTable; + const categoryRef = useRef(categoryOrScreenId); + categoryRef.current = categoryOrScreenId; + const cartTypeRef = useRef(cartTypeValue); + cartTypeRef.current = cartTypeValue; + const legacySourceTableRef = useRef(legacySourceTable); + legacySourceTableRef.current = legacySourceTable; // ----- DB에서 장바구니 로드 ----- const loadFromDb = useCallback(async () => { - if (!screenId || !sourceTable) return; + if (!categoryOrScreenId) return; setLoading(true); try { + const filters: Record = { + cart_type: cartTypeValue, + status: "in_cart", + }; + // 레거시: screen_id로 필터링 + if (screenIdValue) { + filters.screen_id = screenIdValue; + } + const result = await dataApi.getTableData("cart_items", { size: 500, - filters: { - screen_id: screenId, - cart_type: "pop", - status: "in_cart", - }, + filters, }); const items = (result.data || []).map(dbRowToCartItem); @@ -194,7 +217,7 @@ export function useCartSync( } finally { setLoading(false); } - }, [screenId, sourceTable]); + }, [categoryOrScreenId, cartTypeValue, screenIdValue]); // 마운트 시 자동 로드 useEffect(() => { @@ -213,7 +236,7 @@ export function useCartSync( // ----- 로컬 조작 (DB 미반영) ----- - const addItem = useCallback((item: CartItem, rowKey: string) => { + const addItem = useCallback((item: CartItem, rowKey: string, sourceTable?: string) => { setCartItems((prev) => { const exists = prev.find((i) => i.rowKey === rowKey); if (exists) { @@ -232,7 +255,7 @@ export function useCartSync( const newItem: CartItemWithId = { ...item, cartId: undefined, - sourceTable: sourceTableRef.current, + sourceTable: sourceTable || legacySourceTableRef.current || "", rowKey, status: "in_cart", _origin: "local", @@ -293,7 +316,8 @@ export function useCartSync( // ----- diff 계산 (백엔드 전송용) ----- const getChanges = useCallback( (selectedColumns?: string[]): CartChanges => { - const currentScreenId = screenIdRef.current; + const currentCartType = cartTypeRef.current; + const currentScreenId = isLegacy ? categoryRef.current : undefined; const cartRowKeys = new Set(cartItems.map((i) => i.rowKey)); const toDeleteItems = savedItems.filter( @@ -306,7 +330,6 @@ export function useCartSync( if (!c.cartId) return false; const saved = savedMap.get(c.rowKey); if (!saved) return false; - // row JSON 비교 (검사 결과 등 포함) const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row); return ( c.quantity !== saved.quantity || @@ -318,11 +341,11 @@ export function useCartSync( return { toCreate: toCreateItems.map((item) => - cartItemToDbRecord(item, currentScreenId, selectedColumns), + cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId), ), toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, - ...cartItemToDbRecord(item, currentScreenId, selectedColumns), + ...cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId), })), toDelete: toDeleteItems.map((item) => item.cartId!), }; @@ -335,7 +358,8 @@ export function useCartSync( async (selectedColumns?: string[]): Promise => { setSyncStatus("saving"); try { - const currentScreenId = screenIdRef.current; + const currentCartType = cartTypeRef.current; + const currentScreenId = isLegacy ? categoryRef.current : undefined; // 삭제 대상: savedItems에 있지만 cartItems에 없는 것 const cartRowKeys = new Set(cartItems.map((i) => i.rowKey)); @@ -375,8 +399,9 @@ export function useCartSync( for (const item of toCreate) { const record = cartItemToDbRecord( item, - currentScreenId, + currentCartType, selectedColumns, + currentScreenId, ); // cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성 const recordWithId = { id: crypto.randomUUID(), ...record }; @@ -386,8 +411,9 @@ export function useCartSync( for (const item of toUpdate) { const record = cartItemToDbRecord( item, - currentScreenId, + currentCartType, selectedColumns, + currentScreenId, ); promises.push( dataApi.updateRecord("cart_items", item.cartId!, record), diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index d52bc0d3..ec79203e 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -132,6 +132,14 @@ export const useLogin = () => { if (isPopMode) { const popPath = result.data?.popLandingPath; if (popPath) { + // POP 모드 로그인 시 전체화면 전환 시도 + try { + if (!document.fullscreenElement) { + await document.documentElement.requestFullscreen(); + } + } catch { + // 전체화면 미지원 또는 거부 시 무시 + } router.push(popPath); } else { setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요."); diff --git a/frontend/lib/api/packaging.ts b/frontend/lib/api/packaging.ts index 016e7d49..cd3ec742 100644 --- a/frontend/lib/api/packaging.ts +++ b/frontend/lib/api/packaging.ts @@ -97,6 +97,28 @@ export async function deletePkgUnit(id: string) { return res.data as { success: boolean; message?: string }; } +// --- 품목별 포장단위 조회 API --- + +export interface PkgUnitByItem { + id: string; + pkg_code: string; + pkg_name: string; + pkg_type: string; + status: string; + width_mm: number | null; + length_mm: number | null; + height_mm: number | null; + self_weight_kg: number | null; + max_load_kg: number | null; + volume_l: number | null; + pkg_qty: number; +} + +export async function getPkgUnitsByItem(itemNumber: string) { + const res = await apiClient.get(`/packaging/pkg-units-by-item/${encodeURIComponent(itemNumber)}`); + return res.data as { success: boolean; data: PkgUnitByItem[] }; +} + // --- 포장단위 매칭품목 API --- export async function getPkgUnitItems(pkgCode: string) { @@ -114,6 +136,29 @@ export async function deletePkgUnitItem(id: string) { return res.data as { success: boolean; message?: string }; } +// --- 포장코드별 적재함 조회 API --- + +export interface LoadingUnitByPkg { + id: string; + loading_code: string; + loading_name: string; + loading_type: string; + status: string; + width_mm: number | null; + length_mm: number | null; + height_mm: number | null; + self_weight_kg: number | null; + max_load_kg: number | null; + max_stack: number | null; + max_load_qty: number; + load_method: string | null; +} + +export async function getLoadingUnitsByPkg(pkgCode: string) { + const res = await apiClient.get(`/packaging/loading-units-by-pkg/${encodeURIComponent(pkgCode)}`); + return res.data as { success: boolean; data: LoadingUnitByPkg[] }; +} + // --- 적재함 API --- export async function getLoadingUnits() { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 35eb950d..912701f3 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -30,6 +30,7 @@ "components/screen/ScreenDesigner_old.tsx", "components/admin/dashboard/widgets/yard-3d/Yard3DCanvas_NEW.tsx", "components/flow/FlowDataListModal.tsx", - "test-scenarios" + "test-scenarios/**", + "app/test-type-safety/**" ] }