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:
202
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal file
202
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { posMutations, posKeys, type Transaction } 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 { CheckCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface POSPaymentDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
paymentMethod: string
|
||||
transaction: Transaction
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transaction, onComplete }: POSPaymentDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentTransactionId } = usePOSStore()
|
||||
const total = parseFloat(transaction.total)
|
||||
const [amountTendered, setAmountTendered] = useState('')
|
||||
const [checkNumber, setCheckNumber] = useState('')
|
||||
const [completed, setCompleted] = useState(false)
|
||||
const [result, setResult] = useState<Transaction | null>(null)
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const data: { paymentMethod: string; amountTendered?: number; checkNumber?: string } = {
|
||||
paymentMethod,
|
||||
}
|
||||
if (paymentMethod === 'cash') {
|
||||
data.amountTendered = parseFloat(amountTendered) || 0
|
||||
}
|
||||
if (paymentMethod === 'check') {
|
||||
data.checkNumber = checkNumber || undefined
|
||||
}
|
||||
return posMutations.complete(currentTransactionId!, data)
|
||||
},
|
||||
onSuccess: (txn) => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
||||
setResult(txn)
|
||||
setCompleted(true)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const tenderedAmount = parseFloat(amountTendered) || 0
|
||||
const changeDue = paymentMethod === 'cash' ? Math.max(0, tenderedAmount - total) : 0
|
||||
const canComplete = paymentMethod === 'cash'
|
||||
? tenderedAmount >= total
|
||||
: true
|
||||
|
||||
function handleDone() {
|
||||
onComplete()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const QUICK_AMOUNTS = [1, 5, 10, 20, 50, 100]
|
||||
|
||||
if (completed && result) {
|
||||
const changeGiven = parseFloat(result.changeGiven ?? '0')
|
||||
const roundingAdj = parseFloat(result.roundingAdjustment ?? '0')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => handleDone()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<div className="flex flex-col items-center text-center space-y-4 py-4">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
<h2 className="text-xl font-bold">Sale Complete</h2>
|
||||
<p className="text-muted-foreground text-sm">{result.transactionNumber}</p>
|
||||
|
||||
<div className="w-full text-sm space-y-1">
|
||||
<div className="flex justify-between font-semibold text-base">
|
||||
<span>Total</span>
|
||||
<span>${parseFloat(result.total).toFixed(2)}</span>
|
||||
</div>
|
||||
{roundingAdj !== 0 && (
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Rounding</span>
|
||||
<span>{roundingAdj > 0 ? '+' : ''}{roundingAdj.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentMethod === 'cash' && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>Tendered</span>
|
||||
<span>${parseFloat(result.amountTendered ?? '0').toFixed(2)}</span>
|
||||
</div>
|
||||
{changeGiven > 0 && (
|
||||
<div className="flex justify-between text-lg font-bold text-green-600">
|
||||
<span>Change Due</span>
|
||||
<span>${changeGiven.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full h-12 text-base" onClick={handleDone}>
|
||||
New Sale
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{paymentMethod === 'cash' ? 'Cash Payment' : paymentMethod === 'check' ? 'Check Payment' : 'Card Payment'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total Due</span>
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{paymentMethod === 'cash' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Amount Tendered</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={amountTendered}
|
||||
onChange={(e) => setAmountTendered(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-12 text-xl text-right font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.map((amt) => (
|
||||
<Button
|
||||
key={amt}
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => setAmountTendered(String(amt))}
|
||||
>
|
||||
${amt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={() => setAmountTendered(total.toFixed(2))}
|
||||
>
|
||||
Exact ${total.toFixed(2)}
|
||||
</Button>
|
||||
{tenderedAmount >= total && (
|
||||
<div className="flex justify-between text-lg font-bold text-green-600">
|
||||
<span>Change</span>
|
||||
<span>${changeDue.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'check' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Check Number</Label>
|
||||
<Input
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
placeholder="Check #"
|
||||
className="h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'card_present' && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Process card payment on terminal, then confirm below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full h-12 text-base"
|
||||
disabled={!canComplete || completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate()}
|
||||
>
|
||||
{completeMutation.isPending ? 'Processing...' : `Complete ${paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'check' ? 'Check' : 'Card'} Sale`}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user