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:
Ryan Moon
2026-03-29 10:13:38 -05:00
parent 7d55fbe7ef
commit 01cff80f2b
7 changed files with 527 additions and 17 deletions

View File

@@ -0,0 +1,200 @@
import { useState } from 'react'
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 { Filter, X } from 'lucide-react'
const ALL_STATUSES = [
{ value: 'in_transit', label: 'In Transit' },
{ value: 'intake', label: 'Intake' },
{ value: 'diagnosing', label: 'Diagnosing' },
{ value: 'pending_approval', label: 'Pending Approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'pending_parts', label: 'Pending Parts' },
{ value: 'ready', label: 'Ready' },
{ value: 'picked_up', label: 'Picked Up' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'cancelled', label: 'Cancelled' },
]
const ACTIVE_STATUSES = ['in_transit', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'pending_parts', 'ready']
const CONDITIONS = [
{ value: 'excellent', label: 'Excellent' },
{ value: 'good', label: 'Good' },
{ value: 'fair', label: 'Fair' },
{ value: 'poor', label: 'Poor' },
]
export interface RepairFilters {
status?: string[]
conditionIn?: string[]
isBatch?: boolean
intakeDateFrom?: string
intakeDateTo?: string
promisedDateFrom?: string
promisedDateTo?: string
}
interface RepairFilterPanelProps {
filters: RepairFilters
onChange: (filters: RepairFilters) => void
}
export const DEFAULT_FILTERS: RepairFilters = {
status: ACTIVE_STATUSES,
}
export function RepairFilterPanel({ filters, onChange }: RepairFilterPanelProps) {
const [expanded, setExpanded] = useState(false)
const activeFilterCount = [
filters.status && filters.status.length < ALL_STATUSES.length ? 1 : 0,
filters.conditionIn?.length ? 1 : 0,
filters.isBatch !== undefined ? 1 : 0,
filters.intakeDateFrom || filters.intakeDateTo ? 1 : 0,
filters.promisedDateFrom || filters.promisedDateTo ? 1 : 0,
].reduce((a, b) => a + b, 0)
function toggleStatus(value: string) {
const current = filters.status ?? ACTIVE_STATUSES
const next = current.includes(value) ? current.filter((s) => s !== value) : [...current, value]
onChange({ ...filters, status: next.length > 0 ? next : undefined })
}
function toggleCondition(value: string) {
const current = filters.conditionIn ?? []
const next = current.includes(value) ? current.filter((c) => c !== value) : [...current, value]
onChange({ ...filters, conditionIn: next.length > 0 ? next : undefined })
}
function clearFilters() {
onChange(DEFAULT_FILTERS)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button variant={expanded ? 'secondary' : 'outline'} size="sm" onClick={() => setExpanded(!expanded)}>
<Filter className="mr-2 h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge variant="default" className="ml-2 h-5 px-1.5 text-xs">{activeFilterCount}</Badge>
)}
</Button>
{activeFilterCount > 0 && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-xs">
Reset to defaults
</Button>
)}
{/* Active filter tags */}
{filters.isBatch === true && (
<Badge variant="secondary" className="gap-1">
Batch only
<button onClick={() => onChange({ ...filters, isBatch: undefined })}><X className="h-3 w-3" /></button>
</Badge>
)}
{filters.isBatch === false && (
<Badge variant="secondary" className="gap-1">
Individual only
<button onClick={() => onChange({ ...filters, isBatch: undefined })}><X className="h-3 w-3" /></button>
</Badge>
)}
</div>
{expanded && (
<div className="rounded-md border p-4 space-y-4 bg-muted/30">
{/* Status filter */}
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Status</Label>
<div className="flex flex-wrap gap-1.5">
{ALL_STATUSES.map((s) => {
const active = (filters.status ?? ACTIVE_STATUSES).includes(s.value)
return (
<button
key={s.value}
type="button"
onClick={() => toggleStatus(s.value)}
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary'
}`}
>
{s.label}
</button>
)
})}
</div>
</div>
{/* Condition filter */}
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Condition</Label>
<div className="flex flex-wrap gap-1.5">
{CONDITIONS.map((c) => {
const active = (filters.conditionIn ?? []).includes(c.value)
return (
<button
key={c.value}
type="button"
onClick={() => toggleCondition(c.value)}
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary'
}`}
>
{c.label}
</button>
)
})}
</div>
</div>
{/* Batch filter */}
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Batch</Label>
<div className="flex gap-1.5">
{[
{ value: undefined, label: 'All' },
{ value: true, label: 'Batch only' },
{ value: false, label: 'Individual only' },
].map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => onChange({ ...filters, isBatch: opt.value as boolean | undefined })}
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
filters.isBatch === opt.value ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Date filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Intake Date</Label>
<div className="flex gap-2">
<Input type="date" className="h-8 text-xs" value={filters.intakeDateFrom ?? ''} onChange={(e) => onChange({ ...filters, intakeDateFrom: e.target.value || undefined })} />
<span className="text-muted-foreground self-center text-xs">to</span>
<Input type="date" className="h-8 text-xs" value={filters.intakeDateTo ?? ''} onChange={(e) => onChange({ ...filters, intakeDateTo: e.target.value || undefined })} />
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Promised Date</Label>
<div className="flex gap-2">
<Input type="date" className="h-8 text-xs" value={filters.promisedDateFrom ?? ''} onChange={(e) => onChange({ ...filters, promisedDateFrom: e.target.value || undefined })} />
<span className="text-muted-foreground self-center text-xs">to</span>
<Input type="date" className="h-8 text-xs" value={filters.promisedDateTo ?? ''} onChange={(e) => onChange({ ...filters, promisedDateTo: e.target.value || undefined })} />
</div>
</div>
</div>
</div>
)}
</div>
)
}