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:
264
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
264
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { productSearchOptions, posMutations, posKeys, type Transaction, type Product } from '@/api/pos'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Search, ScanBarcode, Wrench, PenLine } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface POSItemPanelProps {
|
||||
transaction: Transaction | null
|
||||
}
|
||||
|
||||
export function POSItemPanel({ transaction }: POSItemPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentTransactionId, setTransaction, locationId } = usePOSStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [customDesc, setCustomDesc] = useState('')
|
||||
const [customPrice, setCustomPrice] = useState('')
|
||||
const [customQty, setCustomQty] = useState('1')
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Debounced product search
|
||||
const { data: productsData, isLoading: searchLoading } = useQuery({
|
||||
...productSearchOptions(search),
|
||||
enabled: search.length >= 1,
|
||||
})
|
||||
const products = productsData?.data ?? []
|
||||
|
||||
// Add line item mutation
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: async (product: Product) => {
|
||||
let txnId = currentTransactionId
|
||||
// Auto-create transaction if none exists
|
||||
if (!txnId) {
|
||||
const txn = await posMutations.createTransaction({
|
||||
transactionType: 'sale',
|
||||
locationId: locationId ?? undefined,
|
||||
})
|
||||
txnId = txn.id
|
||||
setTransaction(txnId)
|
||||
}
|
||||
return posMutations.addLineItem(txnId, {
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
qty: 1,
|
||||
unitPrice: parseFloat(product.sellingPrice ?? '0'),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Custom item mutation
|
||||
const addCustomMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let txnId = currentTransactionId
|
||||
if (!txnId) {
|
||||
const txn = await posMutations.createTransaction({
|
||||
transactionType: 'sale',
|
||||
locationId: locationId ?? undefined,
|
||||
})
|
||||
txnId = txn.id
|
||||
setTransaction(txnId)
|
||||
}
|
||||
return posMutations.addLineItem(txnId, {
|
||||
description: customDesc,
|
||||
qty: parseInt(customQty) || 1,
|
||||
unitPrice: parseFloat(customPrice) || 0,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
|
||||
setCustomOpen(false)
|
||||
setCustomDesc('')
|
||||
setCustomPrice('')
|
||||
setCustomQty('1')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// UPC scan
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: async (upc: string) => {
|
||||
const product = await posMutations.lookupUpc(upc)
|
||||
let txnId = currentTransactionId
|
||||
if (!txnId) {
|
||||
const txn = await posMutations.createTransaction({
|
||||
transactionType: 'sale',
|
||||
locationId: locationId ?? undefined,
|
||||
})
|
||||
txnId = txn.id
|
||||
setTransaction(txnId)
|
||||
}
|
||||
return posMutations.addLineItem(txnId, {
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
qty: 1,
|
||||
unitPrice: parseFloat(product.sellingPrice ?? '0'),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
|
||||
setSearch('')
|
||||
toast.success('Item scanned')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Barcode scanners typically send Enter after the code
|
||||
if (e.key === 'Enter' && search.length >= 6) {
|
||||
// Looks like a UPC — try scanning
|
||||
scanMutation.mutate(search)
|
||||
}
|
||||
}, [search, scanMutation])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search bar */}
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search products or scan barcode..."
|
||||
className="pl-10 h-11 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product grid */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{searchLoading ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : products.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={() => addItemMutation.mutate(product)}
|
||||
disabled={addItemMutation.isPending}
|
||||
className="flex flex-col items-start p-3 rounded-lg border border-border bg-card hover:bg-accent active:bg-accent/80 transition-colors text-left min-h-[80px]"
|
||||
>
|
||||
<span className="font-medium text-sm line-clamp-2">{product.name}</span>
|
||||
<div className="mt-auto flex items-center justify-between w-full pt-1">
|
||||
<span className="text-base font-semibold">${parseFloat(product.sellingPrice ?? '0').toFixed(2)}</span>
|
||||
{product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>}
|
||||
</div>
|
||||
{product.isSerialized ? (
|
||||
<span className="text-[10px] text-muted-foreground">Serialized</span>
|
||||
) : product.qtyOnHand !== null ? (
|
||||
<span className="text-[10px] text-muted-foreground">{product.qtyOnHand} in stock</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : search.length >= 1 ? (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||
No products found for "{search}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||
Search for products to add to the sale
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick action buttons */}
|
||||
<div className="p-3 border-t border-border flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 text-sm gap-2"
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<ScanBarcode className="h-4 w-4" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 text-sm gap-2"
|
||||
disabled
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
Repairs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 text-sm gap-2"
|
||||
onClick={() => setCustomOpen(true)}
|
||||
>
|
||||
<PenLine className="h-4 w-4" />
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Custom item dialog */}
|
||||
<Dialog open={customOpen} onOpenChange={setCustomOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Custom Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addCustomMutation.mutate() }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input
|
||||
value={customDesc}
|
||||
onChange={(e) => setCustomDesc(e.target.value)}
|
||||
placeholder="Item description"
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Price *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={customPrice}
|
||||
onChange={(e) => setCustomPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Qty</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={customQty}
|
||||
onChange={(e) => setCustomQty(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={addCustomMutation.isPending}>
|
||||
{addCustomMutation.isPending ? 'Adding...' : 'Add Item'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user