"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 "../inbound/InspectionModal"; import { NumberPadModal, type PackageEntry } from "../inbound/NumberPadModal"; import { useCartSync, type CartItemWithId } from "../common/useCartSync"; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface Warehouse { warehouse_code: string; warehouse_name: string; warehouse_type?: string; } /** CartItemWithId -> display parsed result */ interface CartItemParsed { id: string; rowKey: string; dbId: string; source_table: string; source_id: string; instruction_no: string; item_code: string; item_name: string; spec: string; material: string; plan_qty: number; remain_qty: number; outbound_qty: number; unit_price: number; customer_code: string; customer_name: string; instruction_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 ?? "shipment_instruction_detail"), source_id: item.rowKey || String(data.source_id ?? data.detail_id ?? data.id ?? ""), instruction_no: String(data.instruction_no ?? ""), item_code: String(data.item_code ?? ""), item_name: String(data.item_name ?? ""), spec: String(data.spec ?? ""), material: String(data.material ?? ""), plan_qty: Number(data.plan_qty ?? 0), remain_qty: Number(data.remain_qty ?? 0), outbound_qty: item.quantity, unit_price: Number(data.unit_price ?? 0), customer_code: String(data.customer_code ?? ""), customer_name: String(data.customer_name ?? ""), instruction_date: data.instruction_date ? String(data.instruction_date) : undefined, inspection_type: inspType, inspection_required: inspType === "self", packages: item.packageEntries as unknown as PackageEntry[] | undefined, image: data.image ? String(data.image) : null, }; } /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export function OutboundCartPage() { const router = useRouter(); /* Cart sync hook */ const cart = useCartSync("pop-sales-outbound", "shipment_instruction_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); /* Outbound number */ const [outboundNumber, setOutboundNumber] = useState(""); /* Confirm result modal */ const [confirmResult, setConfirmResult] = useState<{ outboundNumber: string; items: CartItemParsed[]; warehouse: string; date: string; } | null>(null); /* Outbound date */ const [outboundDate, setOutboundDate] = 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: customer name (all items should be same customer) */ const customerName = items.length > 0 ? items[0].customer_name : ""; /* ------------------------------------------------------------------ */ /* Fetch warehouses */ /* ------------------------------------------------------------------ */ const fetchedRef = useRef(false); const fetchWarehouses = useCallback(async () => { try { const res = await apiClient.get("/outbound/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, // eslint-disable-next-line @typescript-eslint/no-explicit-any packages.length > 0 ? packages as any : undefined, ); setNumpadTarget(null); }; /* ------------------------------------------------------------------ */ /* Remove item */ /* ------------------------------------------------------------------ */ const handleRemove = (rowKey: string) => { cart.removeItem(rowKey); setSelectedItems((prev) => { const next = new Set(prev); next.delete(rowKey); return next; }); }; /* 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); }; 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.outbound_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 outbound */ /* ------------------------------------------------------------------ */ const handleConfirm = async () => { if (selectedItemsList.length === 0) return; if (!selectedWarehouse) { setResultMsg("오류: 출고 창고를 선택해주세요."); return; } if (hasUnfinishedRequiredInspection) { setResultMsg("오류: 필수 검사를 완료해주세요."); return; } setConfirming(true); setResultMsg(null); try { // Generate outbound number at confirm time let finalNumber = ""; try { const numRes = await apiClient.get("/outbound/generate-number"); if (numRes.data?.success && numRes.data?.data) { finalNumber = numRes.data.data; setOutboundNumber(finalNumber); } } catch { /* backend will handle */ } // POST /api/outbound -- matches outboundController.create const payload = { outbound_number: finalNumber, outbound_date: outboundDate, warehouse_code: selectedWarehouse, items: selectedItemsList.map((item) => ({ outbound_type: "판매출고", item_code: item.item_code, item_name: item.item_name, specification: item.spec || "", spec: item.spec || "", material: item.material || "", unit: "EA", outbound_qty: item.outbound_qty, unit_price: item.unit_price || 0, total_amount: (item.outbound_qty || 0) * (item.unit_price || 0), reference_number: item.instruction_no, customer_code: item.customer_code, customer_name: item.customer_name, source_type: "shipment_instruction_detail", source_id: item.source_id || item.id, outbound_status: "대기", })), }; const res = await apiClient.post("/outbound", 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 outNo = finalNumber || ""; setConfirmResult({ outboundNumber: outNo, items: confirmedItems, warehouse: warehouses.find(w => w.warehouse_code === selectedWarehouse)?.warehouse_name || selectedWarehouse, date: outboundDate, }); 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.outbound_qty, 0); /* ------------------------------------------------------------------ */ /* Render */ /* ------------------------------------------------------------------ */ return (
{/* ===== Header ===== */}

출고 장바구니

{customerName && (

{customerName}

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

담은 품목이 없습니다

판매출고 화면에서 품목을 담아주세요

) : (
{items.map((item) => { const inspResult = getInspectionResult(item.rowKey); return (
{/* Green 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.instruction_date || "-"}
지시번호 {item.instruction_no || "-"}
지시수량 {item.plan_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") && (
{!item.inspection_required && !inspResult?.completed && ( )}
)}
); })}
)} {/* ===== Footer summary ===== */} {items.length > 0 && (
{resultMsg && (
{resultMsg}
)} {hasUnfinishedRequiredInspection && (
필수 검사를 완료해주세요. 검사 미완료 품목이 있어 확정할 수 없습니다.
)}
선택{" "} {selectedItemsList.length} /{items.length}건 합계 수량:{" "} {totalQty.toLocaleString()} {" "} EA
)} {/* ===== Warehouse picker modal ===== */} {warehousePickerOpen && (
setWarehousePickerOpen(false)} />

창고 선택

{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.outbound_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.outbound_qty} initialPackages={numpadTarget.packages} /> )} {/* ===== 출고 완료 결과 모달 ===== */} {confirmResult && (
{/* Header */}

출고 처리 완료

{confirmResult.outboundNumber}

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

{item.item_name}

{item.item_code}

{item.outbound_qty?.toLocaleString()} EA
))}
{/* OK button */}
)}
); }