Add repair ticket detail improvements and intake estimate builder

Status progress bar component with visual step indicator, in_transit
status for instruments being transported to shop. Ticket detail page
reworked with inline edit form, reopen for cancelled tickets, photos
grouped by repair phase (intake/in_progress/completed). Intake form
now supports building estimates with template picker and manual line
items that carry over to the ticket. Service template API client and
types added for template search in line item dialogs.
This commit is contained in:
Ryan Moon
2026-03-29 09:56:28 -05:00
parent f17bbff02c
commit 7d55fbe7ef
10 changed files with 839 additions and 127 deletions

View File

@@ -1,18 +1,25 @@
import { useState } from 'react'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { repairTicketDetailOptions, repairTicketMutations, repairTicketKeys, repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys } from '@/api/repairs'
import {
repairTicketDetailOptions, repairTicketMutations, repairTicketKeys,
repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys,
repairServiceTemplateListOptions,
} from '@/api/repairs'
import { usePagination } from '@/hooks/use-pagination'
import { StatusProgress } from '@/components/repairs/status-progress'
import { TicketPhotos } from '@/components/repairs/ticket-photos'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, RotateCcw, Save } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { RepairLineItem } from '@/types/repair'
@@ -29,6 +36,7 @@ export const Route = createFileRoute('/_authenticated/repairs/$ticketId')({
})
const STATUS_LABELS: Record<string, string> = {
in_transit: 'In Transit',
intake: 'Intake',
diagnosing: 'Diagnosing',
pending_approval: 'Pending Approval',
@@ -41,7 +49,7 @@ const STATUS_LABELS: Record<string, string> = {
cancelled: 'Cancelled',
}
const STATUS_FLOW = ['intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
const STATUS_FLOW = ['in_transit', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
function RepairTicketDetailPage() {
const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' })
@@ -49,11 +57,15 @@ function RepairTicketDetailPage() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const [addItemOpen, setAddItemOpen] = useState(false)
const [editing, setEditing] = useState(false)
const { params, setPage, setSort } = usePagination()
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
// Edit form state
const [editFields, setEditFields] = useState<Record<string, string>>({})
const statusMutation = useMutation({
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
onSuccess: () => {
@@ -63,6 +75,16 @@ function RepairTicketDetailPage() {
onError: (err) => toast.error(err.message),
})
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairTicketMutations.update(ticketId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId) })
toast.success('Ticket updated')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
const deleteItemMutation = useMutation({
mutationFn: repairLineItemMutations.delete,
onSuccess: () => {
@@ -73,36 +95,67 @@ function RepairTicketDetailPage() {
})
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
return <div className="space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-64 w-full" /></div>
}
if (!ticket) {
return <p className="text-muted-foreground">Repair ticket not found</p>
}
function startEdit() {
setEditFields({
customerName: ticket!.customerName,
customerPhone: ticket!.customerPhone ?? '',
instrumentDescription: ticket!.instrumentDescription ?? '',
serialNumber: ticket!.serialNumber ?? '',
conditionIn: ticket!.conditionIn ?? '',
conditionInNotes: ticket!.conditionInNotes ?? '',
problemDescription: ticket!.problemDescription,
technicianNotes: ticket!.technicianNotes ?? '',
estimatedCost: ticket!.estimatedCost ?? '',
actualCost: ticket!.actualCost ?? '',
})
setEditing(true)
}
function saveEdit() {
const data: Record<string, unknown> = {}
if (editFields.customerName !== ticket!.customerName) data.customerName = editFields.customerName
if (editFields.customerPhone !== (ticket!.customerPhone ?? '')) data.customerPhone = editFields.customerPhone || undefined
if (editFields.instrumentDescription !== (ticket!.instrumentDescription ?? '')) data.instrumentDescription = editFields.instrumentDescription || undefined
if (editFields.serialNumber !== (ticket!.serialNumber ?? '')) data.serialNumber = editFields.serialNumber || undefined
if (editFields.conditionIn !== (ticket!.conditionIn ?? '')) data.conditionIn = editFields.conditionIn || undefined
if (editFields.conditionInNotes !== (ticket!.conditionInNotes ?? '')) data.conditionInNotes = editFields.conditionInNotes || undefined
if (editFields.problemDescription !== ticket!.problemDescription) data.problemDescription = editFields.problemDescription
if (editFields.technicianNotes !== (ticket!.technicianNotes ?? '')) data.technicianNotes = editFields.technicianNotes || undefined
if (editFields.estimatedCost !== (ticket!.estimatedCost ?? '')) data.estimatedCost = editFields.estimatedCost ? parseFloat(editFields.estimatedCost) : undefined
if (editFields.actualCost !== (ticket!.actualCost ?? '')) data.actualCost = editFields.actualCost ? parseFloat(editFields.actualCost) : undefined
if (Object.keys(data).length === 0) {
setEditing(false)
return
}
updateMutation.mutate(data)
}
const currentIdx = STATUS_FLOW.indexOf(ticket.status)
const nextStatus = currentIdx >= 0 && currentIdx < STATUS_FLOW.length - 1 ? STATUS_FLOW[currentIdx + 1] : null
const isTerminal = ['picked_up', 'delivered', 'cancelled'].includes(ticket.status)
function handleStatusClick(status: string) {
if (hasPermission('repairs.edit') && ticket && status !== ticket.status) {
statusMutation.mutate(status)
}
}
const lineItemColumns: Column<RepairLineItem>[] = [
{
key: 'item_type',
header: 'Type',
sortable: true,
render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge>,
},
{ key: 'item_type', header: 'Type', sortable: true, render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge> },
{ key: 'description', header: 'Description', render: (i) => <>{i.description}</> },
{ key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> },
{ key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice}</> },
{ key: 'total_price', header: 'Total', render: (i) => <span className="font-medium">${i.totalPrice}</span> },
{
key: 'actions',
header: '',
render: (i) => hasPermission('repairs.admin') ? (
key: 'actions', header: '', render: (i) => hasPermission('repairs.admin') ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteItemMutation.mutate(i.id) }}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
@@ -112,6 +165,7 @@ function RepairTicketDetailPage() {
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
<ArrowLeft className="h-4 w-4" />
@@ -120,13 +174,20 @@ function RepairTicketDetailPage() {
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
<p className="text-sm text-muted-foreground">{ticket.customerName}</p>
</div>
<Badge variant={ticket.status === 'cancelled' ? 'destructive' : 'default'} className="text-sm px-3 py-1">
{STATUS_LABELS[ticket.status]}
</Badge>
</div>
{/* Status Progress Bar */}
<Card>
<CardContent className="pt-6">
<StatusProgress
currentStatus={ticket.status}
onStatusClick={hasPermission('repairs.edit') && !isTerminal ? handleStatusClick : undefined}
/>
</CardContent>
</Card>
{/* Status Actions */}
{hasPermission('repairs.edit') && ticket.status !== 'cancelled' && ticket.status !== 'picked_up' && ticket.status !== 'delivered' && (
{hasPermission('repairs.edit') && !isTerminal && (
<div className="flex gap-2">
{nextStatus && (
<Button onClick={() => statusMutation.mutate(nextStatus)} disabled={statusMutation.isPending}>
@@ -138,6 +199,11 @@ function RepairTicketDetailPage() {
Pending Parts
</Button>
)}
{ticket.status === 'pending_parts' && (
<Button variant="secondary" onClick={() => statusMutation.mutate('in_progress')} disabled={statusMutation.isPending}>
Parts Received
</Button>
)}
{hasPermission('repairs.admin') && (
<Button variant="destructive" onClick={() => statusMutation.mutate('cancelled')} disabled={statusMutation.isPending}>
Cancel
@@ -146,37 +212,125 @@ function RepairTicketDetailPage() {
</div>
)}
{/* Ticket Details */}
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="text-lg">Customer</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Name:</span> {ticket.customerName}</div>
<div><span className="text-muted-foreground">Phone:</span> {ticket.customerPhone ?? '-'}</div>
<div><span className="text-muted-foreground">Account:</span> {ticket.accountId ?? 'Walk-in'}</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Instrument</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Description:</span> {ticket.instrumentDescription ?? '-'}</div>
<div><span className="text-muted-foreground">Serial:</span> {ticket.serialNumber ?? '-'}</div>
<div><span className="text-muted-foreground">Condition:</span> {ticket.conditionIn ?? '-'}</div>
</CardContent>
</Card>
</div>
{/* Reopen for cancelled tickets */}
{ticket.status === 'cancelled' && hasPermission('repairs.admin') && (
<Button variant="outline" onClick={() => statusMutation.mutate('intake')} disabled={statusMutation.isPending}>
<RotateCcw className="mr-2 h-4 w-4" />
Reopen Ticket
</Button>
)}
{/* Ticket Details — View or Edit */}
<Card>
<CardHeader><CardTitle className="text-lg">Problem & Notes</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Problem:</span> {ticket.problemDescription}</div>
{ticket.conditionInNotes && <div><span className="text-muted-foreground">Condition Notes:</span> {ticket.conditionInNotes}</div>}
{ticket.technicianNotes && <div><span className="text-muted-foreground">Tech Notes:</span> {ticket.technicianNotes}</div>}
<div className="flex gap-6 pt-2">
<div><span className="text-muted-foreground">Estimate:</span> {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}</div>
<div><span className="text-muted-foreground">Actual:</span> {ticket.actualCost ? `$${ticket.actualCost}` : '-'}</div>
<div><span className="text-muted-foreground">Promised:</span> {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}</div>
</div>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Details</CardTitle>
{hasPermission('repairs.edit') && !editing && (
<Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>
)}
{editing && (
<div className="flex gap-2">
<Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>
<Save className="mr-1 h-3 w-3" />{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div>
)}
</CardHeader>
<CardContent>
{editing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Customer Name</Label>
<Input value={editFields.customerName} onChange={(e) => setEditFields((p) => ({ ...p, customerName: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={editFields.customerPhone} onChange={(e) => setEditFields((p) => ({ ...p, customerPhone: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Instrument</Label>
<Input value={editFields.instrumentDescription} onChange={(e) => setEditFields((p) => ({ ...p, instrumentDescription: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Serial Number</Label>
<Input value={editFields.serialNumber} onChange={(e) => setEditFields((p) => ({ ...p, serialNumber: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Condition</Label>
<Select value={editFields.conditionIn} onValueChange={(v) => setEditFields((p) => ({ ...p, conditionIn: v }))}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
<SelectItem value="excellent">Excellent</SelectItem>
<SelectItem value="good">Good</SelectItem>
<SelectItem value="fair">Fair</SelectItem>
<SelectItem value="poor">Poor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Estimated Cost</Label>
<Input type="number" step="0.01" value={editFields.estimatedCost} onChange={(e) => setEditFields((p) => ({ ...p, estimatedCost: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Actual Cost</Label>
<Input type="number" step="0.01" value={editFields.actualCost} onChange={(e) => setEditFields((p) => ({ ...p, actualCost: e.target.value }))} />
</div>
</div>
<div className="space-y-2">
<Label>Condition Notes</Label>
<Textarea value={editFields.conditionInNotes} onChange={(e) => setEditFields((p) => ({ ...p, conditionInNotes: e.target.value }))} rows={2} />
</div>
<div className="space-y-2">
<Label>Problem Description</Label>
<Textarea value={editFields.problemDescription} onChange={(e) => setEditFields((p) => ({ ...p, problemDescription: e.target.value }))} rows={3} />
</div>
<div className="space-y-2">
<Label>Technician Notes</Label>
<Textarea value={editFields.technicianNotes} onChange={(e) => setEditFields((p) => ({ ...p, technicianNotes: e.target.value }))} rows={3} />
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Customer:</span> {ticket.customerName}</div>
<div><span className="text-muted-foreground">Phone:</span> {ticket.customerPhone ?? '-'}</div>
<div><span className="text-muted-foreground">Account:</span> {ticket.accountId ?? 'Walk-in'}</div>
</div>
<div className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Instrument:</span> {ticket.instrumentDescription ?? '-'}</div>
<div><span className="text-muted-foreground">Serial:</span> {ticket.serialNumber ?? '-'}</div>
<div><span className="text-muted-foreground">Condition:</span> {ticket.conditionIn ?? '-'}</div>
</div>
</div>
<div className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Problem:</span> {ticket.problemDescription}</div>
{ticket.conditionInNotes && <div><span className="text-muted-foreground">Condition Notes:</span> {ticket.conditionInNotes}</div>}
{ticket.technicianNotes && <div><span className="text-muted-foreground">Tech Notes:</span> {ticket.technicianNotes}</div>}
</div>
<div className="flex gap-6 text-sm">
<div><span className="text-muted-foreground">Estimate:</span> {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}</div>
<div><span className="text-muted-foreground">Actual:</span> {ticket.actualCost ? `$${ticket.actualCost}` : '-'}</div>
<div><span className="text-muted-foreground">Promised:</span> {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}</div>
<div><span className="text-muted-foreground">Intake:</span> {new Date(ticket.intakeDate).toLocaleDateString()}</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Photos by Phase */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Photos</CardTitle>
</CardHeader>
<CardContent>
<TicketPhotos ticketId={ticketId} currentStatus={ticket.status} />
</CardContent>
</Card>
@@ -209,10 +363,17 @@ function RepairTicketDetailPage() {
function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const queryClient = useQueryClient()
const [templateSearch, setTemplateSearch] = useState('')
const [showTemplates, setShowTemplates] = useState(false)
const [itemType, setItemType] = useState('labor')
const [description, setDescription] = useState('')
const [qty, setQty] = useState('1')
const [unitPrice, setUnitPrice] = useState('0')
const [cost, setCost] = useState('')
const { data: templatesData } = useQuery(
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
)
const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data),
@@ -220,33 +381,86 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
toast.success('Line item added')
onOpenChange(false)
setDescription('')
setQty('1')
setUnitPrice('0')
resetForm()
},
onError: (err) => toast.error(err.message),
})
function resetForm() {
setDescription('')
setQty('1')
setUnitPrice('0')
setCost('')
setItemType('labor')
setTemplateSearch('')
setShowTemplates(false)
}
function selectTemplate(template: { name: string; instrumentType: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
const desc = [template.name, template.instrumentType, template.size].filter(Boolean).join(' — ')
setDescription(desc)
setItemType(template.itemType)
setUnitPrice(template.defaultPrice)
setCost(template.defaultCost ?? '')
setShowTemplates(false)
setTemplateSearch('')
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const q = parseFloat(qty) || 1
const up = parseFloat(unitPrice) || 0
mutation.mutate({
itemType,
description,
qty: q,
unitPrice: up,
totalPrice: q * up,
})
const c = cost ? parseFloat(cost) : undefined
mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c })
}
const templates = templatesData?.data ?? []
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Item</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Line Item</DialogTitle></DialogHeader>
{/* Template picker */}
<div className="relative">
<Label>Quick Add from Template</Label>
<Input
placeholder="Search templates (e.g. rehair, valve)..."
value={templateSearch}
onChange={(e) => { setTemplateSearch(e.target.value); setShowTemplates(true) }}
onFocus={() => templateSearch && setShowTemplates(true)}
className="mt-1"
/>
{showTemplates && templateSearch.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
{templates.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No templates found</div>
) : (
templates.map((t) => (
<button
key={t.id}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between"
onClick={() => selectTemplate(t)}
>
<span>{t.name}{t.instrumentType ? `${t.instrumentType}` : ''}{t.size ? ` ${t.size}` : ''}</span>
<span className="text-muted-foreground">${t.defaultPrice}</span>
</button>
))
)}
</div>
)}
</div>
<div className="relative flex items-center gap-2 py-1">
<div className="flex-1 border-t" />
<span className="text-xs text-muted-foreground">or fill manually</span>
<div className="flex-1 border-t" />
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Type</Label>
@@ -264,7 +478,7 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
<Label>Description *</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Qty</Label>
<Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} />
@@ -273,6 +487,10 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
<Label>Unit Price</Label>
<Input type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Cost (internal)</Label>
<Input type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} placeholder="Optional" />
</div>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add'}