- Add thermal/full-page receipt format toggle (per-device, localStorage) - Full-page receipt uses clean invoice layout matching repair PDF style - Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced) - Manager override system: configurable PIN prompt for void, refund, discount, cash in/out - Discount threshold setting: require manager approval above X% - Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals - Repair line item dialog: product picker dropdown for parts/consumables from inventory - Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods) - Transaction completion auto-updates repair ticket status to picked_up - POS Repairs dialog with Pickup and New Intake tabs, customer account lookup - Inline price adjustment on cart items: % off, $ off, or set price with live preview - Order-level discount button with same three input modes - Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint - Fix: backend dev script uses --env-file for turbo compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
286 lines
10 KiB
TypeScript
286 lines
10 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, ClipboardList } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { POSTransactionsDialog } from './pos-transactions-dialog'
|
|
import { POSRepairDialog } from './pos-repair-dialog'
|
|
|
|
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 [txnDialogOpen, setTxnDialogOpen] = useState(false)
|
|
const [repairOpen, setRepairOpen] = 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"
|
|
onClick={() => setRepairOpen(true)}
|
|
>
|
|
<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>
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1 h-11 text-sm gap-2"
|
|
onClick={() => setTxnDialogOpen(true)}
|
|
>
|
|
<ClipboardList className="h-4 w-4" />
|
|
Orders
|
|
</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>
|
|
|
|
{/* Transactions dialog */}
|
|
<POSTransactionsDialog open={txnDialogOpen} onOpenChange={setTxnDialogOpen} />
|
|
<POSRepairDialog open={repairOpen} onOpenChange={setRepairOpen} />
|
|
</div>
|
|
)
|
|
}
|