"use client"; import { useRouter } from "next/navigation"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { apiClient } from "@/lib/api/client"; import { type CartItemWithId, useCartSync } from "../common/useCartSync"; import { InspectionModal, type InspectionResult } from "./InspectionModal"; import { NumberPadModal, type PackageEntry } from "./NumberPadModal"; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface Warehouse { warehouse_code: string; warehouse_name: string; warehouse_type?: string; } /** CartItemWithId -> 화면 표시용 파싱 결과 */ interface CartItemParsed { id: string; rowKey: string; dbId: string; source_table: string; source_id: string; purchase_no: string; item_code: string; item_name: string; spec: string; material: string; order_qty: number; remain_qty: number; inbound_qty: number; unit_price: number; supplier_code: string; supplier_name: string; order_date?: string; inspection_required?: boolean; inspection_type?: "self" | "request" | null; packages?: PackageEntry[]; image?: string | null; } /* ------------------------------------------------------------------ */ /* Helper: CartItemWithId -> CartItemParsed */ /* ------------------------------------------------------------------ */ function toCartItemParsed(item: CartItemWithId): CartItemParsed { const data = item.row; const inspType = data.inspection_type === "self" ? "self" : data.inspection_type === "request" ? "request" : null; return { id: item.rowKey || String(data.id ?? ""), rowKey: item.rowKey, dbId: item.cartId || "", source_table: item.sourceTable || String(data.source_table ?? "purchase_detail"), source_id: item.rowKey || String(data.id ?? ""), purchase_no: String(data.purchase_no ?? ""), item_code: String(data.item_code ?? ""), item_name: String(data.item_name ?? ""), spec: String(data.spec ?? ""), material: String(data.material ?? ""), order_qty: Number(data.order_qty ?? 0), remain_qty: Number(data.remain_qty ?? 0), inbound_qty: item.quantity, unit_price: Number(data.unit_price ?? 0), supplier_code: String(data.supplier_code ?? ""), supplier_name: String(data.supplier_name ?? ""), order_date: data.order_date ? String(data.order_date) : undefined, inspection_type: inspType, inspection_required: inspType === "self", // packageEntries의 실제 런타임 타입은 NumberPadModal의 PackageEntry[] packages: item.packageEntries as unknown as PackageEntry[] | undefined, image: data.image ? String(data.image) : null, }; } /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export function InboundCartPage() { const router = useRouter(); /* Cart sync hook */ const cart = useCartSync("pop-purchase-inbound", "purchase_detail"); /* Derived: parsed items from cart */ const items = useMemo( () => cart.cartItems.map(toCartItemParsed), [cart.cartItems], ); /* Inspection results (local overlay, keyed by rowKey) */ const [inspectionResults, setInspectionResults] = useState< Map >(new Map()); /* Selection */ const [selectedItems, setSelectedItems] = useState>(new Set()); /* Auto-select all when items change */ useEffect(() => { if (items.length > 0) { setSelectedItems(new Set(items.map((i) => i.id))); } }, [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(""); const [warehousePickerOpen, setWarehousePickerOpen] = useState(false); /* Inbound number */ const [inboundNumber, setInboundNumber] = useState(""); /* Confirm result modal */ const [confirmResult, setConfirmResult] = useState<{ inboundNumber: string; items: CartItemParsed[]; warehouse: string; date: string; } | null>(null); /* Inbound date */ const [inboundDate, setInboundDate] = useState( new Date().toISOString().slice(0, 10), ); /* Confirm state */ const [confirming, setConfirming] = useState(false); const [resultMsg, setResultMsg] = useState(null); /* Inspection modal */ const [inspectionModalOpen, setInspectionModalOpen] = useState(false); const [inspectionTarget, setInspectionTarget] = useState(null); /* Numpad modal (for qty edit) */ const [numpadOpen, setNumpadOpen] = useState(false); const [numpadTarget, setNumpadTarget] = useState(null); /* Derived: supplier name (all items should be same supplier) */ const supplierName = items.length > 0 ? items[0].supplier_name : ""; /* ------------------------------------------------------------------ */ /* Fetch warehouses */ /* ------------------------------------------------------------------ */ const fetchedRef = useRef(false); const fetchWarehouses = useCallback(async () => { try { const res = await apiClient.get("/receiving/warehouses"); const data: Warehouse[] = res.data?.data ?? []; setWarehouses(data); if (data.length > 0) { setSelectedWarehouse(data[0].warehouse_code); } } catch { /* keep empty */ } }, []); useEffect(() => { if (fetchedRef.current) return; fetchedRef.current = true; fetchWarehouses(); }, [fetchWarehouses]); /* ------------------------------------------------------------------ */ /* Selection */ /* ------------------------------------------------------------------ */ const toggleSelect = (id: string) => { setSelectedItems((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const toggleSelectAll = () => { if (selectedItems.size === items.length) { setSelectedItems(new Set()); } else { setSelectedItems(new Set(items.map((i) => i.id))); } }; /* ------------------------------------------------------------------ */ /* Qty edit via numpad */ /* ------------------------------------------------------------------ */ const openNumpad = (item: CartItemParsed) => { setNumpadTarget(item); setNumpadOpen(true); }; const handleNumpadConfirm = (qty: number, packages: PackageEntry[]) => { if (!numpadTarget) return; const finalQty = Math.min(qty, numpadTarget.remain_qty); cart.updateItemQuantity( numpadTarget.rowKey, finalQty, undefined, // PackageEntry 타입이 registry vs NumberPadModal에서 다르므로 any 캐스팅 // eslint-disable-next-line @typescript-eslint/no-explicit-any packages.length > 0 ? (packages as any) : undefined, ); setNumpadTarget(null); // Auto-save effect below will persist change to DB }; /* ------------------------------------------------------------------ */ /* Remove item */ /* ------------------------------------------------------------------ */ const handleRemove = (rowKey: string) => { cart.removeItem(rowKey); setSelectedItems((prev) => { const next = new Set(prev); next.delete(rowKey); return next; }); // Auto-save effect below will persist change to DB }; /* Auto-save: persist dirty changes to DB after a short debounce */ const autoSaveTimerRef = useRef | null>(null); useEffect(() => { if (!cart.isDirty || cart.syncStatus === "saving") return; if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = setTimeout(() => { cart.saveToDb().catch(() => {}); }, 500); return () => { if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); }; }, [cart.isDirty, cart.syncStatus, cart]); /* ------------------------------------------------------------------ */ /* Inspection */ /* ------------------------------------------------------------------ */ const openInspection = (item: CartItemParsed) => { setInspectionTarget(item); setInspectionModalOpen(true); }; const handleInspectionComplete = (result: InspectionResult) => { if (!inspectionTarget) return; const targetRowKey = inspectionTarget.rowKey; setInspectionResults((prev) => { const next = new Map(prev); 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, result); return next; }); cart.updateItemRow(rowKey, { inspectionResult: result }); }; const getInspectionResult = (rowKey: string): InspectionResult | null => { return inspectionResults.get(rowKey) || null; }; /* ------------------------------------------------------------------ */ /* Validation: required inspections */ /* ------------------------------------------------------------------ */ const selectedItemsList = items.filter((i) => selectedItems.has(i.id)); // CEO 정책 (2026-04-09 시연 결정): 검사 필수 항목 미완료 시 확정 차단 // 검사 빠진 입고가 검사관리에서 추적 안 되므로, 입력 시점에 막음 const hasUnfinishedRequiredInspection = selectedItemsList.some( (item) => item.inspection_required && item.inspection_type === "self" && !getInspectionResult(item.rowKey)?.completed, ); /* ------------------------------------------------------------------ */ /* Confirm inbound */ /* ------------------------------------------------------------------ */ const handleConfirm = async () => { if (selectedItemsList.length === 0) return; if (!selectedWarehouse) { setResultMsg("오류: 입고 창고를 선택해주세요."); return; } // 검사 미완료여도 확정 가능. 단지 inspection_result에 안 들어가거나 "대기" 상태로 기록. // (CEO 정책: 입고 자체는 진행, 검사 결과만 누락/대기 상태로 표시) setConfirming(true); setResultMsg(null); try { // 확정 시점에 채번 (동시접속 충돌 방지) // POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본) let finalNumber = ""; try { const settingsRes: any = await apiClient .get("/screen-management/screens/6527/layout-pop") .catch(() => null); const ruleId = settingsRes?.data?.data?.settings?.popConfig?.inbound ?.numberingRuleId; const url = ruleId && ruleId !== "__none__" ? `/receiving/generate-number?ruleId=${encodeURIComponent(ruleId)}` : "/receiving/generate-number"; const numRes = await apiClient.get(url); if (numRes.data?.success && numRes.data?.data) { finalNumber = numRes.data.data; setInboundNumber(finalNumber); } } catch { /* backend will handle */ } // POST /api/receiving -- same payload structure as PC const payload = { inbound_number: finalNumber, inbound_date: inboundDate, warehouse_code: selectedWarehouse, inbound_type: "구매입고", items: selectedItemsList.map((item, idx) => { const inspResult = getInspectionResult(item.rowKey); return { inbound_type: "구매입고", item_number: item.item_code, item_name: item.item_name, spec: item.spec || "", material: item.material || "", unit: "EA", inbound_qty: String(item.inbound_qty), unit_price: String(item.unit_price || 0), total_amount: String( (item.inbound_qty || 0) * (item.unit_price || 0), ), reference_number: item.purchase_no, supplier_code: item.supplier_code, supplier_name: item.supplier_name, inbound_status: "입고완료", inspection_status: inspResult?.completed ? "검사완료" : item.inspection_required ? "검사대기" : "합격", source_table: item.source_table, source_id: item.source_id || item.id, seq_no: idx + 1, }; }), }; 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"); const confirmPromises = confirmedItems .filter((item) => item.dbId) .map((item) => dataApi .updateRecord("cart_items", item.dbId, { status: "confirmed" }) .catch(() => {}), ); await Promise.all(confirmPromises); // Also clean up local state via useCartSync for (const item of confirmedItems) { cart.removeItem(item.rowKey); } // Reload from DB to sync state await cart.loadFromDb(); const inboundNo = res.data?.data?.header?.inbound_number || finalNumber || ""; // 결과 모달 표시 (바로 이동하지 않음) setConfirmResult({ inboundNumber: inboundNo, items: confirmedItems, warehouse: warehouses.find((w) => w.warehouse_code === selectedWarehouse) ?.warehouse_name || selectedWarehouse, date: inboundDate, }); setResultMsg(null); } else { setResultMsg( `오류: ${res.data?.message || "입고 등록에 실패했습니다."}`, ); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : "입고 등록에 실패했습니다."; setResultMsg(`오류: ${msg}`); } finally { setConfirming(false); } }; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const selectedWarehouseName = warehouses.find((w) => w.warehouse_code === selectedWarehouse) ?.warehouse_name || selectedWarehouse; const totalQty = selectedItemsList.reduce((s, i) => s + i.inbound_qty, 0); /* ------------------------------------------------------------------ */ /* Render */ /* ------------------------------------------------------------------ */ return (
{/* ===== Header ===== */}

입고 장바구니

{supplierName && (

{supplierName}

)}
{/* Confirm button (header only) */}
{/* ===== Info banner ===== */}
{supplierName && ( {supplierName} )} {inboundDate} {selectedWarehouseName && ( | {selectedWarehouseName} )} {inboundNumber || "확정 시 자동생성"}
{/* Info fields: 3 columns */}
{/* Inbound date */}
setInboundDate(e.target.value)} className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white" />
{/* Warehouse selector - card-style touch button */}
{/* Inbound number (readonly -- 확정 시점에 채번) */}
{inboundNumber ? ( {inboundNumber} ) : ( 확정 시 자동생성 )}
{/* ===== Select all bar ===== */} {items.length > 0 && (
담은 품목 {items.length}
)} {/* ===== Items list ===== */} {cart.loading ? (
불러오는 중...
) : items.length === 0 ? (

담은 품목이 없습니다

구매입고 화면에서 품목을 담아주세요

) : (
{items.map((item) => { const inspResult = getInspectionResult(item.rowKey); return (
{/* Blue left bar for selected items */} {selectedItems.has(item.id) && (
)} {/* === Header row: checkbox + item code + item name + inspection badge === */}
{/* Checkbox */} {item.item_code} {item.item_name} {item.inspection_type === "self" && ( 검사 필수 )} {item.inspection_type === "request" && ( 검사의뢰 선택 )}
{/* === Body row: image + info + action === */}
{/* Product image */}
{item.image ? ( {item.item_name} ) : ( {"\uD83D\uDCE6"} )}
{/* Info columns */}
발주일 {item.order_date || "-"}
발주번호 {item.purchase_no || "-"}
발주수량 {item.order_qty.toLocaleString()}
미입고 {item.remain_qty.toLocaleString()}
{/* Action column: qty display + delete button */}
{/* Qty display - clickable to open numpad */} {/* Delete button */}
{/* === Package info === */} {item.packages && item.packages.length > 0 && (
포장완료 {item.packages .reduce((s, p) => s + p.count * p.qtyPerUnit, 0) .toLocaleString()}{" "} EA
{item.packages.map((pkg, idx) => (
{pkg.unit.icon} {pkg.count} {pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA
))}
)} {/* === Inspection row === */} {(item.inspection_type === "self" || item.inspection_type === "request") && (
{/* Pass button for non-required */} {!item.inspection_required && !inspResult?.completed && ( )}
)}
); })}
)} {/* ===== Result toast (only when message exists) ===== */} {resultMsg && (
{resultMsg}
)} {/* ===== Warehouse picker modal ===== */} {warehousePickerOpen && (
setWarehousePickerOpen(false)} />
{/* Header */}

창고 선택

{/* Warehouse list */}
{warehouses.length === 0 ? (

등록된 창고가 없습니다

) : (
{warehouses.map((wh) => ( ))}
)}
)} {/* ===== Inspection Modal ===== */} {inspectionTarget && ( { setInspectionModalOpen(false); 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} initialResult={getInspectionResult(inspectionTarget.rowKey)} /> )} {/* ===== NumberPad Modal (qty edit) ===== */} {numpadTarget && ( { setNumpadOpen(false); setNumpadTarget(null); }} onConfirm={handleNumpadConfirm} maxQty={numpadTarget.remain_qty} itemName={numpadTarget.item_name} initialQty={numpadTarget.inbound_qty} initialPackages={numpadTarget.packages} /> )} {/* ===== 입고 완료 결과 모달 ===== */} {confirmResult && (
{/* 헤더 */}

입고 처리 완료

{confirmResult.inboundNumber}

{/* 처리 내역 */}
창고:{" "} {confirmResult.warehouse} {confirmResult.date}
처리된 품목 ({confirmResult.items.length}건)
{confirmResult.items.map((item) => (

{item.item_name}

{item.item_code}

{item.inbound_qty?.toLocaleString()} EA
))}
{/* 확인 버튼 */}
)}
); }