feat: add POS register screen with full-screen touch-optimized layout

Standalone register at /pos bypassing the admin sidebar layout:
- Two-panel layout: product search/grid (60%) + cart/payment (40%)
- Product search with barcode scan support (UPC lookup on Enter)
- Custom item entry dialog for ad-hoc items
- Cart with line items, tax, totals, and remove-item support
- Payment dialogs: cash (quick amounts + change calc), card, check
- Drawer open/close with balance reconciliation and over/short
- Auto-creates pending transaction on first item added
- POS link added to admin sidebar nav (module-gated)
- Zustand store for POS session state, React Query for server data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 19:29:37 +00:00
parent bd5f0ca511
commit bd3a25aa1c
11 changed files with 1180 additions and 1 deletions

View File

@@ -0,0 +1,179 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { X, Banknote, CreditCard, FileText, Ban } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { POSPaymentDialog } from './pos-payment-dialog'
interface POSCartPanelProps {
transaction: Transaction | null
}
export function POSCartPanel({ transaction }: POSCartPanelProps) {
const queryClient = useQueryClient()
const { currentTransactionId, setTransaction } = usePOSStore()
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
const lineItems = transaction?.lineItems ?? []
const removeItemMutation = useMutation({
mutationFn: (lineItemId: string) =>
posMutations.removeLineItem(currentTransactionId!, lineItemId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
},
onError: (err) => toast.error(err.message),
})
const voidMutation = useMutation({
mutationFn: () => posMutations.void(currentTransactionId!),
onSuccess: () => {
setTransaction(null)
toast.success('Transaction voided')
},
onError: (err) => toast.error(err.message),
})
const subtotal = parseFloat(transaction?.subtotal ?? '0')
const discountTotal = parseFloat(transaction?.discountTotal ?? '0')
const taxTotal = parseFloat(transaction?.taxTotal ?? '0')
const total = parseFloat(transaction?.total ?? '0')
const hasItems = lineItems.length > 0
const isPending = transaction?.status === 'pending'
function handlePaymentComplete() {
setPaymentMethod(null)
setTransaction(null)
}
return (
<div className="flex flex-col h-full bg-card">
{/* Header */}
<div className="p-3 border-b border-border">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Current Sale</h2>
{transaction && (
<span className="text-xs text-muted-foreground font-mono">
{transaction.transactionNumber}
</span>
)}
</div>
</div>
{/* Line items */}
<div className="flex-1 overflow-y-auto">
{lineItems.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No items yet
</div>
) : (
<div className="divide-y divide-border">
{lineItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.description}</p>
<p className="text-xs text-muted-foreground">
{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}
{parseFloat(item.taxAmount) > 0 && (
<span className="ml-2">tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
)}
</p>
</div>
<span className="text-sm font-medium tabular-nums">
${parseFloat(item.lineTotal).toFixed(2)}
</span>
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 shrink-0"
onClick={() => removeItemMutation.mutate(item.id)}
disabled={removeItemMutation.isPending}
>
<X className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Totals + payment */}
<div className="shrink-0 border-t border-border">
<div className="px-3 py-2 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">${subtotal.toFixed(2)}</span>
</div>
{discountTotal > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount</span>
<span className="tabular-nums">-${discountTotal.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Tax</span>
<span className="tabular-nums">${taxTotal.toFixed(2)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold pt-1">
<span>Total</span>
<span className="tabular-nums">${total.toFixed(2)}</span>
</div>
</div>
{/* Payment buttons */}
<div className="p-3 grid grid-cols-2 gap-2">
<Button
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => setPaymentMethod('cash')}
>
<Banknote className="h-4 w-4" />
Cash
</Button>
<Button
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => setPaymentMethod('card_present')}
>
<CreditCard className="h-4 w-4" />
Card
</Button>
<Button
variant="outline"
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => setPaymentMethod('check')}
>
<FileText className="h-4 w-4" />
Check
</Button>
<Button
variant="destructive"
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => voidMutation.mutate()}
>
<Ban className="h-4 w-4" />
Void
</Button>
</div>
</div>
{/* Payment dialog */}
{paymentMethod && transaction && (
<POSPaymentDialog
open={!!paymentMethod}
onOpenChange={(open) => { if (!open) setPaymentMethod(null) }}
paymentMethod={paymentMethod}
transaction={transaction}
onComplete={handlePaymentComplete}
/>
)}
</div>
)
}