From bfac350ed4075bbea663ca8cd64352e10fa1dafd Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 9 Apr 2026 14:28:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20POP=20=EC=8B=9C=EC=97=B0=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=20=E2=80=94=205=EA=B0=9C=20=ED=99=94=EB=A9=B4=20+=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20+=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/outboundController.ts | 17 + .../controllers/popProductionController.ts | 97 ++- .../src/controllers/receivingController.ts | 16 +- .../src/routes/inspectionResultRoutes.ts | 192 ++++-- .../app/(pop)/pop/inventory/history/page.tsx | 12 + frontend/app/(pop)/pop/inventory/page.tsx | 12 + .../app/(pop)/pop/quality/inspection/page.tsx | 12 + frontend/app/(pop)/pop/quality/page.tsx | 12 + .../components/pop/hardcoded/MenuIcons.tsx | 4 +- .../pop/hardcoded/common/ConfirmModal.tsx | 82 +++ .../pop/hardcoded/inbound/InboundCart.tsx | 49 ++ .../pop/hardcoded/inbound/InboundCartPage.tsx | 185 ++++-- .../pop/hardcoded/inbound/InspectionModal.tsx | 265 +++++++-- frontend/components/pop/hardcoded/index.ts | 2 + .../hardcoded/inventory/DateRangePicker.tsx | 250 ++++++++ .../pop/hardcoded/inventory/InOutHistory.tsx | 551 ++++++++++++++++++ .../pop/hardcoded/inventory/InventoryHome.tsx | 285 +++++++++ .../pop/hardcoded/inventory/index.ts | 2 + .../hardcoded/outbound/OutboundCartPage.tsx | 6 +- .../pop/hardcoded/production/ProcessWork.tsx | 197 ++++--- .../hardcoded/production/WorkOrderList.tsx | 51 +- .../pop/hardcoded/quality/InspectionList.tsx | 542 +++++++++++++++++ .../pop/hardcoded/quality/QualityHome.tsx | 219 +++++++ .../components/pop/hardcoded/quality/index.ts | 2 + frontend/hooks/pop/useCartSync.ts | 26 +- 25 files changed, 2804 insertions(+), 284 deletions(-) create mode 100644 frontend/app/(pop)/pop/inventory/history/page.tsx create mode 100644 frontend/app/(pop)/pop/inventory/page.tsx create mode 100644 frontend/app/(pop)/pop/quality/inspection/page.tsx create mode 100644 frontend/app/(pop)/pop/quality/page.tsx create mode 100644 frontend/components/pop/hardcoded/common/ConfirmModal.tsx create mode 100644 frontend/components/pop/hardcoded/inventory/DateRangePicker.tsx create mode 100644 frontend/components/pop/hardcoded/inventory/InOutHistory.tsx create mode 100644 frontend/components/pop/hardcoded/inventory/InventoryHome.tsx create mode 100644 frontend/components/pop/hardcoded/inventory/index.ts create mode 100644 frontend/components/pop/hardcoded/quality/InspectionList.tsx create mode 100644 frontend/components/pop/hardcoded/quality/QualityHome.tsx create mode 100644 frontend/components/pop/hardcoded/quality/index.ts diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 7e77974c..b59480c6 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -179,6 +179,23 @@ export async function create(req: AuthenticatedRequest, res: Response) { const locCode = location_code || item.location_code || null; const outQty = Number(item.outbound_qty) || 0; if (itemCode && outQty > 0) { + // 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 ROLLBACK) + const stockCheck = await client.query( + `SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as cur + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + const currentStock = parseFloat(stockCheck.rows[0]?.cur || '0'); + if (currentStock < outQty) { + throw new Error( + `재고 부족: ${item.item_name || itemCode} (창고 ${whCode || '미지정'}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}` + ); + } + const existingStock = await client.query( `SELECT id FROM inventory_stock WHERE company_code = $1 AND item_code = $2 diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 8d0af7c5..14334b75 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -1157,21 +1157,30 @@ export const saveResult = async ( } if (shouldActivateNext) { - // 다음 seq 활성화 (completed도 재활성화 — 새 양품이 들어오면 추가 접수 가능) - const nextSeq = String(seqNum + 1); - const nextUpdate = await pool.query( - `UPDATE work_order_process - SET status = 'acceptable', - updated_date = NOW() - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL - RETURNING id, process_name, status`, - [wo_id, nextSeq, companyCode] + // 다음 seq 활성화 (seq_no 비순차 대응: seqNum+1이 아니라 "현재보다 큰 가장 작은 seq_no") + const nextSeqQuery = await pool.query( + `SELECT MIN(CAST(seq_no AS int)) as next_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL + AND CAST(seq_no AS int) > $3`, + [wo_id, companyCode, seqNum] ); - if (nextUpdate.rowCount > 0) { - logger.info("[pop/production] 다음 공정 상태 전환", { - nextProcess: nextUpdate.rows[0], - }); + const actualNextSeq = nextSeqQuery.rows[0]?.next_seq; + if (actualNextSeq != null) { + const nextUpdate = await pool.query( + `UPDATE work_order_process + SET status = 'acceptable', + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + RETURNING id, process_name, status`, + [wo_id, String(actualNextSeq), companyCode] + ); + if (nextUpdate.rowCount > 0) { + logger.info("[pop/production] 다음 공정 상태 전환", { + nextProcess: nextUpdate.rows[0], + }); + } } } } @@ -1672,17 +1681,37 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + // 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단 + // (라우팅 seq_no가 1, 2, 3이 아니라 10, 20, 30 같은 비순차여도 정상 동작) + const minSeqCheck = await pool.query( + `SELECT MIN(CAST(seq_no AS int)) as min_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`, + [wo_id, companyCode] + ); + const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; + const isFirstProcess = seqNum <= minSeq; + if (!isFirstProcess) { + // 이전 공정 찾기 (seq_no가 더 작은 가장 가까운 공정) + const prevProcessSeq = await pool.query( + `SELECT MAX(CAST(seq_no AS int)) as prev_seq FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, prevSeq, companyCode] + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL + AND CAST(seq_no AS int) < $3`, + [wo_id, companyCode, seqNum] ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; + if (actualPrevSeq != null) { + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [wo_id, String(actualPrevSeq), companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } } } availableQty = Math.max(0, prevGoodQty - myInputQty); @@ -1848,8 +1877,26 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); + // 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단 + const minSeqCheck = await client.query( + `SELECT MIN(CAST(seq_no AS int)) as min_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`, + [row.wo_id, companyCode] + ); + const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; + const isFirstProcess = seqNum <= minSeq; + if (!isFirstProcess) { + // 이전 공정 = 이 공정보다 작은 seq_no 중 가장 큰 값 + const prevProcessSeq = await client.query( + `SELECT MAX(CAST(seq_no AS int)) as prev_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL + AND CAST(seq_no AS int) < $3`, + [row.wo_id, companyCode, seqNum] + ); + const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; + const prevSeq = actualPrevSeq != null ? String(actualPrevSeq) : String(seqNum - 1); const prevProcess = await client.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index fb358a06..3fd5ebac 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -689,7 +689,13 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price, COALESCE(po.status, '') AS status, COALESCE(pd.due_date, po.due_date) AS due_date, - 'purchase_detail' AS source_table + 'purchase_detail' AS source_table, + CASE WHEN EXISTS ( + SELECT 1 FROM item_inspection_info iii + WHERE iii.company_code = pd.company_code + AND COALESCE(iii.is_active, 'Y') = 'Y' + AND iii.item_code = COALESCE(NULLIF(pd.item_code, ''), ii.item_number) + ) THEN 'self' ELSE NULL END AS inspection_type FROM purchase_detail pd LEFT JOIN purchase_order_mng po ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code @@ -722,7 +728,13 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price, po.status, po.due_date, - 'purchase_order_mng' AS source_table + 'purchase_order_mng' AS source_table, + CASE WHEN EXISTS ( + SELECT 1 FROM item_inspection_info iii + WHERE iii.company_code = po.company_code + AND COALESCE(iii.is_active, 'Y') = 'Y' + AND iii.item_code = po.item_code + ) THEN 'self' ELSE NULL END AS inspection_type FROM purchase_order_mng po WHERE po.company_code = $1 AND NOT EXISTS ( diff --git a/backend-node/src/routes/inspectionResultRoutes.ts b/backend-node/src/routes/inspectionResultRoutes.ts index 1b2c7cde..d6e9375c 100644 --- a/backend-node/src/routes/inspectionResultRoutes.ts +++ b/backend-node/src/routes/inspectionResultRoutes.ts @@ -94,7 +94,59 @@ router.get("/", async (req: Request, res: Response) => { } }); -// ---- 검사 결과 저장 (INSERT or UPDATE) ---- +// ---- 검사번호 채번 (PC numberingRuleService 활용) ---- +async function generateInspectionNumber(companyCode: string): Promise { + // PC 채번 서비스 동적 import (순환 참조 방지) + const { numberingRuleService } = await import("../services/numberingRuleService"); + + // 1) inspection_result_mng / inspection_number 채번 규칙 조회 + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + "inspection_result_mng", + "inspection_number" + ); + + if (rule && rule.ruleId) { + // 2) PC API와 동일한 allocateCode 호출 → 실제 시퀀스 +1 + return await numberingRuleService.allocateCode(rule.ruleId, companyCode); + } + + // fallback: 채번 규칙 없으면 단순 SELECT MAX + const { getPool } = await import("../database/db"); + const pool = getPool(); + const year = new Date().getFullYear(); + const prefix = `QI-${year}-`; + const result = await pool.query( + `SELECT inspection_number FROM inspection_result_mng + WHERE company_code = $1 AND inspection_number LIKE $2 + ORDER BY inspection_number DESC LIMIT 1`, + [companyCode, `${prefix}%`] + ); + let nextSeq = 1; + if (result.rows.length > 0) { + const lastNumber = result.rows[0].inspection_number; + const match = lastNumber.match(/(\d+)$/); + if (match) nextSeq = parseInt(match[1], 10) + 1; + } + return `${prefix}${String(nextSeq).padStart(4, "0")}`; +} + +// ---- 검사번호 채번 전용 엔드포인트 (검사 모달에서 검사 완료 시) ---- +// POST /api/pop/inspection-result/allocate-number +router.post("/allocate-number", async (req: Request, res: Response) => { + const companyCode = (req as any).user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + try { + const inspectionNumber = await generateInspectionNumber(companyCode); + return res.json({ success: true, data: { inspectionNumber } }); + } catch (err: any) { + return res.status(500).json({ success: false, message: err.message }); + } +}); + +// ---- 검사 결과 저장 (마스터 + 디테일 트랜잭션) ---- // POST /api/pop/inspection-result router.post("/", async (req: Request, res: Response) => { const pool = getPool(); @@ -106,6 +158,7 @@ router.post("/", async (req: Request, res: Response) => { } const { + inspectionNumber: providedNumber, // 프론트에서 미리 채번한 번호 (있으면 재사용) referenceTable, referenceId, screenId, @@ -115,7 +168,14 @@ router.post("/", async (req: Request, res: Response) => { inspectionType, items, // 검사 항목별 결과 배열 overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription, memo, + inspector, + supplierCode, + supplierName, isCompleted, } = req.body; @@ -127,59 +187,117 @@ router.post("/", async (req: Request, res: Response) => { try { await client.query("BEGIN"); - // 기존 결과 삭제 (동일 referenceId + referenceTable 기준 덮어쓰기) + // 1. 동일 referenceId + referenceTable 기존 마스터/디테일 삭제 (덮어쓰기) if (referenceId && referenceTable) { await client.query( - `DELETE FROM inspection_result + `DELETE FROM inspection_result WHERE master_id IN ( + SELECT id FROM inspection_result_mng + WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3 + )`, + [companyCode, referenceId, referenceTable] + ); + await client.query( + `DELETE FROM inspection_result_mng WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`, [companyCode, referenceId, referenceTable] ); } - const insertedIds: string[] = []; + // 2. 검사번호 (프론트에서 미리 받았으면 재사용, 없으면 새로 채번) + const inspectionNumber = providedNumber || await generateInspectionNumber(companyCode); + + // 3. 마스터 INSERT + const completedFlag = isCompleted ? "Y" : "N"; + const completedDate = isCompleted ? new Date() : null; + const masterResult = await client.query( + `INSERT INTO inspection_result_mng ( + company_code, writer, inspection_number, + reference_table, reference_id, screen_id, + item_id, item_code, item_name, + inspection_type, total_qty, good_qty, bad_qty, + overall_judgment, defect_description, memo, + inspector, inspection_date, + supplier_code, supplier_name, + is_completed, completed_date + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, $21 + ) RETURNING id, inspection_number`, + [ + companyCode, + writer, + inspectionNumber, + referenceTable || null, + referenceId || null, + screenId || null, + itemId || null, + itemCode || null, + itemName || null, + inspectionType || null, + totalQty != null ? Number(totalQty) : null, + goodQty != null ? Number(goodQty) : null, + badQty != null ? Number(badQty) : null, + overallJudgment || null, + defectDescription || null, + memo || null, + inspector || writer, + supplierCode || null, + supplierName || null, + completedFlag, + completedDate, + ] + ); + const masterId = masterResult.rows[0].id; + + // 4. 디테일 N건 INSERT + const insertedDetailIds: string[] = []; for (const item of items) { - const completedFlag = isCompleted ? "Y" : "N"; - const completedDate = isCompleted ? new Date() : null; - const insertSql = ` - INSERT INTO inspection_result ( - company_code, writer, + const detailResult = await client.query( + `INSERT INTO inspection_result ( + company_code, writer, master_id, reference_table, reference_id, screen_id, inspection_info_id, item_id, item_code, item_name, inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required, measured_value, judgment, overall_judgment, memo, is_completed, completed_date ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 - ) - RETURNING id - `; - const result = await client.query(insertSql, [ - companyCode, - writer, - referenceTable || null, - referenceId || null, - screenId || null, - item.inspectionInfoId || null, - itemId || item.itemId || null, - itemCode || item.itemCode || null, - itemName || item.itemName || null, - inspectionType || item.inspectionType || null, - item.inspectionItemName || null, - item.inspectionStandard || null, - item.passCriteria || null, - item.isRequired || "Y", - item.measuredValue || null, - item.judgment || null, - overallJudgment || null, - memo || null, - completedFlag, - completedDate, - ]); - insertedIds.push(result.rows[0].id); + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 + ) RETURNING id`, + [ + companyCode, + writer, + masterId, + referenceTable || null, + referenceId || null, + screenId || null, + item.inspectionInfoId || null, + itemId || item.itemId || null, + itemCode || item.itemCode || null, + itemName || item.itemName || null, + inspectionType || item.inspectionType || null, + item.inspectionItemName || null, + item.inspectionStandard || null, + item.passCriteria || null, + item.isRequired || "Y", + item.measuredValue || null, + item.judgment || null, + overallJudgment || null, + memo || null, + completedFlag, + completedDate, + ] + ); + insertedDetailIds.push(detailResult.rows[0].id); } await client.query("COMMIT"); - return res.json({ success: true, data: { ids: insertedIds } }); + return res.json({ + success: true, + data: { + masterId, + inspectionNumber, + detailIds: insertedDetailIds, + }, + }); } catch (err: any) { await client.query("ROLLBACK"); return res.status(500).json({ success: false, message: err.message }); diff --git a/frontend/app/(pop)/pop/inventory/history/page.tsx b/frontend/app/(pop)/pop/inventory/history/page.tsx new file mode 100644 index 00000000..dafee006 --- /dev/null +++ b/frontend/app/(pop)/pop/inventory/history/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InOutHistory } from "@/components/pop/hardcoded/inventory"; + +export default function InOutHistoryPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/inventory/page.tsx b/frontend/app/(pop)/pop/inventory/page.tsx new file mode 100644 index 00000000..25499758 --- /dev/null +++ b/frontend/app/(pop)/pop/inventory/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InventoryHome } from "@/components/pop/hardcoded/inventory"; + +export default function InventoryPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/quality/inspection/page.tsx b/frontend/app/(pop)/pop/quality/inspection/page.tsx new file mode 100644 index 00000000..e682c963 --- /dev/null +++ b/frontend/app/(pop)/pop/quality/inspection/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InspectionList } from "@/components/pop/hardcoded/quality"; + +export default function InspectionListPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/quality/page.tsx b/frontend/app/(pop)/pop/quality/page.tsx new file mode 100644 index 00000000..9f0bf528 --- /dev/null +++ b/frontend/app/(pop)/pop/quality/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { QualityHome } from "@/components/pop/hardcoded/quality"; + +export default function QualityPage() { + return ( + + + + ); +} diff --git a/frontend/components/pop/hardcoded/MenuIcons.tsx b/frontend/components/pop/hardcoded/MenuIcons.tsx index 94a56813..15184777 100644 --- a/frontend/components/pop/hardcoded/MenuIcons.tsx +++ b/frontend/components/pop/hardcoded/MenuIcons.tsx @@ -60,7 +60,7 @@ const MENU_ITEMS: MenuIconItem[] = [ ), - href: "/pop/screens/quality", + href: "/pop/quality", }, { id: "equipment", @@ -84,7 +84,7 @@ const MENU_ITEMS: MenuIconItem[] = [ ), - href: "/pop/screens/inventory", + href: "/pop/inventory", }, // 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동 { diff --git a/frontend/components/pop/hardcoded/common/ConfirmModal.tsx b/frontend/components/pop/hardcoded/common/ConfirmModal.tsx new file mode 100644 index 00000000..8b5a5e41 --- /dev/null +++ b/frontend/components/pop/hardcoded/common/ConfirmModal.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React from "react"; + +export interface ConfirmModalProps { + open: boolean; + title?: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: "primary" | "danger" | "success"; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * POP 공용 확인 모달 (native confirm() 대체) + * 모바일 친화 디자인, bottom-sheet 스타일 + */ +export function ConfirmModal({ + open, + title, + message, + confirmText = "확인", + cancelText = "취소", + variant = "primary", + onConfirm, + onCancel, +}: ConfirmModalProps) { + if (!open) return null; + + const confirmBg = + variant === "danger" + ? "bg-gradient-to-b from-red-500 to-red-600 hover:from-red-600 hover:to-red-700" + : variant === "success" + ? "bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700" + : "bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700"; + + return ( +
+ {/* Overlay */} +
+ + {/* Center modal */} +
+
e.stopPropagation()} + > + {/* Body */} +
+ {title && ( +

{title}

+ )} +

+ {message} +

+
+ + {/* Buttons */} +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/components/pop/hardcoded/inbound/InboundCart.tsx b/frontend/components/pop/hardcoded/inbound/InboundCart.tsx index 36efb141..e9649cc9 100644 --- a/frontend/components/pop/hardcoded/inbound/InboundCart.tsx +++ b/frontend/components/pop/hardcoded/inbound/InboundCart.tsx @@ -197,6 +197,55 @@ export function InboundCart({ const res = await apiClient.post("/receiving", payload); if (res.data?.success) { + // 2-1. 검사 결과가 있는 항목 → inspection_result에 저장 + const insertedDetails: any[] = res.data?.data?.details ?? res.data?.data?.items ?? []; + const inboundHeaderNo = res.data?.data?.header?.inbound_number || inboundNumber || ""; + const inspectionPromises = items + .map((item, idx) => { + if (!item.inspectionResult?.completed) return null; + const matchedDetail = insertedDetails[idx] ?? {}; + const referenceId = matchedDetail.id || matchedDetail.detail_id || `${inboundHeaderNo}-${idx + 1}`; + const goodQty = item.inspectionResult.goodQty || 0; + const badQty = item.inspectionResult.badQty || 0; + const totalQty = goodQty + badQty; + const overallJudgment = badQty === 0 ? "합격" : "불합격"; + return apiClient.post("/pop/inspection-result", { + inspectionNumber: item.inspectionResult.inspectionNumber, // 카트에서 받은 검사번호 재사용 + referenceTable: "inbound_mng", + referenceId, + screenId: "pop_inbound_inspection", + itemId: item.item_id || null, + itemCode: item.item_code, + itemName: item.item_name, + inspectionType: "입고검사", + overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription: badQty > 0 ? `불량 ${badQty}건` : "", + memo: item.inspectionResult.remark || "", + supplierCode: item.supplier_code || null, + supplierName: item.supplier_name || null, + isCompleted: true, + items: item.inspectionResult.items.map((insp: any) => ({ + inspectionInfoId: insp.id || null, + inspectionItemName: insp.inspection_item_name, + inspectionStandard: insp.inspection_standard, + passCriteria: insp.pass_criteria, + isRequired: insp.is_required || "Y", + measuredValue: insp.measured_value || "", + judgment: insp.result || null, + })), + }).catch((err) => { + console.error("[inspection_result 저장 실패]", item.item_code, err?.message); + }); + }) + .filter(Boolean); + + if (inspectionPromises.length > 0) { + await Promise.allSettled(inspectionPromises); + } + // 3. cart_items DB 정리 (백그라운드, 논블로킹) // cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨) const rowKeys = items.map((item) => item.source_id || item.id).filter(Boolean); diff --git a/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx index d6a90ec3..c8b5ccfb 100644 --- a/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx +++ b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx @@ -108,6 +108,37 @@ export function InboundCartPage() { } }, [items]); + /* Sync inspectionResults with cart.row.inspectionResult + * 페이지 새로고침/재진입 시 cart_items에 저장된 inspectionResult를 Map으로 복원. + * 주의: delete는 명시적 검사 취소(handleCancel)에서만 처리. + * (cart.saveToDb 후 row JSON이 stale할 수 있어 delete 로직은 race condition 유발) */ + useEffect(() => { + setInspectionResults((prev) => { + const next = new Map(prev); + let changed = false; + cart.cartItems.forEach((c) => { + const stored = (c.row as Record)?.inspectionResult; + if (stored && typeof stored === "object") { + // 유효한 검사 결과 → Map에 추가 (덮어쓰지 않음, 로컬 우선) + if (!next.has(c.rowKey)) { + next.set(c.rowKey, stored as InspectionResult); + changed = true; + } + } + // null/undefined여도 Map에서 자동 제거하지 않음 — 명시적 cancel만 처리 + }); + // 카트에서 사라진 rowKey만 정리 (실제 카트 삭제 시) + const cartKeys = new Set(cart.cartItems.map((c) => c.rowKey)); + Array.from(next.keys()).forEach((k) => { + if (!cartKeys.has(k)) { + next.delete(k); + changed = true; + } + }); + return changed ? next : prev; + }); + }, [cart.cartItems]); + /* Warehouse */ const [warehouses, setWarehouses] = useState([]); const [selectedWarehouse, setSelectedWarehouse] = useState(""); @@ -248,29 +279,38 @@ export function InboundCartPage() { const handleInspectionComplete = (result: InspectionResult) => { if (!inspectionTarget) return; + const targetRowKey = inspectionTarget.rowKey; setInspectionResults((prev) => { const next = new Map(prev); - next.set(inspectionTarget.rowKey, result); + next.set(targetRowKey, result); return next; }); + // cart_items.row_data에 검사 결과 저장 (페이지 새로고침해도 유지) + cart.updateItemRow(targetRowKey, { inspectionResult: result }); setInspectionTarget(null); + // 즉시 DB 저장 (자동저장 디바운스를 기다리지 않음) + setTimeout(() => { + cart.saveToDb().catch((err) => console.error("[검사 결과 저장 실패]", err)); + }, 100); }; /* Pass inspection (non-required only) */ const handlePassInspection = (rowKey: string) => { const item = items.find((i) => i.rowKey === rowKey); if (!item) return; + const result: InspectionResult = { + items: [], + goodQty: item.inbound_qty, + badQty: 0, + remark: "pass", + completed: true, + }; setInspectionResults((prev) => { const next = new Map(prev); - next.set(rowKey, { - items: [], - goodQty: item.inbound_qty, - badQty: 0, - remark: "pass", - completed: true, - }); + next.set(rowKey, result); return next; }); + cart.updateItemRow(rowKey, { inspectionResult: result }); }; const getInspectionResult = (rowKey: string): InspectionResult | null => { @@ -282,6 +322,8 @@ export function InboundCartPage() { /* ------------------------------------------------------------------ */ const selectedItemsList = items.filter((i) => selectedItems.has(i.id)); + // CEO 정책 (2026-04-09 시연 결정): 검사 필수 항목 미완료 시 확정 차단 + // 검사 빠진 입고가 검사관리에서 추적 안 되므로, 입력 시점에 막음 const hasUnfinishedRequiredInspection = selectedItemsList.some( (item) => item.inspection_required && @@ -300,10 +342,8 @@ export function InboundCartPage() { return; } - if (hasUnfinishedRequiredInspection) { - setResultMsg("오류: 필수 검사를 완료해주세요."); - return; - } + // 검사 미완료여도 확정 가능. 단지 inspection_result에 안 들어가거나 "대기" 상태로 기록. + // (CEO 정책: 입고 자체는 진행, 검사 결과만 누락/대기 상태로 표시) setConfirming(true); setResultMsg(null); @@ -366,6 +406,66 @@ export function InboundCartPage() { const res = await apiClient.post("/receiving", payload); if (res.data?.success) { + // 검사 결과를 inspection_result_mng + inspection_result에 저장 + const insertedDetails: Array> = + (res.data?.data?.details as Array>) ?? + (res.data?.data?.items as Array>) ?? + []; + const inboundHeaderNo: string = + (res.data?.data?.header as { inbound_number?: string } | undefined)?.inbound_number || + finalNumber || ""; + const inspectionPromises = selectedItemsList + .map((item, idx) => { + const inspResult = getInspectionResult(item.rowKey); + if (!inspResult?.completed) return null; + const matchedDetail = insertedDetails[idx] ?? {}; + const referenceId = + (matchedDetail.id as string) || + (matchedDetail.detail_id as string) || + `${inboundHeaderNo}-${idx + 1}`; + const goodQty = inspResult.goodQty || 0; + const badQty = inspResult.badQty || 0; + const totalQty = goodQty + badQty; + const overallJudgment = badQty === 0 ? "합격" : "불합격"; + return apiClient + .post("/pop/inspection-result", { + inspectionNumber: inspResult.inspectionNumber, + referenceTable: "inbound_mng", + referenceId, + screenId: "pop_inbound_inspection", + itemId: item.item_id || null, + itemCode: item.item_code, + itemName: item.item_name, + inspectionType: "입고검사", + overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription: badQty > 0 ? `불량 ${badQty}건` : "", + memo: inspResult.remark || "", + supplierCode: item.supplier_code || null, + supplierName: item.supplier_name || null, + isCompleted: true, + items: inspResult.items.map((insp) => ({ + inspectionInfoId: insp.id || null, + inspectionItemName: insp.inspection_item_name, + inspectionStandard: insp.inspection_standard, + passCriteria: insp.pass_criteria, + isRequired: insp.is_required || "Y", + measuredValue: insp.measured_value || "", + judgment: insp.result || null, + })), + }) + .catch((err: unknown) => { + const e = err as { message?: string }; + console.error("[inspection_result 저장 실패]", item.item_code, e?.message); + }); + }) + .filter(Boolean); + if (inspectionPromises.length > 0) { + await Promise.all(inspectionPromises); + } + // Remove confirmed items from cart - direct DB delete for reliability const confirmedItems = [...selectedItemsList]; const { dataApi } = await import("@/lib/api/data"); @@ -878,45 +978,17 @@ export function InboundCartPage() {
)} - {/* ===== Footer summary (no confirm button -- header only) ===== */} - {items.length > 0 && ( -
- {/* Result message */} - {resultMsg && ( -
- {resultMsg} -
- )} - - {/* Required inspection warning */} - {hasUnfinishedRequiredInspection && ( -
- 필수 검사를 완료해주세요. 검사 미완료 품목이 있어 확정할 수 없습니다. -
- )} - - {/* Summary only (no big confirm button) */} -
- - 선택{" "} - - {selectedItemsList.length} - - /{items.length}건 - - - 합계 수량:{" "} - - {totalQty.toLocaleString()} - {" "} - EA - + {/* ===== Result toast (only when message exists) ===== */} + {resultMsg && ( +
+
+ {resultMsg}
)} @@ -1036,6 +1108,17 @@ export function InboundCartPage() { setInspectionTarget(null); }} onComplete={handleInspectionComplete} + onCancel={() => { + // 검사 결과 무효화 (완료 → 대기 풀림) + const targetRowKey = inspectionTarget.rowKey; + setInspectionResults((prev) => { + const next = new Map(prev); + next.delete(targetRowKey); + return next; + }); + cart.updateItemRow(targetRowKey, { inspectionResult: null }); + setTimeout(() => cart.saveToDb().catch(() => {}), 100); + }} itemCode={inspectionTarget.item_code} itemName={inspectionTarget.item_name} totalQty={inspectionTarget.inbound_qty} diff --git a/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx b/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx index bd4ba346..b85254fa 100644 --- a/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx +++ b/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx @@ -26,12 +26,14 @@ export interface InspectionResult { badQty: number; remark: string; completed: boolean; + inspectionNumber?: string; // 검사 완료 시 채번 받음 (재사용) } interface InspectionModalProps { open: boolean; onClose: () => void; onComplete: (result: InspectionResult) => void; + onCancel?: () => void; // 취소 = 검사 무효화 (완료 → 대기) itemCode: string; itemName: string; totalQty: number; @@ -93,6 +95,7 @@ export function InspectionModal({ open, onClose, onComplete, + onCancel, itemCode, itemName, totalQty, @@ -102,19 +105,28 @@ export function InspectionModal({ const [loading, setLoading] = useState(false); const [goodQty, setGoodQty] = useState(0); const [badQty, setBadQty] = useState(0); + /* NumPad state */ + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadTitle, setNumpadTitle] = useState(""); + const [numpadValue, setNumpadValue] = useState(""); + const [numpadMax, setNumpadMax] = useState(undefined); + const numpadCallbackRef = React.useRef<((val: string) => void) | null>(null); + + const openNumpad = (title: string, currentValue: string | number, onConfirm: (v: string) => void, max?: number) => { + setNumpadTitle(title); + setNumpadValue(String(currentValue || "")); + setNumpadMax(max); + numpadCallbackRef.current = onConfirm; + setNumpadOpen(true); + }; const [remark, setRemark] = useState(""); /* Fetch inspection items from DB */ const fetchInspectionItems = useCallback(async () => { setLoading(true); try { - const res = await apiClient.get("/pop/execute-action", { - params: { - taskType: "data-list", - targetTable: "item_inspection_info", - filters: JSON.stringify({ item_code: itemCode }), - pageSize: "50", - }, + const res = await apiClient.get("/pop/inspection-result/info", { + params: { itemCode }, }); const data = res.data?.data; if (Array.isArray(data) && data.length > 0) { @@ -125,7 +137,7 @@ export function InspectionModal({ inspection_standard: String(r.inspection_standard ?? ""), inspection_method: String(r.inspection_method ?? ""), pass_criteria: String(r.pass_criteria ?? ""), - is_required: String(r.is_required ?? ""), + is_required: String(r.is_required ?? "Y"), measured_value: "", result: null, })) @@ -180,34 +192,66 @@ export function InspectionModal({ setGoodQty(totalQty - v); }; + /* 검사 완료 가능 여부: 필수 항목이 모두 pass */ + const canComplete = inspItems + .filter((it) => it.is_required === "Y") + .every((it) => it.result === "pass"); + /* Complete */ - const handleComplete = () => { - onComplete({ - items: inspItems, - goodQty, - badQty, - remark, - completed: true, - }); - onClose(); + const [allocating, setAllocating] = useState(false); + const handleComplete = async () => { + if (!canComplete) return; + setAllocating(true); + try { + // 1. 기존 inspectionNumber 있으면 재사용, 없으면 채번 호출 + let inspectionNumber = initialResult?.inspectionNumber; + if (!inspectionNumber) { + try { + const res = await apiClient.post("/pop/inspection-result/allocate-number"); + inspectionNumber = res.data?.data?.inspectionNumber; + } catch (err) { + console.error("[검사번호 채번 실패]", err); + } + } + // 2. 결과 전달 (채번 포함) + onComplete({ + items: inspItems, + goodQty, + badQty, + remark, + completed: true, + inspectionNumber, + }); + onClose(); + } finally { + setAllocating(false); + } }; if (!open) return null; return ( -
- {/* Full-screen slide panel */} -
+
+ {/* Overlay */} +
+ + {/* Bottom sheet */}
e.stopPropagation()} > + {/* Handle bar */} +
+
+
+ {/* Header */} -
+

자주검사

EA
- handleBadQtyChange(parseInt(e.target.value, 10) || 0)} - className="flex-1 min-w-0 h-10 px-2 text-center text-sm font-semibold border-2 border-red-400 rounded-lg bg-red-50 text-red-700 outline-none focus:ring-2 focus:ring-red-200" - /> + EA
@@ -401,10 +455,16 @@ export function InspectionModal({ {/* Footer */}
- {/* Global keyframe (only mounts once) */} - +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +function DetailField({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function KpiCell({ icon, value, label, color }: { icon: string; value: string; label: string; color: string }) { + return ( +
+ {icon} + + {value} + + {label} +
+ ); +} diff --git a/frontend/components/pop/hardcoded/inventory/InventoryHome.tsx b/frontend/components/pop/hardcoded/inventory/InventoryHome.tsx new file mode 100644 index 00000000..07907d7f --- /dev/null +++ b/frontend/components/pop/hardcoded/inventory/InventoryHome.tsx @@ -0,0 +1,285 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface RecentItem { + id: string; + time: string; + direction: "입고" | "출고"; + type: string; + itemName: string; + qty: string; + partnerName: string; + statusColor: string; + statusLabel: string; +} + +interface KpiData { + todayInbound: number; + todayOutbound: number; + todayTotal: number; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function getStatusStyle(status: string | null): { color: string; label: string } { + switch (status) { + case "완료": + case "입고완료": + case "출고완료": + return { color: "text-green-600 bg-green-50", label: "완료" }; + case "대기": + return { color: "text-amber-600 bg-amber-50", label: "대기" }; + case "진행중": + return { color: "text-blue-600 bg-blue-50", label: "진행중" }; + default: + return { color: "text-gray-600 bg-gray-50", label: status || "대기" }; + } +} + +/* ------------------------------------------------------------------ */ +/* Menu Items */ +/* ------------------------------------------------------------------ */ + +const MENU_ITEMS = [ + { + id: "history", + title: "입출고관리", + gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)", + shadowColor: "rgba(59,130,246,.3)", + icon: ( + + + + ), + href: "/pop/inventory/history", + }, + { + id: "adjust", + title: "재고조정", + gradient: "linear-gradient(135deg,#f59e0b,#d97706)", + shadowColor: "rgba(245,158,11,.3)", + icon: ( + + + + ), + href: "#", + }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InventoryHome() { + const router = useRouter(); + + const [kpi, setKpi] = useState({ todayInbound: 0, todayOutbound: 0, todayTotal: 0 }); + const [recentItems, setRecentItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const today = new Date().toISOString().slice(0, 10); + + const [inRes, outRes] = await Promise.all([ + apiClient.get("/receiving/list", { params: { date_from: today, date_to: today } }), + apiClient.get("/outbound/list", { params: { date_from: today, date_to: today } }), + ]); + + const inRows: any[] = inRes.data?.data ?? []; + const outRows: any[] = outRes.data?.data ?? []; + + setKpi({ + todayInbound: inRows.length, + todayOutbound: outRows.length, + todayTotal: inRows.length + outRows.length, + }); + + const combined: RecentItem[] = [ + ...inRows.map((r: any, idx: number) => { + const st = getStatusStyle(r.inbound_status); + return { + id: `in-${r.detail_id || r.id}-${idx}`, + time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--", + direction: "입고" as const, + type: r.inbound_type || "입고", + itemName: r.item_name || r.item_number || "-", + qty: `${Number(r.inbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`, + partnerName: r.supplier_name || "-", + statusColor: st.color, + statusLabel: st.label, + }; + }), + ...outRows.map((r: any, idx: number) => { + const st = getStatusStyle(r.outbound_status); + return { + id: `out-${r.id}-${idx}`, + time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--", + direction: "출고" as const, + type: r.outbound_type || "출고", + itemName: r.item_name || r.item_code || "-", + qty: `${Number(r.outbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`, + partnerName: r.customer_name || "-", + statusColor: st.color, + statusLabel: st.label, + }; + }), + ] + .sort((a, b) => b.time.localeCompare(a.time)) + .slice(0, 5); + + setRecentItems(combined); + } catch { + // keep empty + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const handleMenuClick = (item: (typeof MENU_ITEMS)[number]) => { + if (item.href === "#") { + alert(`${item.title} 화면은 준비 중입니다.`); + } else { + router.push(item.href); + } + }; + + return ( +
+ {/* Back + Title */} +
+ +
+

재고

+

입출고 현황 및 재고 관리

+
+
+ + {/* KPI */} +
+
+ + + +
+
+ + {/* Menu Icons */} +
+
+
+

재고 관리

+
+
+ {MENU_ITEMS.map((item) => ( +
handleMenuClick(item)} + > +
+ {item.icon} +
+ + {item.title} + +
+ ))} +
+
+ + {/* Recent Activity */} +
+
+
+

최근 입출고

+ 최근 5건 +
+
+ {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : recentItems.length === 0 ? ( +
금일 입출고 내역이 없습니다
+ ) : ( + recentItems.map((item) => ( +
+ + {item.time} + +
+
+ + {item.direction} + + {item.itemName} + + {item.statusLabel} + +
+
+ {item.type} | {item.partnerName} | {item.qty} +
+
+
+ )) + )} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +function KpiCell({ value, label, color }: { value: string; label: string; color: string }) { + return ( +
+ + {value} + + {label} +
+ ); +} diff --git a/frontend/components/pop/hardcoded/inventory/index.ts b/frontend/components/pop/hardcoded/inventory/index.ts new file mode 100644 index 00000000..6ebfce58 --- /dev/null +++ b/frontend/components/pop/hardcoded/inventory/index.ts @@ -0,0 +1,2 @@ +export { InventoryHome } from "./InventoryHome"; +export { InOutHistory } from "./InOutHistory"; diff --git a/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx b/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx index 32bcc462..ee38c6f9 100644 --- a/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx +++ b/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx @@ -381,8 +381,10 @@ export function OutboundCartPage() { ); } } catch (err: unknown) { - const msg = - err instanceof Error ? err.message : "출고 등록에 실패했습니다."; + // axios 에러 우선: 백엔드 message가 있으면 그것을 표시 (재고 부족 등) + const e = err as { response?: { data?: { message?: string } }; message?: string }; + const backendMsg = e?.response?.data?.message; + const msg = backendMsg || e?.message || "출고 등록에 실패했습니다."; setResultMsg(`오류: ${msg}`); } finally { setConfirming(false); diff --git a/frontend/components/pop/hardcoded/production/ProcessWork.tsx b/frontend/components/pop/hardcoded/production/ProcessWork.tsx index 7bfe1c10..237ccd85 100644 --- a/frontend/components/pop/hardcoded/production/ProcessWork.tsx +++ b/frontend/components/pop/hardcoded/production/ProcessWork.tsx @@ -7,6 +7,7 @@ import { usePopSettings } from "@/hooks/pop/usePopSettings"; import { dataApi } from "@/lib/api/data"; import { ProcessTimer, type TimerStatus } from "./ProcessTimer"; import { DefectTypeModal, type DefectEntry, type DefectType } from "./DefectTypeModal"; +import { ConfirmModal } from "../common/ConfirmModal"; /* ================================================================== */ /* Types */ @@ -337,6 +338,31 @@ export function ProcessWork({ processId }: ProcessWorkProps) { /* ---- 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); @@ -799,25 +825,30 @@ export function ProcessWork({ processId }: ProcessWorkProps) { }; /* ---- Confirm Result ---- */ - const handleConfirmResult = async () => { - if (!confirm("실적을 확정하시겠습니까?\n확정 후에는 추가 등록이 불가합니다.")) return; - 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); - } + 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" } + ); }; /* ================================================================ */ @@ -837,39 +868,44 @@ export function ProcessWork({ processId }: ProcessWorkProps) { } }, []); - const handleInbound = async () => { + const handleInbound = () => { if (!selectedWarehouse) { alert("창고를 선택해주세요."); return; } - if (!confirm("생산입고를 진행하시겠습니까?")) return; - 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); - } + 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" } + ); }; /* ================================================================ */ @@ -1460,20 +1496,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { {saving ? "저장중..." : (totalProduced + productionQty >= inputQty && inputQty > 0) ? "작업 완료" : "분할 완료"} - {totalProduced > 0 && ( - - )} + {/* 공정 확정 버튼 제거 (CEO 결정 2026-04-09): 작업 완료/분할 완료로 충분 */}
{/* Batch History */} @@ -1722,35 +1745,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { {isConfirmed ? "실적 확정 완료" : "공정 완료"}
- ) : ( - <> - - {totalProduced > 0 && ( - - )} - - )} + ) : null}
{/* ============================================================ */} @@ -1774,6 +1769,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) { initialEntries={defectEntries} processList={processList} /> + + setConfirmModalState((s) => ({ ...s, open: false }))} + />
); } @@ -2165,17 +2170,22 @@ function MaterialInputSection({ processId }: { processId: string }) { }>>([]); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); + const [defaultWarehouseCode, setDefaultWarehouseCode] = React.useState(""); React.useEffect(() => { const fetchData = async () => { setLoading(true); try { - const [bomRes, inputRes] = await Promise.all([ + const [bomRes, inputRes, whRes] = await Promise.all([ apiClient.get(`/pop/production/bom-materials/${processId}`), apiClient.get(`/pop/production/material-inputs/${processId}`), + apiClient.get("/pop/production/warehouses"), ]); setBomMaterials(bomRes.data?.data?.materials || []); setInputted(inputRes.data?.data || []); + // 첫 번째 창고를 기본 자재 출고 창고로 사용 (재고 차감용) + const wh = whRes.data?.data?.[0]; + if (wh?.warehouse_code) setDefaultWarehouseCode(wh.warehouse_code); } catch { /* non-critical */ } setLoading(false); }; @@ -2196,6 +2206,9 @@ function MaterialInputSection({ processId }: { processId: string }) { unit: m.unit, bom_detail_id: m.id, required_qty: m.required_qty, + // 재고 차감을 위한 창고 코드 (기본 창고) + warehouse_code: defaultWarehouseCode || undefined, + location_code: defaultWarehouseCode || undefined, })); if (inputs.length === 0) { diff --git a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx index 92d8968e..550eef16 100644 --- a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx +++ b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx @@ -8,6 +8,7 @@ import { dataApi } from "@/lib/api/data"; import { AcceptProcessModal } from "./AcceptProcessModal"; import { ProcessDetailModal, ProcessStep } from "./ProcessDetailModal"; import { ProcessWork } from "./ProcessWork"; +import { ConfirmModal } from "../common/ConfirmModal"; /* 텍스트가 넘칠 때 자동 슬라이드 (마키) */ function AutoScrollText({ children, className }: { children: React.ReactNode; className?: string }) { @@ -774,6 +775,11 @@ export function WorkOrderList() { }>({ open: false, processId: "", processName: "", seqNo: "", maxQty: 0 }); const [acceptLoading, setAcceptLoading] = useState(false); + const [cancelConfirm, setCancelConfirm] = useState<{ + open: boolean; + processId: string; + }>({ open: false, processId: "" }); + /* Process Detail Modal */ const [detailModal, setDetailModal] = useState<{ open: boolean; @@ -1526,22 +1532,9 @@ export function WorkOrderList() { )} {proc.status === "in_progress" && parseInt(proc.total_production_qty || "0", 10) === 0 && proc.parent_process_id && (
); } diff --git a/frontend/components/pop/hardcoded/quality/InspectionList.tsx b/frontend/components/pop/hardcoded/quality/InspectionList.tsx new file mode 100644 index 00000000..63efa1c2 --- /dev/null +++ b/frontend/components/pop/hardcoded/quality/InspectionList.tsx @@ -0,0 +1,542 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; +import { DateRangePicker } from "../inventory/DateRangePicker"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface InspectionRow { + id: string; + inspectionNumber: string; + itemCode: string; + itemName: string; + inspectionType: string; + totalQty: number; + goodQty: number; + badQty: number; + passRate: number; + overallJudgment: string; + defectDescription: string; + referenceTable: string; + referenceId: string; + memo: string; + inspector: string; + supplierCode: string; + supplierName: string; + isCompleted: string; + completedDate: string; + createdDate: string; + time: string; + date: string; + fullDate: string; +} + +interface DetailRow { + inspectionItemName: string; + inspectionStandard: string; + passCriteria: string; + measuredValue: string; + judgment: string; +} + +interface KpiData { + total: number; + pass: number; + fail: number; + waiting: number; + passRate: number; +} + +type TabKey = "all" | "incoming" | "process" | "outgoing"; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function getJudgmentStyle(judgment: string): { color: string; label: string } { + if (judgment === "합격" || judgment === "pass") return { color: "text-green-600 bg-green-50", label: "합격" }; + if (judgment === "불합격" || judgment === "fail") return { color: "text-red-600 bg-red-50", label: "불합격" }; + return { color: "text-amber-600 bg-amber-50", label: "대기" }; +} + +function classifyTab(inspectionType: string): TabKey { + if (inspectionType?.includes("입고")) return "incoming"; + if (inspectionType?.includes("공정") || inspectionType?.includes("생산")) return "process"; + if (inspectionType?.includes("출하") || inspectionType?.includes("출고")) return "outgoing"; + return "all"; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InspectionList() { + const router = useRouter(); + + const [dateFrom, setDateFrom] = useState(() => new Date().toISOString().slice(0, 10)); + const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10)); + const [keyword, setKeyword] = useState(""); + const [judgmentFilter, setJudgmentFilter] = useState("전체"); + + const [items, setItems] = useState([]); + const [kpi, setKpi] = useState({ total: 0, pass: 0, fail: 0, waiting: 0, passRate: 0 }); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState("all"); + const [selectedItem, setSelectedItem] = useState(null); + const [selectedDetails, setSelectedDetails] = useState([]); + + /* Fetch data — 마스터 (inspection_result_mng) */ + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.post("/table-management/tables/inspection_result_mng/data", { + page: 1, + pageSize: 500, + }); + + const rows: any[] = res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? []; + + const filtered = rows.filter((r: any) => { + const d = (r.inspection_date || r.created_date || "").slice(0, 10); + if (!d) return true; + if (dateFrom && d < dateFrom) return false; + if (dateTo && d > dateTo) return false; + return true; + }); + + const mapped: InspectionRow[] = filtered.map((r: any, idx: number) => { + const overall = r.overall_judgment || ""; + const totalQ = Number(r.total_qty || 0); + const goodQ = Number(r.good_qty || 0); + const passRate = totalQ > 0 ? Math.round((goodQ / totalQ) * 100) : 0; + return { + id: `${r.id || idx}`, + inspectionNumber: r.inspection_number || "", + itemCode: r.item_code || "", + itemName: r.item_name || "-", + inspectionType: r.inspection_type || "", + totalQty: totalQ, + goodQty: goodQ, + badQty: Number(r.bad_qty || 0), + passRate, + overallJudgment: overall, + defectDescription: r.defect_description || "", + referenceTable: r.reference_table || "", + referenceId: r.reference_id || "", + memo: r.memo || "", + inspector: r.inspector || r.writer || "", + supplierCode: r.supplier_code || "", + supplierName: r.supplier_name || "", + isCompleted: r.is_completed || "N", + completedDate: r.completed_date || "", + createdDate: r.created_date || "", + time: r.inspection_date ? new Date(r.inspection_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--", + date: (r.inspection_date || r.created_date || "").slice(0, 10), + fullDate: r.inspection_date ? new Date(r.inspection_date).toLocaleString("ko-KR") : "-", + }; + }); + + setItems(mapped); + + const total = mapped.length; + const pass = mapped.filter((m) => m.overallJudgment === "합격").length; + const fail = mapped.filter((m) => m.overallJudgment === "불합격").length; + const waiting = total - pass - fail; + const passRate = total > 0 ? Math.round((pass / total) * 100) : 0; + + setKpi({ total, pass, fail, waiting, passRate }); + } catch { + setItems([]); + setKpi({ total: 0, pass: 0, fail: 0, waiting: 0, passRate: 0 }); + } finally { + setLoading(false); + } + }, [dateFrom, dateTo]); + + /* Fetch detail when selected */ + useEffect(() => { + if (!selectedItem) { + setSelectedDetails([]); + return; + } + apiClient.post("/table-management/tables/inspection_result/data", { + page: 1, + pageSize: 100, + filters: { master_id: selectedItem.id }, + }).then((res) => { + const rows: any[] = res.data?.data?.data ?? res.data?.data ?? []; + const details: DetailRow[] = rows + .filter((r: any) => r.master_id === selectedItem.id) + .map((r: any) => ({ + inspectionItemName: r.inspection_item_name || "-", + inspectionStandard: r.inspection_standard || r.pass_criteria || "-", + passCriteria: r.pass_criteria || "-", + measuredValue: r.measured_value || "-", + judgment: r.judgment || "", + })); + setSelectedDetails(details); + }).catch(() => setSelectedDetails([])); + }, [selectedItem]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + /* Filter */ + const filtered = items.filter((item) => { + if (activeTab !== "all") { + const tab = classifyTab(item.inspectionType); + if (tab !== activeTab) return false; + } + if (keyword) { + const kw = keyword.toLowerCase(); + if (!item.itemName.toLowerCase().includes(kw) && !item.itemCode.toLowerCase().includes(kw)) return false; + } + if (judgmentFilter !== "전체") { + const j = item.overallJudgment; + if (judgmentFilter === "합격" && !(j === "합격" || j === "pass")) return false; + if (judgmentFilter === "불합격" && !(j === "불합격" || j === "fail")) return false; + if (judgmentFilter === "대기" && (j === "합격" || j === "pass" || j === "불합격" || j === "fail")) return false; + } + return true; + }); + + // 탭별 카운트 + const counts = { + all: items.length, + incoming: items.filter((i) => classifyTab(i.inspectionType) === "incoming").length, + process: items.filter((i) => classifyTab(i.inspectionType) === "process").length, + outgoing: items.filter((i) => classifyTab(i.inspectionType) === "outgoing").length, + }; + + const TABS: { key: TabKey; label: string; count: number }[] = [ + { key: "all", label: "전체", count: counts.all }, + { key: "incoming", label: "입고검사", count: counts.incoming }, + { key: "process", label: "공정검사", count: counts.process }, + { key: "outgoing", label: "출하검사", count: counts.outgoing }, + ]; + + return ( +
+ {/* Back + Title */} +
+ +
+

검사관리

+

검사 결과 내역을 조회합니다

+
+
+ + {/* Filters */} +
+
+
+ { setDateFrom(f); setDateTo(t); }} /> +
+ + setKeyword(e.target.value)} + placeholder="품목명 또는 검사번호" + className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400" + /> +
+
+ + +
+
+
+ + +
+
+
+ + {/* KPI */} +
+
+ + + + + +
+
+ + {/* Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* List */} +
+
+ 검사 내역 + 총 {filtered.length}건 +
+ + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : filtered.length === 0 ? ( +
+ + + +

검사 내역이 없습니다

+

기간을 변경하거나 입고/생산 시 검사를 진행해주세요

+
+ ) : ( + filtered.map((item) => { + const js = getJudgmentStyle(item.overallJudgment); + return ( +
setSelectedItem(item)} + className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]" + > +
+
+ 🔍 +
+
+
+ {item.inspectionNumber} + + {js.label} + +
+
+ {item.itemName} + {item.itemCode ? ` (${item.itemCode})` : ""} +
+
+ {item.inspectionType} + {item.supplierName ? ` · ${item.supplierName}` : ""} +
+
+
+

+ {item.goodQty} + / + {item.badQty} +

+

{item.passRate}%

+

{item.time}

+
+
+
+ ); + }) + )} +
+ + {/* Detail Bottom Sheet */} + {selectedItem && ( +
setSelectedItem(null)}> +
+
e.stopPropagation()} + > +
+
+
+
+

{selectedItem.inspectionType} 상세 — {selectedItem.inspectionNumber}

+ +
+
+
+ +
+

검사유형

+ + {selectedItem.inspectionType} + +
+
+
+ +
+

판정

+ + {getJudgmentStyle(selectedItem.overallJudgment).label} + +
+
+
+
+

품목

+

+ {selectedItem.itemName} + {selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""} +

+
+
+ +
+

합격률

+

{selectedItem.passRate}%

+
+
+
+
+

검사수량

+

{selectedItem.totalQty.toLocaleString()}

+
+
+

합격

+

{selectedItem.goodQty.toLocaleString()}

+
+
+

불합격

+

{selectedItem.badQty.toLocaleString()}

+
+
+ {selectedItem.defectDescription && ( + + )} + + {selectedItem.memo && ( + + )} + + {/* 검사 항목별 결과 (디테일) */} + {selectedDetails.length > 0 && ( +
+

검사 항목별 결과

+
+ {selectedDetails.map((d, idx) => { + const dj = getJudgmentStyle(d.judgment); + return ( +
+
+ {d.inspectionItemName} + + {dj.label} + +
+
+
+ 기준 +

{d.inspectionStandard}

+
+
+ 측정값 +

{d.measuredValue}

+
+
+
+ ); + })} +
+
+ )} +
+
+ +
+
+
+ )} + +
+ ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function KpiCell({ icon, value, label, color }: { icon: string; value: string; label: string; color: string }) { + return ( +
+ {icon} + + {value} + + {label} +
+ ); +} diff --git a/frontend/components/pop/hardcoded/quality/QualityHome.tsx b/frontend/components/pop/hardcoded/quality/QualityHome.tsx new file mode 100644 index 00000000..54f94a23 --- /dev/null +++ b/frontend/components/pop/hardcoded/quality/QualityHome.tsx @@ -0,0 +1,219 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface RecentItem { + id: string; + itemName: string; + itemCode: string; + inspectionType: string; + judgment: string; + judgmentColor: string; + judgmentLabel: string; + time: string; +} + +interface KpiData { + todayTotal: number; + todayPass: number; + todayFail: number; + passRate: number; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function getJudgmentStyle(j: string): { color: string; label: string } { + if (j === "합격" || j === "pass") return { color: "text-green-600 bg-green-50", label: "합격" }; + if (j === "불합격" || j === "fail") return { color: "text-red-600 bg-red-50", label: "불합격" }; + return { color: "text-amber-600 bg-amber-50", label: "대기" }; +} + +const MENU_ITEMS = [ + { + id: "inspection", + title: "검사관리", + gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)", + shadowColor: "rgba(139,92,246,.3)", + icon: ( + + + + ), + href: "/pop/quality/inspection", + }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function QualityHome() { + const router = useRouter(); + + const [kpi, setKpi] = useState({ todayTotal: 0, todayPass: 0, todayFail: 0, passRate: 0 }); + const [recentItems, setRecentItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const today = new Date().toISOString().slice(0, 10); + + const res = await apiClient.post("/table-management/tables/inspection_result_mng/data", { + page: 1, + pageSize: 500, + }); + + const rows: any[] = res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? []; + const todayRows = rows.filter((r: any) => (r.created_date || "").slice(0, 10) === today); + + const total = todayRows.length; + const pass = todayRows.filter((r: any) => r.overall_judgment === "합격" || r.overall_judgment === "pass").length; + const fail = todayRows.filter((r: any) => r.overall_judgment === "불합격" || r.overall_judgment === "fail").length; + const passRate = total > 0 ? Math.round((pass / total) * 100) : 0; + + setKpi({ todayTotal: total, todayPass: pass, todayFail: fail, passRate }); + + // 최근 5건 + const sorted = [...rows].sort((a: any, b: any) => (b.created_date || "").localeCompare(a.created_date || "")); + const top5 = sorted.slice(0, 5).map((r: any, idx: number) => { + const js = getJudgmentStyle(r.overall_judgment || r.judgment || ""); + return { + id: `${r.id || idx}`, + itemName: r.item_name || "-", + itemCode: r.item_code || "", + inspectionType: r.inspection_type || "", + judgment: r.overall_judgment || "", + judgmentColor: js.color, + judgmentLabel: js.label, + time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--", + }; + }); + setRecentItems(top5); + } catch { + // empty + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + return ( +
+ {/* Back + Title */} +
+ +
+

품질

+

검사 현황 및 품질 관리

+
+
+ + {/* KPI */} +
+
+ + + + +
+
+ + {/* Menu Icons */} +
+
+
+

품질 관리

+
+
+ {MENU_ITEMS.map((item) => ( +
router.push(item.href)} + > +
+ {item.icon} +
+ + {item.title} + +
+ ))} +
+
+ + {/* Recent Activity */} +
+
+
+

최근 검사

+ 최근 5건 +
+
+ {loading ? ( +
로딩 중...
+ ) : recentItems.length === 0 ? ( +
최근 검사 내역이 없습니다
+ ) : ( + recentItems.map((item) => ( +
+ + {item.time} + +
+
+ + {item.itemName} + {item.itemCode ? ` (${item.itemCode})` : ""} + + + {item.judgmentLabel} + +
+
{item.inspectionType}
+
+
+ )) + )} +
+
+
+
+ ); +} + +function KpiCell({ value, label, color }: { value: string; label: string; color: string }) { + return ( +
+ + {value} + + {label} +
+ ); +} diff --git a/frontend/components/pop/hardcoded/quality/index.ts b/frontend/components/pop/hardcoded/quality/index.ts new file mode 100644 index 00000000..eb98534c --- /dev/null +++ b/frontend/components/pop/hardcoded/quality/index.ts @@ -0,0 +1,2 @@ +export { QualityHome } from "./QualityHome"; +export { InspectionList } from "./InspectionList"; diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts index 759ed18c..651e42f0 100644 --- a/frontend/hooks/pop/useCartSync.ts +++ b/frontend/hooks/pop/useCartSync.ts @@ -51,6 +51,7 @@ export interface UseCartSyncReturn { addItem: (item: CartItem, rowKey: string) => void; removeItem: (rowKey: string) => void; updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void; + updateItemRow: (rowKey: string, partialRow: Record) => void; isItemInCart: (rowKey: string) => boolean; getCartItem: (rowKey: string) => CartItemWithId | undefined; @@ -137,7 +138,7 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean { const serialize = (items: CartItemWithId[]) => items - .map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`) + .map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}:${JSON.stringify(item.row)}`) .sort() .join("|"); @@ -249,6 +250,20 @@ export function useCartSync( [], ); + // row 객체에 임의 필드를 부분 업데이트 (예: inspectionResult) + const updateItemRow = useCallback( + (rowKey: string, partialRow: Record) => { + setCartItems((prev) => + prev.map((i) => + i.rowKey === rowKey + ? { ...i, row: { ...i.row, ...partialRow } } + : i, + ), + ); + }, + [], + ); + const isItemInCart = useCallback( (rowKey: string) => cartItems.some((i) => i.rowKey === rowKey), [cartItems], @@ -272,7 +287,9 @@ export function useCartSync( if (!c.cartId) return false; const saved = savedMap.get(c.rowKey); if (!saved) return false; - return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status; + // row JSON 비교 (검사 결과 등 포함) + const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row); + return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status || rowChanged; }); return { @@ -301,10 +318,12 @@ export function useCartSync( if (!c.cartId) return false; const saved = savedMap.get(c.rowKey); if (!saved) return false; + const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row); return ( c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || - c.status !== saved.status + c.status !== saved.status || + rowChanged ); }); @@ -354,6 +373,7 @@ export function useCartSync( addItem, removeItem, updateItemQuantity, + updateItemRow, isItemInCart, getCartItem, getChanges,