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:
Ryan Moon
2026-03-29 09:12:40 -05:00
parent 1d48f0befa
commit f17bbff02c
20 changed files with 2791 additions and 1 deletions

View File

@@ -0,0 +1,153 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { repairTicketListOptions } 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 { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Plus, Search } from 'lucide-react'
import { useAuthStore } from '@/stores/auth.store'
import type { RepairTicket } from '@/types/repair'
export const Route = createFileRoute('/_authenticated/repairs/')({
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') || 'desc',
}),
component: RepairsListPage,
})
function statusBadge(status: string) {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
intake: 'outline',
diagnosing: 'secondary',
pending_approval: 'secondary',
approved: 'default',
in_progress: 'default',
pending_parts: 'secondary',
ready: 'default',
picked_up: 'outline',
delivered: 'outline',
cancelled: 'destructive',
}
const labels: Record<string, string> = {
intake: 'Intake',
diagnosing: 'Diagnosing',
pending_approval: 'Pending Approval',
approved: 'Approved',
in_progress: 'In Progress',
pending_parts: 'Pending Parts',
ready: 'Ready',
picked_up: 'Picked Up',
delivered: 'Delivered',
cancelled: 'Cancelled',
}
return <Badge variant={variants[status] ?? 'outline'}>{labels[status] ?? status}</Badge>
}
const columns: Column<RepairTicket>[] = [
{
key: 'ticket_number',
header: 'Ticket #',
sortable: true,
render: (t) => <span className="font-mono text-sm">{t.ticketNumber ?? '-'}</span>,
},
{
key: 'customer_name',
header: 'Customer',
sortable: true,
render: (t) => <span className="font-medium">{t.customerName}</span>,
},
{
key: 'instrument',
header: 'Instrument',
render: (t) => <>{t.instrumentDescription ?? '-'}</>,
},
{
key: 'status',
header: 'Status',
sortable: true,
render: (t) => statusBadge(t.status),
},
{
key: 'intake_date',
header: 'Intake',
sortable: true,
render: (t) => <>{new Date(t.intakeDate).toLocaleDateString()}</>,
},
{
key: 'promised_date',
header: 'Promised',
sortable: true,
render: (t) => <>{t.promisedDate ? new Date(t.promisedDate).toLocaleDateString() : '-'}</>,
},
{
key: 'estimated_cost',
header: 'Estimate',
render: (t) => <>{t.estimatedCost ? `$${t.estimatedCost}` : '-'}</>,
},
]
function RepairsListPage() {
const navigate = useNavigate()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const { data, isLoading } = useQuery(repairTicketListOptions(params))
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
function handleRowClick(ticket: RepairTicket) {
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Repairs</h1>
{hasPermission('repairs.edit') && (
<Button onClick={() => navigate({ to: '/repairs/new' })}>
<Plus className="mr-2 h-4 w-4" />
New Repair
</Button>
)}
</div>
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search repairs..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<DataTable
columns={columns}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
onRowClick={handleRowClick}
/>
</div>
)
}