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:
139
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal file
139
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user