Files
lunarfront-app/packages/admin/src/components/pos/pos-repair-dialog.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

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