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:
153
packages/admin/src/routes/_authenticated/repairs/index.tsx
Normal file
153
packages/admin/src/routes/_authenticated/repairs/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user