"use client"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useRouter } from "next/navigation"; import { apiClient } from "@/lib/api/client"; import { InspectionModal, type InspectionResult } from "./InspectionModal"; import { NumberPadModal, type PackageEntry } from "./NumberPadModal"; import { useCartSync, type CartItemWithId } from "../common/useCartSync"; /* ------------------------------------------------------------------ */ /* 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]); /* 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; setInspectionResults((prev) => { const next = new Map(prev); next.set(inspectionTarget.rowKey, result); return next; }); setInspectionTarget(null); }; /* Pass inspection (non-required only) */ const handlePassInspection = (rowKey: string) => { const item = items.find((i) => i.rowKey === rowKey); if (!item) return; setInspectionResults((prev) => { const next = new Map(prev); next.set(rowKey, { items: [], goodQty: item.inbound_qty, badQty: 0, remark: "pass", completed: true, }); return next; }); }; const getInspectionResult = (rowKey: string): InspectionResult | null => { return inspectionResults.get(rowKey) || null; }; /* ------------------------------------------------------------------ */ /* Validation: required inspections */ /* ------------------------------------------------------------------ */ const selectedItemsList = items.filter((i) => selectedItems.has(i.id)); 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; } if (hasUnfinishedRequiredInspection) { setResultMsg("오류: 필수 검사를 완료해주세요."); return; } setConfirming(true); setResultMsg(null); try { // 확정 시점에 채번 (동시접속 충돌 방지) let finalNumber = ""; try { const numRes = await apiClient.get("/receiving/generate-number"); 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, 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) { // 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 && ( )}
)}
); })}
)} {/* ===== 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
)} {/* ===== Warehouse picker modal ===== */} {warehousePickerOpen && (
setWarehousePickerOpen(false)} />
{/* Header */}

창고 선택

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

등록된 창고가 없습니다

) : (
{warehouses.map((wh) => ( ))}
)}
)} {/* ===== Inspection Modal ===== */} {inspectionTarget && ( { setInspectionModalOpen(false); setInspectionTarget(null); }} onComplete={handleInspectionComplete} 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
))}
{/* 확인 버튼 */}
)}
); }