/** * Scan to purchase — the counter flow. * * Scan a customer's loyalty QR, confirm it is the right person, add what they * are buying, clear any dispensing gate, confirm. Confirming writes a real * order, a loyalty award and a stock movement into the mocks, then offers the * receipt and a reset so the counter loops onto the next customer. */ import { useEffect, useMemo, useState, type FormEvent } from 'react' import { Link } from 'react-router-dom' import { toast } from 'sonner' import { AlertTriangle, BadgeCheck, CheckCircle2, FileHeart, Minus, Package, Plus, Printer, Receipt, RotateCcw, ScanLine, ShieldAlert, Trash2, UserPlus, UserRound, X, } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { SelectNative } from '@/components/ui/select-native' import { Separator } from '@/components/ui/separator' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { EmptyState } from '@/components/ui/empty-state' import { IconTile } from '@/components/ui/icon-tile' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { DetailSection } from '@/components/detail/ActivityTimeline' import { useAuthStore } from '@/zustand/authStore' import { formatDateTime, formatEtb, formatRelative } from '@/lib/utils' import { auditLog, branches, counterScanHistory, customers, type CounterScan, type Customer, type PurchaseTicket, type ScanSource, } from '@/mocks/data' import type { Order } from '@/types' import { QrImage } from '@/pages/pos/QrImage' import { ScanPad } from '@/pages/pos/ScanPad' import { commitPurchase, shortBranchName } from '@/pages/pos/commitPurchase' import { blockingIssues, cartTotal, customerQrPayload, findStock, gateFor, isShort, lineTotal, pointsForPurchase, resolveScannedCode, searchBranchStock, type CartLine, } from '@/pages/pos/posEngine' function tierVariant(tier: string) { if (tier === 'Gold') return 'warning' as const if (tier === 'Silver') return 'secondary' as const return 'muted' as const } type CounterReceipt = { order: Order lines: CartLine[] points: number customer: Customer } export function QuickPurchasePanel({ handoff, onHandoffConsumed, }: { /** A ticket sent over from the queue for an operator to clear and confirm. */ handoff?: PurchaseTicket | null onHandoffConsumed?: () => void }) { const user = useAuthStore((s) => s.user) const role = user?.role const [branchId, setBranchId] = useState(user?.branchId ?? branches[0]?.id ?? '') const [customer, setCustomer] = useState(null) const [unknownCode, setUnknownCode] = useState(null) const [scanSource, setScanSource] = useState('manual') const [lines, setLines] = useState([]) const [query, setQuery] = useState('') const [receipt, setReceipt] = useState(null) const [overrideSku, setOverrideSku] = useState(null) const [overrideReason, setOverrideReason] = useState('') const [createOpen, setCreateOpen] = useState(false) const [draft, setDraft] = useState({ name: '', phone: '' }) const [history, setHistory] = useState(() => counterScanHistory.slice(0, 6)) const suggestions = useMemo(() => searchBranchStock(branchId, query), [branchId, query]) const total = cartTotal(lines) const points = customer ? pointsForPurchase(total, customer.tier) : 0 const issues = customer ? blockingIssues(lines, customer.name, role) : [] /** Ticket-queue accept lands here with a pre-filled customer + basket. */ useEffect(() => { if (!handoff) return const match = (handoff.customerId ? customers.find((c) => c.id === handoff.customerId) : undefined) ?? (handoff.phone ? customers.find((c) => c.phone === handoff.phone) : undefined) ?? null setBranchId(handoff.branchId || branchId) setReceipt(null) setUnknownCode(null) setScanSource('manual') if (match) { setCustomer(match) } else { setCustomer(null) setUnknownCode(handoff.phone ?? handoff.externalRef) } setLines( handoff.lines.map((line) => { const stock = findStock(handoff.branchId || branchId, line.sku) return { sku: line.sku, name: line.name, qty: line.qty, unitEtb: line.unitEtb, available: stock?.qty ?? 0, } }), ) onHandoffConsumed?.() toast.message(`Ticket ${handoff.externalRef} loaded`, { description: match ? `${match.name} · confirm the basket` : 'Resolve the customer, then confirm the basket', }) // eslint-disable-next-line react-hooks/exhaustive-deps -- consume each handoff once when identity changes }, [handoff?.id]) const logScan = (entry: CounterScan) => { counterScanHistory.unshift(entry) setHistory((prev) => [entry, ...prev].slice(0, 6)) } const onCode = (code: string, source: ScanSource) => { const { customer: match } = resolveScannedCode(code) setScanSource(source) if (!match) { setUnknownCode(code) setCustomer(null) logScan({ id: `scn-${Date.now()}`, at: new Date().toISOString(), code, customerName: 'Not recognised', source, outcome: 'unknown', branchId, staff: user?.name ?? 'Counter staff', }) return } setUnknownCode(null) setCustomer(match) setLines([]) setReceipt(null) toast.success(`${match.name} identified`, { description: `${match.tier} tier · ${match.points} points` }) } const resetCounter = () => { setCustomer(null) setUnknownCode(null) setLines([]) setQuery('') setReceipt(null) } const addLine = (sku: string) => { const row = findStock(branchId, sku) if (!row) return setQuery('') setLines((prev) => { const existing = prev.find((line) => line.sku === sku) if (existing) { return prev.map((line) => (line.sku === sku ? { ...line, qty: line.qty + 1 } : line)) } return [ ...prev, { sku: row.sku, name: row.name, qty: 1, unitEtb: row.unitEtb, available: row.qty }, ] }) } const setQty = (sku: string, qty: number) => { setLines((prev) => prev.map((line) => (line.sku === sku ? { ...line, qty: Math.max(1, qty) } : line)), ) } const removeLine = (sku: string) => setLines((prev) => prev.filter((line) => line.sku !== sku)) const saveOverride = (event: FormEvent) => { event.preventDefault() const reason = overrideReason.trim() if (!overrideSku || !reason) return setLines((prev) => prev.map((line) => (line.sku === overrideSku ? { ...line, overrideReason: reason } : line)), ) auditLog.unshift({ id: `a-${Date.now().toString().slice(-4)}`, at: new Date().toISOString(), actor: user?.name ?? 'Counter staff', actorRole: role ?? 'pharmacist', action: 'pos.emergency_supply', category: 'prescriptions', target: overrideSku, targetLabel: `${customer?.name ?? 'Walk-in'} · ${overrideSku}`, ip: '196.189.44.12', userAgent: navigator.userAgent, outcome: 'success', security: true, note: reason, }) toast.success('Emergency supply recorded', { description: 'Logged to the security review queue' }) setOverrideSku(null) setOverrideReason('') } const createCustomer = (event: FormEvent) => { event.preventDefault() const name = draft.name.trim() const phone = draft.phone.trim() if (!name || !phone) return const created: Customer = { id: `c${customers.length + 1}`, name, phone, tier: 'Bronze', points: 0, branch: shortBranchName(branchId), joinedAt: new Date().toISOString(), orders: 0, spendEtb: 0, lastOrderAt: null, } customers.push(created) setCustomer(created) setUnknownCode(null) setLines([]) setCreateOpen(false) setDraft({ name: '', phone: '' }) toast.success(`${name} enrolled`, { description: 'A loyalty QR code is ready in Customer codes' }) } const confirmPurchase = () => { if (!customer || lines.length === 0 || issues.length > 0) return const { order, points: awarded, scan } = commitPurchase({ customer, branchId, lines, role, source: scanSource, staffName: user?.name ?? 'Counter staff', }) setHistory((prev) => [scan, ...prev].slice(0, 6)) setReceipt({ order, lines, points: awarded, customer }) setLines([]) toast.success(`${order.id} confirmed`, { description: `${formatEtb(total)} · ${awarded} points awarded to ${customer.name}`, }) } // ── Receipt ─────────────────────────────────────────────────────────────── if (receipt) { return (
Purchase confirmed

{receipt.order.id} · {formatDateTime(receipt.order.createdAt)}

{receipt.customer.name}

{receipt.customer.phone} · {shortBranchName(receipt.order.branchId)}

{formatEtb(receipt.order.totalEtb)}

+{receipt.points} loyalty points

SKU Item Qty Line total {receipt.lines.map((line) => ( {line.sku} {line.name} {line.qty} {formatEtb(lineTotal(line))} ))}
) } // ── Scan / basket ───────────────────────────────────────────────────────── return (
Identify the customer

Scan their loyalty QR. No camera? Type the code or phone number, or simulate a scan.

{ setBranchId(event.target.value) setLines([]) }} disabled={role === 'pharmacist' && !!user?.branchId} > {branches.map((branch) => ( ))}
{customer ? (

{customer.name}

{customer.tier}

{customer.phone} · {customer.id} · Home branch {customer.branch}

Points balance
{customer.points.toLocaleString()}
Last order
{customer.lastOrderAt ? formatRelative(customer.lastOrderAt) : 'First visit'}

Resolved from a {scanSource} scan. Confirm the name and phone with the customer before taking payment.

) : unknownCode ? (

Code not recognised

{unknownCode} matches no customer record.

) : ( ({ label: c.name.split(' ')[0], hint: `${c.tier} · ${c.points} points`, value: customerQrPayload(c.id), }))} simulateHint="No camera needed — picks a demo customer as if their code had been read." /> )}
Recent counter scans {history.map((entry) => (

{entry.customerName}

{formatRelative(entry.at)} · {entry.source} {entry.orderId ? ` · ${entry.orderId}` : ''}

{entry.outcome}
))}
What are they buying?

Search the {shortBranchName(branchId)} shelf by SKU or name.

setQuery(event.target.value)} placeholder="AMX-500, paracetamol…" disabled={!customer} autoComplete="off" />
{suggestions.length > 0 ? (
    {suggestions.map((row) => (
  • ))}
) : null} {lines.length === 0 ? ( ) : (
Item Qty Unit Line Remove {lines.map((line) => { const gate = customer ? gateFor({ sku: line.sku, productName: line.name, customerName: customer.name, role, }) : null const short = isShort(line) return (

{line.name}

{line.sku} · {line.available} on shelf

{gate && gate.dispenseClass !== 'otc' ? ( {gate.dispenseClass === 'controlled' ? 'Controlled' : 'Rx only'} ) : null} {short ? Insufficient stock : null} {line.overrideReason ? Emergency supply : null}
{gate && gate.state !== 'clear' && !line.overrideReason ? (

{gate.message}

) : null} {short ? (

Only {line.available} available — reduce the quantity or transfer stock in.

) : null} {gate?.state === 'needs_rx' && gate.overridable && !line.overrideReason ? (
) : null} {line.overrideReason ? (

Emergency supply: {line.overrideReason}

) : null}
setQty(line.sku, Number(event.target.value))} aria-label={`Quantity for ${line.sku}`} />
{formatEtb(line.unitEtb)} {formatEtb(lineTotal(line))}
) })}
)}

Total due

{formatEtb(total)}

{customer ? (

Earns {points} points · {customer.tier} multiplier on 1 point per 100 ETB

) : null}
{issues.length > 0 ? (

    {issues.map((issue) => (
  • {issue.label}
  • ))}
) : null}
{ if (!open) setOverrideSku(null) }} > Record an emergency supply Prescription-only item with no approved Rx on file. Your name, the reason and the SKU go to the security review queue in the audit log.
setOverrideReason(event.target.value)} placeholder="Repeat inhaler, prescriber contacted by phone…" autoFocus />
Enrol a new customer Creates a loyalty record on the spot and generates a QR code this tool can scan next time.
setDraft({ ...draft, name: event.target.value })} autoFocus />
setDraft({ ...draft, phone: event.target.value })} placeholder="+2519…" />

Their code will be {customerQrPayload(`c${customers.length + 1}`)} — printable from the Customer codes tab.

) }