Add medication Items catalogue, counter POS/QR purchase, profile/login, detail pages, loyalty ledger, Cmd+K nav, KPI strips/charts, avatars, and i18n; document the new APIs and open questions for the shared backend team. Co-authored-by: Cursor <cursoragent@cursor.com>
874 lines
35 KiB
TypeScript
874 lines
35 KiB
TypeScript
/**
|
|
* 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<Customer | null>(null)
|
|
const [unknownCode, setUnknownCode] = useState<string | null>(null)
|
|
const [scanSource, setScanSource] = useState<ScanSource>('manual')
|
|
const [lines, setLines] = useState<CartLine[]>([])
|
|
const [query, setQuery] = useState('')
|
|
const [receipt, setReceipt] = useState<CounterReceipt | null>(null)
|
|
const [overrideSku, setOverrideSku] = useState<string | null>(null)
|
|
const [overrideReason, setOverrideReason] = useState('')
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [draft, setDraft] = useState({ name: '', phone: '' })
|
|
const [history, setHistory] = useState<CounterScan[]>(() => 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 (
|
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
|
<Card>
|
|
<CardHeader className="flex-row items-center gap-3">
|
|
<IconTile icon={CheckCircle2} tone="brand" />
|
|
<div>
|
|
<CardTitle>Purchase confirmed</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
{receipt.order.id} · {formatDateTime(receipt.order.createdAt)}
|
|
</p>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-xl border bg-cream/60 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<p className="font-display text-base font-semibold text-forest">{receipt.customer.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{receipt.customer.phone} · {shortBranchName(receipt.order.branchId)}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-display text-xl font-bold text-forest">{formatEtb(receipt.order.totalEtb)}</p>
|
|
<p className="text-xs text-brand-700">+{receipt.points} loyalty points</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead>SKU</TableHead>
|
|
<TableHead>Item</TableHead>
|
|
<TableHead>Qty</TableHead>
|
|
<TableHead className="text-right">Line total</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{receipt.lines.map((line) => (
|
|
<TableRow key={line.sku}>
|
|
<TableCell className="font-medium">{line.sku}</TableCell>
|
|
<TableCell>{line.name}</TableCell>
|
|
<TableCell>{line.qty}</TableCell>
|
|
<TableCell className="text-right">{formatEtb(lineTotal(line))}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button className="h-10 gap-2 shadow-none" onClick={resetCounter}>
|
|
<ScanLine className="h-4 w-4" />
|
|
Next customer
|
|
</Button>
|
|
<Button variant="outline" className="h-10 gap-2" onClick={() => window.print()}>
|
|
<Printer className="h-4 w-4" />
|
|
Print receipt
|
|
</Button>
|
|
<Button asChild variant="secondary" className="h-10 gap-2">
|
|
<Link to={`/orders/${receipt.order.id}`}>
|
|
<Receipt className="h-4 w-4" />
|
|
Open order
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<DetailSection title="What was written" description="Every mock this confirmation touched">
|
|
<ul className="space-y-2 text-sm">
|
|
<li className="flex gap-2">
|
|
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
|
|
<span>
|
|
Order <strong>{receipt.order.id}</strong> appended, paid, channel <strong>counter</strong>
|
|
</span>
|
|
</li>
|
|
<li className="flex gap-2">
|
|
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
|
|
<span>
|
|
Loyalty ledger credited <strong>+{receipt.points}</strong> points ({receipt.customer.tier} multiplier
|
|
applied)
|
|
</span>
|
|
</li>
|
|
<li className="flex gap-2">
|
|
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
|
|
<span>
|
|
Stock decremented at {shortBranchName(receipt.order.branchId)} with a sale movement per line
|
|
</span>
|
|
</li>
|
|
<li className="flex gap-2">
|
|
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
|
|
<span>Scan logged to the counter history below</span>
|
|
</li>
|
|
</ul>
|
|
</DetailSection>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Scan / basket ─────────────────────────────────────────────────────────
|
|
return (
|
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Identify the customer</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Scan their loyalty QR. No camera? Type the code or phone number, or simulate a scan.
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="pos-branch">Selling branch</Label>
|
|
<SelectNative
|
|
id="pos-branch"
|
|
value={branchId}
|
|
onChange={(event) => {
|
|
setBranchId(event.target.value)
|
|
setLines([])
|
|
}}
|
|
disabled={role === 'pharmacist' && !!user?.branchId}
|
|
>
|
|
{branches.map((branch) => (
|
|
<option key={branch.id} value={branch.id}>
|
|
{branch.name}
|
|
</option>
|
|
))}
|
|
</SelectNative>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{customer ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-start gap-3 rounded-xl border border-brand-200 bg-brand-50 p-4">
|
|
<IconTile icon={UserRound} tone="brand" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="font-display text-base font-semibold text-forest">{customer.name}</p>
|
|
<Badge variant={tierVariant(customer.tier)}>{customer.tier}</Badge>
|
|
</div>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{customer.phone} · {customer.id} · Home branch {customer.branch}
|
|
</p>
|
|
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<dt className="text-muted-foreground">Points balance</dt>
|
|
<dd className="font-semibold text-forest">{customer.points.toLocaleString()}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-muted-foreground">Last order</dt>
|
|
<dd className="font-semibold text-forest">
|
|
{customer.lastOrderAt ? formatRelative(customer.lastOrderAt) : 'First visit'}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
onClick={resetCounter}
|
|
aria-label="Clear customer and scan again"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Resolved from a {scanSource} scan. Confirm the name and phone with the customer before taking payment.
|
|
</p>
|
|
</div>
|
|
) : unknownCode ? (
|
|
<div className="rounded-xl border border-orange/40 bg-orange/5 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<IconTile icon={AlertTriangle} tone="orange" size="sm" />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold text-forest">Code not recognised</p>
|
|
<p className="mt-0.5 break-all text-xs text-muted-foreground">
|
|
<code>{unknownCode}</code> matches no customer record.
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button
|
|
className="h-9 gap-1.5 px-3 text-xs shadow-none"
|
|
onClick={() => {
|
|
setDraft({
|
|
name: '',
|
|
phone: /^\+?\d[\d\s-]{7,}$/.test(unknownCode) ? unknownCode : '',
|
|
})
|
|
setCreateOpen(true)
|
|
}}
|
|
>
|
|
<UserPlus className="h-3.5 w-3.5" />
|
|
Enrol this customer
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-9 px-3 text-xs"
|
|
onClick={() => setUnknownCode(null)}
|
|
>
|
|
Scan again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ScanPad
|
|
onCode={onCode}
|
|
manualLabel="Keypad entry"
|
|
manualPlaceholder="c1, GISHEN-CUST:c1 or +2519…"
|
|
manualHint="Accepts the printed code, a loyalty URL, a customer id or a phone number."
|
|
simulateOptions={customers.slice(0, 4).map((c) => ({
|
|
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."
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Recent counter scans</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{history.map((entry) => (
|
|
<div key={entry.id} className="flex items-center justify-between gap-3 rounded-xl border p-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium text-forest">{entry.customerName}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatRelative(entry.at)} · {entry.source}
|
|
{entry.orderId ? ` · ${entry.orderId}` : ''}
|
|
</p>
|
|
</div>
|
|
<Badge variant={entry.outcome === 'resolved' ? 'success' : 'warning'}>{entry.outcome}</Badge>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>What are they buying?</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Search the {shortBranchName(branchId)} shelf by SKU or name.
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="pos-sku">Add item</Label>
|
|
<Input
|
|
id="pos-sku"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="AMX-500, paracetamol…"
|
|
disabled={!customer}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
{suggestions.length > 0 ? (
|
|
<ul className="space-y-1.5">
|
|
{suggestions.map((row) => (
|
|
<li key={`${row.sku}-${row.branchId}`}>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-colors hover:bg-grayScale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
|
onClick={() => addLine(row.sku)}
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm font-medium text-forest">{row.name}</span>
|
|
<span className="block text-xs text-muted-foreground">
|
|
{row.sku} · {row.qty} on shelf · {formatEtb(row.unitEtb)}
|
|
</span>
|
|
</span>
|
|
<Plus className="h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
{lines.length === 0 ? (
|
|
<EmptyState
|
|
icon={Package}
|
|
variant="dashed"
|
|
title={customer ? 'Basket is empty' : 'Waiting for a scan'}
|
|
description={
|
|
customer
|
|
? 'Search above to add what the customer is buying.'
|
|
: 'Identify the customer first — the basket unlocks once a code resolves.'
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead>Item</TableHead>
|
|
<TableHead>Qty</TableHead>
|
|
<TableHead>Unit</TableHead>
|
|
<TableHead>Line</TableHead>
|
|
<TableHead className="text-right">Remove</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{lines.map((line) => {
|
|
const gate = customer
|
|
? gateFor({
|
|
sku: line.sku,
|
|
productName: line.name,
|
|
customerName: customer.name,
|
|
role,
|
|
})
|
|
: null
|
|
const short = isShort(line)
|
|
|
|
return (
|
|
<TableRow key={line.sku} className="align-top">
|
|
<TableCell>
|
|
<p className="font-medium text-forest">{line.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{line.sku} · {line.available} on shelf
|
|
</p>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
{gate && gate.dispenseClass !== 'otc' ? (
|
|
<Badge
|
|
variant={
|
|
gate.state === 'rx_linked'
|
|
? 'success'
|
|
: gate.state === 'blocked'
|
|
? 'danger'
|
|
: 'warning'
|
|
}
|
|
>
|
|
{gate.dispenseClass === 'controlled' ? 'Controlled' : 'Rx only'}
|
|
</Badge>
|
|
) : null}
|
|
{short ? <Badge variant="danger">Insufficient stock</Badge> : null}
|
|
{line.overrideReason ? <Badge variant="warning">Emergency supply</Badge> : null}
|
|
</div>
|
|
{gate && gate.state !== 'clear' && !line.overrideReason ? (
|
|
<p
|
|
className={
|
|
gate.state === 'blocked'
|
|
? 'mt-1.5 text-xs text-red-700'
|
|
: 'mt-1.5 text-xs text-muted-foreground'
|
|
}
|
|
>
|
|
{gate.message}
|
|
</p>
|
|
) : null}
|
|
{short ? (
|
|
<p className="mt-1 text-xs text-red-700">
|
|
Only {line.available} available — reduce the quantity or transfer stock in.
|
|
</p>
|
|
) : null}
|
|
{gate?.state === 'needs_rx' && gate.overridable && !line.overrideReason ? (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 gap-1.5 px-2.5 text-xs"
|
|
onClick={() => {
|
|
setOverrideSku(line.sku)
|
|
setOverrideReason('')
|
|
}}
|
|
>
|
|
<ShieldAlert className="h-3.5 w-3.5" />
|
|
Record emergency supply
|
|
</Button>
|
|
<Button asChild variant="ghost" className="h-8 gap-1.5 px-2.5 text-xs">
|
|
<Link to="/prescriptions/new">
|
|
<FileHeart className="h-3.5 w-3.5" />
|
|
Log an Rx
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
{line.overrideReason ? (
|
|
<p className="mt-1.5 text-xs text-orange-deep">
|
|
Emergency supply: {line.overrideReason}
|
|
</p>
|
|
) : null}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setQty(line.sku, line.qty - 1)}
|
|
aria-label={`Decrease ${line.sku}`}
|
|
>
|
|
<Minus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Input
|
|
className="h-8 w-14 text-center"
|
|
type="number"
|
|
min={1}
|
|
value={line.qty}
|
|
onChange={(event) => setQty(line.sku, Number(event.target.value))}
|
|
aria-label={`Quantity for ${line.sku}`}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setQty(line.sku, line.qty + 1)}
|
|
aria-label={`Increase ${line.sku}`}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap">{formatEtb(line.unitEtb)}</TableCell>
|
|
<TableCell className="whitespace-nowrap font-medium">
|
|
{formatEtb(lineTotal(line))}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => removeLine(line.sku)}
|
|
aria-label={`Remove ${line.sku}`}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="space-y-4 p-5">
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total due</p>
|
|
<p className="font-display text-3xl font-bold text-forest">{formatEtb(total)}</p>
|
|
{customer ? (
|
|
<p className="mt-1 text-xs text-brand-700">
|
|
Earns {points} points · {customer.tier} multiplier on 1 point per 100 ETB
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="h-11 gap-2"
|
|
onClick={() => setLines([])}
|
|
disabled={lines.length === 0}
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
Clear basket
|
|
</Button>
|
|
<Button
|
|
className="h-11 gap-2 shadow-none"
|
|
onClick={confirmPurchase}
|
|
disabled={!customer || lines.length === 0 || issues.length > 0}
|
|
>
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
Confirm purchase
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{issues.length > 0 ? (
|
|
<div className="rounded-xl border border-red-200 bg-red-50 p-3">
|
|
<p className="flex items-center gap-1.5 text-sm font-semibold text-red-700">
|
|
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
|
{issues.length} thing{issues.length === 1 ? '' : 's'} to resolve before confirming
|
|
</p>
|
|
<ul className="mt-1.5 space-y-1 text-xs text-red-700">
|
|
{issues.map((issue) => (
|
|
<li key={`${issue.sku}-${issue.label}`}>{issue.label}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Dialog
|
|
open={!!overrideSku}
|
|
onOpenChange={(open) => {
|
|
if (!open) setOverrideSku(null)
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Record an emergency supply</DialogTitle>
|
|
<DialogDescription>
|
|
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.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form className="space-y-3" onSubmit={saveOverride}>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="pos-override">Clinical reason</Label>
|
|
<Input
|
|
id="pos-override"
|
|
value={overrideReason}
|
|
onChange={(event) => setOverrideReason(event.target.value)}
|
|
placeholder="Repeat inhaler, prescriber contacted by phone…"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={() => setOverrideSku(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={!overrideReason.trim()}>
|
|
Record and continue
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Enrol a new customer</DialogTitle>
|
|
<DialogDescription>
|
|
Creates a loyalty record on the spot and generates a QR code this tool can scan next time.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form className="space-y-3" onSubmit={createCustomer}>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="pos-new-name">Full name</Label>
|
|
<Input
|
|
id="pos-new-name"
|
|
value={draft.name}
|
|
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="pos-new-phone">Phone</Label>
|
|
<Input
|
|
id="pos-new-phone"
|
|
value={draft.phone}
|
|
onChange={(event) => setDraft({ ...draft, phone: event.target.value })}
|
|
placeholder="+2519…"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 rounded-xl border bg-cream/60 p-3">
|
|
<QrImage value={customerQrPayload(`c${customers.length + 1}`)} size={72} alt="Preview of the new loyalty code" />
|
|
<p className="text-xs text-muted-foreground">
|
|
Their code will be <code>{customerQrPayload(`c${customers.length + 1}`)}</code> — printable from the
|
|
Customer codes tab.
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={!draft.name.trim() || !draft.phone.trim()}>
|
|
Enrol and continue
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|