Add repairs domain with tickets, line items, batches, and service templates
Full-stack implementation of instrument repair tracking: DB schema with repair_ticket, repair_line_item, repair_batch, and repair_service_template tables. Backend services and routes with pagination/search/sort. 20 API tests covering CRUD, status workflow, line items, and batch operations. Admin frontend with ticket list, detail with status progression, line item management, batch list/detail with approval workflow, and new ticket form with searchable account picker and intake photo uploads.
This commit is contained in:
284
packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx
Normal file
284
packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
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 { usePagination } from '@/hooks/use-pagination'
|
||||
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 { 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 { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { RepairLineItem } from '@/types/repair'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/repairs/$ticketId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||
}),
|
||||
component: RepairTicketDetailPage,
|
||||
})
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
intake: 'Intake',
|
||||
diagnosing: 'Diagnosing',
|
||||
pending_approval: 'Pending Approval',
|
||||
approved: 'Approved',
|
||||
in_progress: 'In Progress',
|
||||
pending_parts: 'Pending Parts',
|
||||
ready: 'Ready for Pickup',
|
||||
picked_up: 'Picked Up',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
const STATUS_FLOW = ['intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
|
||||
|
||||
function RepairTicketDetailPage() {
|
||||
const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' })
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const [addItemOpen, setAddItemOpen] = useState(false)
|
||||
const { params, setPage, setSort } = usePagination()
|
||||
|
||||
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
|
||||
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId) })
|
||||
toast.success('Status updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteItemMutation = useMutation({
|
||||
mutationFn: repairLineItemMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
|
||||
toast.success('Line item removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
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>
|
||||
}
|
||||
|
||||
const currentIdx = STATUS_FLOW.indexOf(ticket.status)
|
||||
const nextStatus = currentIdx >= 0 && currentIdx < STATUS_FLOW.length - 1 ? STATUS_FLOW[currentIdx + 1] : null
|
||||
|
||||
const lineItemColumns: Column<RepairLineItem>[] = [
|
||||
{
|
||||
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') ? (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteItemMutation.mutate(i.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<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" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<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 Actions */}
|
||||
{hasPermission('repairs.edit') && ticket.status !== 'cancelled' && ticket.status !== 'picked_up' && ticket.status !== 'delivered' && (
|
||||
<div className="flex gap-2">
|
||||
{nextStatus && (
|
||||
<Button onClick={() => statusMutation.mutate(nextStatus)} disabled={statusMutation.isPending}>
|
||||
Move to {STATUS_LABELS[nextStatus]}
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'in_progress' && (
|
||||
<Button variant="secondary" onClick={() => statusMutation.mutate('pending_parts')} disabled={statusMutation.isPending}>
|
||||
Pending Parts
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('repairs.admin') && (
|
||||
<Button variant="destructive" onClick={() => statusMutation.mutate('cancelled')} disabled={statusMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Line Items</CardTitle>
|
||||
{hasPermission('repairs.edit') && (
|
||||
<AddLineItemDialog ticketId={ticketId} open={addItemOpen} onOpenChange={setAddItemOpen} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
columns={lineItemColumns}
|
||||
data={lineItemsData?.data ?? []}
|
||||
loading={itemsLoading}
|
||||
page={params.page}
|
||||
totalPages={lineItemsData?.pagination.totalPages ?? 1}
|
||||
total={lineItemsData?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [itemType, setItemType] = useState('labor')
|
||||
const [description, setDescription] = useState('')
|
||||
const [qty, setQty] = useState('1')
|
||||
const [unitPrice, setUnitPrice] = useState('0')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
|
||||
toast.success('Line item added')
|
||||
onOpenChange(false)
|
||||
setDescription('')
|
||||
setQty('1')
|
||||
setUnitPrice('0')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={itemType} onValueChange={setItemType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="labor">Labor</SelectItem>
|
||||
<SelectItem value="part">Part</SelectItem>
|
||||
<SelectItem value="flat_rate">Flat Rate</SelectItem>
|
||||
<SelectItem value="misc">Misc</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Qty</Label>
|
||||
<Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Unit Price</Label>
|
||||
<Input type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user