Files
lunarfront-app/packages/admin/src/components/pos/pos-item-panel.tsx
ryan 95cf017b4b feat: repair-POS integration, receipt formats, manager overrides, price adjustments
- 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>
2026-04-05 16:05:19 +00:00

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>
)
}