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,139 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type DrawerSession } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { toast } from 'sonner'
interface POSDrawerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawer: DrawerSession | null
}
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
const queryClient = useQueryClient()
const { locationId, setDrawerSession } = usePOSStore()
const isOpen = drawer?.status === 'open'
const [openingBalance, setOpeningBalance] = useState('200')
const [closingBalance, setClosingBalance] = useState('')
const [notes, setNotes] = useState('')
const openMutation = useMutation({
mutationFn: () =>
posMutations.openDrawer({
locationId: locationId ?? undefined,
openingBalance: parseFloat(openingBalance) || 0,
}),
onSuccess: (session) => {
setDrawerSession(session.id)
queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
toast.success('Drawer opened')
onOpenChange(false)
},
onError: (err) => toast.error(err.message),
})
const closeMutation = useMutation({
mutationFn: () =>
posMutations.closeDrawer(drawer!.id, {
closingBalance: parseFloat(closingBalance) || 0,
notes: notes || undefined,
}),
onSuccess: (session) => {
setDrawerSession(null)
queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
const overShort = parseFloat(session.overShort ?? '0')
if (Math.abs(overShort) < 0.01) {
toast.success('Drawer closed - balanced')
} else {
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
}
onOpenChange(false)
},
onError: (err) => toast.error(err.message),
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{isOpen ? 'Close Drawer' : 'Open Drawer'}</DialogTitle>
</DialogHeader>
{isOpen ? (
<div className="space-y-4">
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Opening Balance</span>
<span>${parseFloat(drawer!.openingBalance).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Opened</span>
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Closing Balance *</Label>
<Input
type="number"
step="0.01"
min="0"
value={closingBalance}
onChange={(e) => setClosingBalance(e.target.value)}
placeholder="Count the cash in the drawer"
className="h-11 text-lg"
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="End of shift notes"
className="h-11"
/>
</div>
<Button
className="w-full h-12"
onClick={() => closeMutation.mutate()}
disabled={!closingBalance || closeMutation.isPending}
>
{closeMutation.isPending ? 'Closing...' : 'Close Drawer'}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Opening Balance *</Label>
<Input
type="number"
step="0.01"
min="0"
value={openingBalance}
onChange={(e) => setOpeningBalance(e.target.value)}
placeholder="Starting cash amount"
className="h-11 text-lg"
autoFocus
/>
</div>
<Button
className="w-full h-12"
onClick={() => openMutation.mutate()}
disabled={!openingBalance || openMutation.isPending}
>
{openMutation.isPending ? 'Opening...' : 'Open Drawer'}
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}