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:
200
packages/admin/src/components/repairs/repair-filters.tsx
Normal file
200
packages/admin/src/components/repairs/repair-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user