- 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>
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { usePOSStore } from '@/stores/pos.store'
|
|
import { api } from '@/lib/api-client'
|
|
import { posMutations, posKeys } from '@/api/pos'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Search, Wrench, Plus } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
interface Account {
|
|
id: string
|
|
name: string
|
|
email: string | null
|
|
phone: string | null
|
|
accountNumber: string | null
|
|
}
|
|
|
|
interface RepairTicketSummary {
|
|
id: string
|
|
ticketNumber: string | null
|
|
customerName: string
|
|
customerPhone: string | null
|
|
itemDescription: string | null
|
|
estimatedCost: string | null
|
|
status: string
|
|
}
|
|
|
|
interface POSRepairDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
}
|
|
|
|
export function POSRepairDialog({ open, onOpenChange }: POSRepairDialogProps) {
|
|
const queryClient = useQueryClient()
|
|
const { locationId, setTransaction, setAccount } = usePOSStore()
|
|
const [search, setSearch] = useState('')
|
|
const [tab, setTab] = useState('pickup')
|
|
|
|
// --- Pickup tab ---
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['pos', 'repair-tickets-ready', search],
|
|
queryFn: () => api.get<{ data: RepairTicketSummary[] }>('/v1/repair-tickets/ready', { q: search || undefined, limit: 20 }),
|
|
enabled: open && tab === 'pickup',
|
|
})
|
|
const tickets = data?.data ?? []
|
|
|
|
const pickupMutation = useMutation({
|
|
mutationFn: (ticketId: string) => posMutations.createFromRepair(ticketId, locationId ?? undefined),
|
|
onSuccess: (txn) => {
|
|
setTransaction(txn.id)
|
|
if (txn.accountId) {
|
|
const ticket = tickets.find((t) => t.id === pickupMutation.variables)
|
|
if (ticket) setAccount(txn.accountId, ticket.customerName, ticket.customerPhone)
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txn.id) })
|
|
toast.success(`Repair payment loaded — ${txn.transactionNumber}`)
|
|
close()
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
// --- New intake tab ---
|
|
const [customerName, setCustomerName] = useState('')
|
|
const [customerPhone, setCustomerPhone] = useState('')
|
|
const [itemDescription, setItemDescription] = useState('')
|
|
const [problemDescription, setProblemDescription] = useState('')
|
|
const [estimatedCost, setEstimatedCost] = useState('')
|
|
const [accountId, setAccountId] = useState<string | null>(null)
|
|
const [customerSearch, setCustomerSearch] = useState('')
|
|
const [showCustomers, setShowCustomers] = useState(false)
|
|
|
|
const { data: customerData } = useQuery({
|
|
queryKey: ['pos', 'accounts', customerSearch],
|
|
queryFn: () => api.get<{ data: Account[] }>('/v1/accounts', { q: customerSearch, limit: 10 }),
|
|
enabled: customerSearch.length >= 2 && tab === 'intake',
|
|
})
|
|
const customerResults = customerData?.data ?? []
|
|
|
|
function selectCustomer(account: Account) {
|
|
setAccountId(account.id)
|
|
setCustomerName(account.name)
|
|
setCustomerPhone(account.phone ?? '')
|
|
setCustomerSearch('')
|
|
setShowCustomers(false)
|
|
}
|
|
|
|
function clearCustomer() {
|
|
setAccountId(null)
|
|
setCustomerName('')
|
|
setCustomerPhone('')
|
|
}
|
|
|
|
const intakeMutation = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) =>
|
|
api.post<{ id: string; ticketNumber: string }>('/v1/repair-tickets', data),
|
|
onSuccess: (ticket) => {
|
|
toast.success(`Repair ticket #${ticket.ticketNumber} created`)
|
|
close()
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
function handleIntakeSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
intakeMutation.mutate({
|
|
customerName,
|
|
customerPhone: customerPhone || undefined,
|
|
accountId: accountId ?? undefined,
|
|
itemDescription: itemDescription || undefined,
|
|
problemDescription,
|
|
estimatedCost: estimatedCost ? parseFloat(estimatedCost) : undefined,
|
|
locationId: locationId ?? undefined,
|
|
})
|
|
}
|
|
|
|
function close() {
|
|
onOpenChange(false)
|
|
setSearch('')
|
|
setCustomerName('')
|
|
setCustomerPhone('')
|
|
setItemDescription('')
|
|
setProblemDescription('')
|
|
setEstimatedCost('')
|
|
setAccountId(null)
|
|
setCustomerSearch('')
|
|
setShowCustomers(false)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) close(); else onOpenChange(true) }}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Wrench className="h-5 w-5" />Repairs
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Tabs value={tab} onValueChange={setTab}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="pickup" className="flex-1">Pickup</TabsTrigger>
|
|
<TabsTrigger value="intake" className="flex-1">New Intake</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="pickup" className="mt-3 space-y-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by ticket #, name, or phone..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-9"
|
|
autoFocus={tab === 'pickup'}
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-64 overflow-y-auto space-y-1">
|
|
{isLoading ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">Loading...</p>
|
|
) : tickets.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
{search ? 'No ready tickets found' : 'No tickets ready for pickup'}
|
|
</p>
|
|
) : (
|
|
tickets.map((ticket) => (
|
|
<button
|
|
key={ticket.id}
|
|
type="button"
|
|
className="w-full text-left rounded-md border p-3 hover:bg-accent transition-colors"
|
|
onClick={() => pickupMutation.mutate(ticket.id)}
|
|
disabled={pickupMutation.isPending}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-medium text-sm">#{ticket.ticketNumber}</span>
|
|
<Badge variant="outline" className="text-xs">Ready</Badge>
|
|
</div>
|
|
<div className="text-sm mt-0.5">{ticket.customerName}</div>
|
|
{ticket.itemDescription && (
|
|
<div className="text-xs text-muted-foreground mt-0.5 truncate">{ticket.itemDescription}</div>
|
|
)}
|
|
{ticket.estimatedCost && (
|
|
<div className="text-xs text-muted-foreground mt-0.5">Est: ${ticket.estimatedCost}</div>
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="intake" className="mt-3">
|
|
<form onSubmit={handleIntakeSubmit} className="space-y-3">
|
|
{/* Customer lookup */}
|
|
<div className="relative space-y-1">
|
|
<Label className="text-xs">Customer Lookup</Label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by name, phone, or email..."
|
|
value={customerSearch}
|
|
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomers(true) }}
|
|
onFocus={() => customerSearch.length >= 2 && setShowCustomers(true)}
|
|
className="pl-9 h-8 text-sm"
|
|
/>
|
|
</div>
|
|
{accountId && (
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Badge variant="secondary" className="text-[10px]">Linked</Badge>
|
|
<span>{customerName}</span>
|
|
<button type="button" className="underline text-destructive ml-1" onClick={clearCustomer}>clear</button>
|
|
</div>
|
|
)}
|
|
{showCustomers && customerSearch.length >= 2 && (
|
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-40 overflow-auto">
|
|
{customerResults.length === 0 ? (
|
|
<div className="p-2 text-xs text-muted-foreground">No accounts found</div>
|
|
) : customerResults.map((a) => (
|
|
<button key={a.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent" onClick={() => selectCustomer(a)}>
|
|
<div className="font-medium">{a.name}</div>
|
|
<div className="text-xs text-muted-foreground">{[a.phone, a.email].filter(Boolean).join(' · ')}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Customer Name *</Label>
|
|
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} required />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Phone</Label>
|
|
<Input value={customerPhone} onChange={(e) => setCustomerPhone(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Item Description</Label>
|
|
<Input value={itemDescription} onChange={(e) => setItemDescription(e.target.value)} placeholder="e.g. Violin, iPhone 12, Trek bicycle" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Problem *</Label>
|
|
<Textarea value={problemDescription} onChange={(e) => setProblemDescription(e.target.value)} rows={2} placeholder="What needs to be fixed?" required />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Estimated Cost</Label>
|
|
<Input type="number" step="0.01" min="0" value={estimatedCost} onChange={(e) => setEstimatedCost(e.target.value)} placeholder="0.00" />
|
|
</div>
|
|
<Button type="submit" className="w-full gap-2" disabled={intakeMutation.isPending}>
|
|
<Plus className="h-4 w-4" />
|
|
{intakeMutation.isPending ? 'Creating...' : 'Create Repair Ticket'}
|
|
</Button>
|
|
</form>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|