Files
vexplor_dev/frontend/components/pop/hardcoded/outbound/SalesOutbound.tsx
SeongHyun Kim 9b7b88ff7c
Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m30s
feat: POP 하드코딩 화면 추가 (PC 코드 무변경 재병합)
- POP 전용 39개 파일 추가 (홈/입고/출고/생산)
- 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일)
- POP 전용 API 7개 추가 (창고/위치/입고/동기화)
- PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
2026-04-02 17:39:42 +09:00

595 lines
28 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { CustomerModal, type Customer, matchChosung } from "./CustomerModal";
import { NumberPadModal, type PackageEntry } from "../inbound/NumberPadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ShipmentInstruction {
id: string;
detail_id: string;
instruction_id: string;
instruction_no: string;
instruction_date: string;
partner_id: string;
customer_code: string;
customer_name: string;
item_code: string;
item_name: string;
spec: string;
material: string;
plan_qty: number;
ship_qty: number;
remain_qty: number;
order_qty: number;
source_type: string;
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
inspection_type: "self" | "request" | null;
/** Item image URL from item_info.image (may be null) */
image: string | null;
}
/* ------------------------------------------------------------------ */
/* Dummy data (fallback) */
/* ------------------------------------------------------------------ */
const DUMMY_INSTRUCTIONS: ShipmentInstruction[] = [
{
id: "dm1", detail_id: "dm1", instruction_id: "ins1",
instruction_no: "SI-2026-0401-001", instruction_date: "2026-03-30",
partner_id: "C001", customer_code: "C001", customer_name: "(주)삼성전자",
item_code: "STS304-2T", item_name: "STS304 2T 판재",
spec: "2T x 1219 x 2438", material: "STS304",
plan_qty: 500, ship_qty: 0, remain_qty: 500, order_qty: 500,
source_type: "shipment_instruction_detail",
inspection_type: null,
image: null,
},
{
id: "dm2", detail_id: "dm2", instruction_id: "ins1",
instruction_no: "SI-2026-0401-001", instruction_date: "2026-03-30",
partner_id: "C001", customer_code: "C001", customer_name: "(주)삼성전자",
item_code: "AL-A100", item_name: "알루미늄 프로파일 A100",
spec: "A100 x 6000L", material: "AL6063",
plan_qty: 200, ship_qty: 50, remain_qty: 150, order_qty: 200,
source_type: "shipment_instruction_detail",
inspection_type: null,
image: null,
},
{
id: "dm3", detail_id: "dm3", instruction_id: "ins2",
instruction_no: "SI-2026-0330-005", instruction_date: "2026-03-28",
partner_id: "C002", customer_code: "C002", customer_name: "(주)LG전자",
item_code: "BOLT-M10", item_name: "볼트 M10x30",
spec: "M10x30 SUS", material: "SUS304",
plan_qty: 1000, ship_qty: 0, remain_qty: 1000, order_qty: 1000,
source_type: "shipment_instruction_detail",
inspection_type: null,
image: null,
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
interface SalesOutboundProps {
/** useCartSync hook instance (created in page.tsx and passed in) */
cart: import("../common/useCartSync").UseCartSyncReturn;
}
export function SalesOutbound({ cart }: SalesOutboundProps) {
const router = useRouter();
/* State */
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
const [customerModalOpen, setCustomerModalOpen] = useState(false);
const [instructions, setInstructions] = useState<ShipmentInstruction[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState("");
/* NumberPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<ShipmentInstruction | null>(null);
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
/* Inline customer search state */
const [allCustomers, setAllCustomers] = useState<Customer[]>([]);
/* Fetch all customers for inline search (customer_mng = 고객사) */
const fetchAllCustomers = useCallback(async () => {
try {
const res = await apiClient.get("/data/customer_mng", { params: { pageSize: 500 } });
const data = res.data?.data ?? res.data?.rows ?? [];
const list: Customer[] = (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r.customer_name ?? r.name ?? ""),
customer_code: String(r.customer_code ?? r.code ?? ""),
business_number: String(r.business_number ?? ""),
phone: String(r.contact_phone ?? r.phone ?? ""),
address: String(r.address ?? ""),
}));
setAllCustomers(list);
} catch {
setAllCustomers([]);
}
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
/* Fetch shipment instructions */
const fetchInstructions = useCallback(async (searchKeyword?: string) => {
setLoading(true);
try {
const params: Record<string, string> = { pageSize: "50" };
if (searchKeyword) params.keyword = searchKeyword;
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const data = res.data?.data;
if (Array.isArray(data) && data.length > 0) {
setInstructions(data.map((r: Record<string, unknown>) => ({
id: String(r.detail_id ?? r.id ?? ""),
detail_id: String(r.detail_id ?? ""),
instruction_id: String(r.instruction_id ?? ""),
instruction_no: String(r.instruction_no ?? ""),
instruction_date: String(r.instruction_date ?? "").slice(0, 10),
partner_id: String(r.partner_id ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: "", // will be resolved from customer list or instruction
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
plan_qty: Number(r.plan_qty ?? 0),
ship_qty: Number(r.ship_qty ?? 0),
remain_qty: Number(r.remain_qty ?? 0),
order_qty: Number(r.order_qty ?? 0),
source_type: String(r.source_type ?? "shipment_instruction_detail"),
inspection_type: r.inspection_type === "self" ? "self"
: r.inspection_type === "request" ? "request"
: null,
image: r.image ? String(r.image) : null,
})));
} else {
setInstructions(DUMMY_INSTRUCTIONS);
}
} catch {
setInstructions(DUMMY_INSTRUCTIONS);
} finally {
setLoading(false);
}
}, []);
/* Initial load */
useEffect(() => {
fetchInstructions();
}, [fetchInstructions]);
/* Filter instructions by selected customer */
const filteredInstructions = selectedCustomer
? instructions.filter((o) =>
o.customer_code === selectedCustomer.customer_code ||
o.partner_id === selectedCustomer.customer_code ||
o.partner_id === selectedCustomer.customer_name
)
: instructions;
/* Filter by keyword */
const displayInstructions = keyword
? filteredInstructions.filter((o) =>
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
o.instruction_no.toLowerCase().includes(keyword.toLowerCase())
)
: filteredInstructions;
/* Open numpad for an instruction */
const openNumpad = (instr: ShipmentInstruction) => {
setNumpadTarget(instr);
setNumpadOpen(true);
};
/* Add to cart with numpad result */
const handleNumpadConfirm = (qty: number, packages: PackageEntry[]) => {
if (!numpadTarget) return;
const instr = numpadTarget;
if (cart.isItemInCart(instr.id)) return;
const finalQty = Math.min(qty, instr.remain_qty);
cart.addItem(
{
row: {
id: instr.id,
detail_id: instr.detail_id,
instruction_id: instr.instruction_id,
item_code: instr.item_code,
item_name: instr.item_name,
customer_code: instr.customer_code || instr.partner_id,
customer_name: selectedCustomer?.customer_name || instr.customer_name,
instruction_no: instr.instruction_no,
instruction_date: instr.instruction_date,
unit_price: 0,
spec: instr.spec || "",
material: instr.material || "",
plan_qty: instr.plan_qty,
remain_qty: instr.remain_qty,
inspection_type: instr.inspection_type,
source_table: "shipment_instruction_detail",
source_type: "shipment_instruction_detail",
source_id: instr.detail_id || instr.id,
image: instr.image || null,
},
quantity: finalQty,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
packageEntries: packages.length > 0 ? packages as any : undefined,
},
instr.id,
);
setNumpadTarget(null);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
};
/* Search */
const handleSearch = () => {
fetchInstructions(keyword || undefined);
};
const isInCart = (id: string) => cart.isItemInCart(id);
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
return (
<div className="flex flex-col gap-4">
{/* ===== Header ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/outbound")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
</div>
{/* ===== Search area (2 columns on tablet+) ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Customer search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"></span>
{selectedCustomer && (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{selectedCustomer.customer_name}
</span>
)}
</div>
<div className="relative flex items-center gap-2">
<button
onClick={() => setCustomerModalOpen(true)}
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
selectedCustomer
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{selectedCustomer ? selectedCustomer.customer_name : "고객사를 선택하세요"}
</button>
{/* QR/Barcode scan button */}
<button
onClick={() => setCustomerScanOpen(true)}
className="min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0"
style={{
background: "linear-gradient(to bottom, #4ade80, #16a34a)",
boxShadow: "0 4px 12px rgba(34,197,94,0.3)",
}}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
{selectedCustomer && (
<button
onClick={() => setSelectedCustomer(null)}
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Item search card */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-[11px] font-semibold text-white bg-green-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
{selectedCustomer ? displayInstructions.length : 0}
</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명, 품목코드, 출하지시번호 검색..."
disabled={!selectedCustomer}
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
selectedCustomer
? "focus:border-green-400 focus:ring-2 focus:ring-green-100"
: "bg-gray-50 text-gray-400 cursor-not-allowed"
}`}
/>
{/* QR/Barcode scan button */}
<button
onClick={() => setItemScanOpen(true)}
disabled={!selectedCustomer}
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${
!selectedCustomer ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
background: "linear-gradient(to bottom, #4ade80, #16a34a)",
boxShadow: selectedCustomer ? "0 4px 12px rgba(34,197,94,0.3)" : "none",
}}
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
</svg>
</button>
</div>
</div>
</div>
{/* ===== Instruction items ===== */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-[11px] text-gray-400">
{selectedCustomer ? `${displayInstructions.length}` : "-"}
</span>
</div>
{!selectedCustomer ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-green-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</div>
) : displayInstructions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0H6.75m11.25 0h2.625c.621 0 1.125-.504 1.125-1.125v-4.875c0-.621-.504-1.125-1.125-1.125H17.25" />
</svg>
<p className="text-sm">
{selectedCustomer ? "해당 고객사의 미출고 출하지시가 없습니다" : "고객사를 선택하거나 품목을 검색하세요"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{displayInstructions.map((instr) => {
const inCart = isInCart(instr.id);
const cartItem = getCartItem(instr.id);
return (
<div
key={instr.id}
className={`relative rounded-xl border p-3 transition-all ${
inCart
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
: "border-gray-200 bg-white hover:border-green-300"
}`}
>
{/* Green left bar for in-cart items */}
{inCart && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
)}
{/* === Header row: item code + item name === */}
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
<span className="text-[11px] text-gray-400 font-medium shrink-0">{instr.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{instr.item_name}</span>
</div>
{/* === Body row: image + info + action === */}
<div className="flex gap-2.5">
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{instr.image ? (
<img src={instr.image} alt={instr.item_name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[55px] shrink-0"></span>
<span className="font-medium text-gray-700">{instr.instruction_date}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[55px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{instr.instruction_no}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[55px] shrink-0"></span>
<span className="font-medium text-gray-700">{instr.plan_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[55px] shrink-0"></span>
<span className="font-bold text-red-500">
{inCart
? (instr.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
: instr.remain_qty.toLocaleString()
}
</span>
</div>
</div>
{/* Action column: qty display + add/cancel button */}
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
{/* Qty display */}
<button
onClick={() => {
if (!inCart) openNumpad(instr);
}}
disabled={inCart}
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
inCart
? "bg-gray-50 border-gray-200 cursor-default"
: "bg-green-50 border-green-200 hover:bg-green-100 cursor-pointer active:scale-95"
}`}
>
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-green-700"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{inCart
? (cartItem?.quantity ?? instr.remain_qty).toLocaleString()
: instr.remain_qty.toLocaleString()
}
</span>
<span className="text-[10px] text-gray-400">EA</span>
</button>
{/* Add / Cancel button */}
{inCart ? (
<button
onClick={() => handleRemoveFromCart(instr.id)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
</button>
) : (
<button
onClick={() => openNumpad(instr)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #22c55e 0%, #15803d 100%)",
}}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
</svg>
</button>
)}
</div>
</div>
{/* === Package info (shown when in cart with packages) === */}
{(() => {
const pkgs = (inCart && cartItem?.packageEntries) ? cartItem.packageEntries as unknown as PackageEntry[] : [];
return pkgs.length > 0 && (
<div className="mt-2.5 px-3 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-white bg-gradient-to-r from-green-500 to-green-600 px-2 py-0.5 rounded-full">
</span>
<span className="text-[11px] font-semibold text-green-600">
{pkgs.reduce((s, p) => s + p.count * p.qtyPerUnit, 0).toLocaleString()} EA
</span>
</div>
{pkgs.map((pkg, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<span>{pkg.unit.icon}</span>
<span>{pkg.count}{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA</span>
</div>
))}
</div>
);
})()}
</div>
);
})}
</div>
)}
</div>
{/* ===== Modals ===== */}
<CustomerModal
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(c) => setSelectedCustomer(c)}
/>
<NumberPadModal
open={numpadOpen}
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
onConfirm={handleNumpadConfirm}
maxQty={numpadTarget?.remain_qty ?? 0}
itemName={numpadTarget?.item_name ?? ""}
/>
{/* Barcode scan modal for customer */}
<BarcodeScanModal
open={customerScanOpen}
onOpenChange={setCustomerScanOpen}
targetField="고객사"
autoSubmit
onScanSuccess={(barcode) => {
setCustomerScanOpen(false);
const match = allCustomers.find(
(c) =>
c.customer_code === barcode ||
c.customer_name.includes(barcode)
);
if (match) {
setSelectedCustomer(match);
}
}}
/>
{/* Barcode scan modal for item */}
<BarcodeScanModal
open={itemScanOpen}
onOpenChange={setItemScanOpen}
targetField="출하지시 품목"
autoSubmit
onScanSuccess={(barcode) => {
setItemScanOpen(false);
setKeyword(barcode);
fetchInstructions(barcode);
}}
/>
</div>
);
}