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