Add repair list filters, template management page, and backend filter support
Repairs list now has a filter panel with status (defaults to active only), condition, batch/individual toggle, and date range filters for intake and promised dates. Added Batch column to the repairs table. Backend list endpoint accepts filter query params for status, condition, dates, and batch membership. Template management page (admin only) with CRUD for common repair services (rehair, string change, etc.) with instrument type, size, and default pricing. Sidebar updated with Repair Templates link gated on repairs.admin permission.
This commit is contained in:
@@ -4,12 +4,14 @@ 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 { RepairFilterPanel, DEFAULT_FILTERS, type RepairFilters } from '@/components/repairs/repair-filters'
|
||||
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'
|
||||
import type { PaginationInput } from '@forte/shared/schemas'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/repairs/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -24,6 +26,7 @@ export const Route = createFileRoute('/_authenticated/repairs/')({
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
in_transit: 'secondary',
|
||||
intake: 'outline',
|
||||
diagnosing: 'secondary',
|
||||
pending_approval: 'secondary',
|
||||
@@ -36,6 +39,7 @@ function statusBadge(status: string) {
|
||||
cancelled: 'destructive',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
in_transit: 'In Transit',
|
||||
intake: 'Intake',
|
||||
diagnosing: 'Diagnosing',
|
||||
pending_approval: 'Pending Approval',
|
||||
@@ -74,6 +78,11 @@ const columns: Column<RepairTicket>[] = [
|
||||
sortable: true,
|
||||
render: (t) => statusBadge(t.status),
|
||||
},
|
||||
{
|
||||
key: 'batch',
|
||||
header: 'Batch',
|
||||
render: (t) => t.repairBatchId ? <Badge variant="outline">Batch</Badge> : <span className="text-muted-foreground">-</span>,
|
||||
},
|
||||
{
|
||||
key: 'intake_date',
|
||||
header: 'Intake',
|
||||
@@ -98,8 +107,19 @@ function RepairsListPage() {
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [filters, setFilters] = useState<RepairFilters>(DEFAULT_FILTERS)
|
||||
|
||||
const { data, isLoading } = useQuery(repairTicketListOptions(params))
|
||||
// Build query params with filters
|
||||
const queryParams: Record<string, unknown> = { ...params }
|
||||
if (filters.status?.length) queryParams.status = filters.status.join(',')
|
||||
if (filters.conditionIn?.length) queryParams.conditionIn = filters.conditionIn.join(',')
|
||||
if (filters.isBatch !== undefined) queryParams.isBatch = String(filters.isBatch)
|
||||
if (filters.intakeDateFrom) queryParams.intakeDateFrom = filters.intakeDateFrom
|
||||
if (filters.intakeDateTo) queryParams.intakeDateTo = filters.intakeDateTo
|
||||
if (filters.promisedDateFrom) queryParams.promisedDateFrom = filters.promisedDateFrom
|
||||
if (filters.promisedDateTo) queryParams.promisedDateTo = filters.promisedDateTo
|
||||
|
||||
const { data, isLoading } = useQuery(repairTicketListOptions(queryParams as PaginationInput))
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -135,6 +155,8 @@ function RepairsListPage() {
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<RepairFilterPanel filters={filters} onChange={setFilters} />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
|
||||
216
packages/admin/src/routes/_authenticated/repairs/templates.tsx
Normal file
216
packages/admin/src/routes/_authenticated/repairs/templates.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { repairServiceTemplateListOptions, repairServiceTemplateMutations, repairServiceTemplateKeys } 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 { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { RepairServiceTemplate } from '@/types/repair'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/repairs/templates')({
|
||||
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: RepairTemplatesPage,
|
||||
})
|
||||
|
||||
const columns: Column<RepairServiceTemplate>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (t) => <span className="font-medium">{t.name}</span> },
|
||||
{ key: 'instrument_type', header: 'Instrument', sortable: true, render: (t) => <>{t.instrumentType ?? '-'}</> },
|
||||
{ key: 'size', header: 'Size', render: (t) => <>{t.size ?? '-'}</> },
|
||||
{ key: 'item_type', header: 'Type', render: (t) => <Badge variant="outline">{t.itemType.replace('_', ' ')}</Badge> },
|
||||
{ key: 'default_price', header: 'Price', sortable: true, render: (t) => <>${t.defaultPrice}</> },
|
||||
{ key: 'default_cost', header: 'Cost', render: (t) => <>{t.defaultCost ? `$${t.defaultCost}` : '-'}</> },
|
||||
{ key: 'sort_order', header: 'Order', sortable: true, render: (t) => <>{t.sortOrder}</> },
|
||||
]
|
||||
|
||||
function RepairTemplatesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(repairServiceTemplateListOptions(params))
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: repairServiceTemplateMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: repairServiceTemplateKeys.all })
|
||||
toast.success('Template removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<RepairServiceTemplate>[] = [
|
||||
...columns,
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (t) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Repair Templates</h1>
|
||||
<CreateTemplateDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</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 templates..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTemplateDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [instrumentType, setInstrumentType] = useState('')
|
||||
const [size, setSize] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [itemType, setItemType] = useState('flat_rate')
|
||||
const [defaultPrice, setDefaultPrice] = useState('0')
|
||||
const [defaultCost, setDefaultCost] = useState('')
|
||||
const [sortOrder, setSortOrder] = useState('0')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: repairServiceTemplateMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: repairServiceTemplateKeys.all })
|
||||
toast.success('Template created')
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
setName('')
|
||||
setInstrumentType('')
|
||||
setSize('')
|
||||
setDescription('')
|
||||
setItemType('flat_rate')
|
||||
setDefaultPrice('0')
|
||||
setDefaultCost('')
|
||||
setSortOrder('0')
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
mutation.mutate({
|
||||
name,
|
||||
instrumentType: instrumentType || undefined,
|
||||
size: size || undefined,
|
||||
description: description || undefined,
|
||||
itemType,
|
||||
defaultPrice: parseFloat(defaultPrice) || 0,
|
||||
defaultCost: defaultCost ? parseFloat(defaultCost) : undefined,
|
||||
sortOrder: parseInt(sortOrder) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Template</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Repair Template</DialogTitle></DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Bow Rehair, String Change, Valve Overhaul" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument Type</Label>
|
||||
<Input value={instrumentType} onChange={(e) => setInstrumentType(e.target.value)} placeholder="e.g. Violin, Trumpet, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Size</Label>
|
||||
<Input value={size} onChange={(e) => setSize(e.target.value)} placeholder="e.g. 4/4, 3/4, Full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What this service includes..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-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>Default Price</Label>
|
||||
<Input type="number" step="0.01" value={defaultPrice} onChange={(e) => setDefaultPrice(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Internal Cost</Label>
|
||||
<Input type="number" step="0.01" value={defaultCost} onChange={(e) => setDefaultCost(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sort Order</Label>
|
||||
<Input type="number" value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user