Files
lunarfront-app/packages/admin/src/components/pos/pos-item-panel.tsx
ryan 31d80b5b9d feat: customer lookup from POS with order history and item search
- Customer dialog in cart panel: search accounts by name, phone, email, account #
- Selected customer shown with name, phone, email in cart header
- accountId passed when creating transactions
- Order history view: tap a transaction to expand and see line items
- Item search in history (e.g. "strings") — filters orders containing that item
- Backend: add accountId and itemSearch filters to transaction list endpoint
- itemSearch uses EXISTS subquery on line item descriptions (ILIKE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:07:24 +00:00

270 lines
9.5 KiB
TypeScript

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: _transaction }: POSItemPanelProps) {
const queryClient = useQueryClient()
const { currentTransactionId, setTransaction, locationId, accountId } = 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,
accountId: accountId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
productId: product.id,
description: product.name,
qty: 1,
unitPrice: parseFloat(product.price ?? '0'),
})
},
onSuccess: () => {
const txnId = usePOSStore.getState().currentTransactionId
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
},
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,
accountId: accountId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
description: customDesc,
qty: parseInt(customQty) || 1,
unitPrice: parseFloat(customPrice) || 0,
})
},
onSuccess: () => {
const txnId = usePOSStore.getState().currentTransactionId
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
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,
accountId: accountId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
productId: product.id,
description: product.name,
qty: 1,
unitPrice: parseFloat(product.price ?? '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.price ?? '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>
)
}