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>
)
}

View File

@@ -22,6 +22,7 @@ import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authent
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new'
import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId'
import { Route as AuthenticatedRepairsTemplatesRouteImport } from './routes/_authenticated/repairs/templates'
import { Route as AuthenticatedRepairsNewRouteImport } from './routes/_authenticated/repairs/new'
import { Route as AuthenticatedRepairsTicketIdRouteImport } from './routes/_authenticated/repairs/$ticketId'
import { Route as AuthenticatedRepairBatchesNewRouteImport } from './routes/_authenticated/repair-batches/new'
@@ -104,6 +105,12 @@ const AuthenticatedRolesRoleIdRoute =
path: '/roles/$roleId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRepairsTemplatesRoute =
AuthenticatedRepairsTemplatesRouteImport.update({
id: '/repairs/templates',
path: '/repairs/templates',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRepairsNewRoute = AuthenticatedRepairsNewRouteImport.update({
id: '/repairs/new',
path: '/repairs/new',
@@ -189,6 +196,7 @@ export interface FileRoutesByFullPath {
'/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
'/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute
'/repairs/new': typeof AuthenticatedRepairsNewRoute
'/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute
@@ -214,6 +222,7 @@ export interface FileRoutesByTo {
'/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
'/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute
'/repairs/new': typeof AuthenticatedRepairsNewRoute
'/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute
@@ -242,6 +251,7 @@ export interface FileRoutesById {
'/_authenticated/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
'/_authenticated/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute
'/_authenticated/repairs/new': typeof AuthenticatedRepairsNewRoute
'/_authenticated/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute
'/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
@@ -270,6 +280,7 @@ export interface FileRouteTypes {
| '/repair-batches/new'
| '/repairs/$ticketId'
| '/repairs/new'
| '/repairs/templates'
| '/roles/$roleId'
| '/roles/new'
| '/accounts/'
@@ -295,6 +306,7 @@ export interface FileRouteTypes {
| '/repair-batches/new'
| '/repairs/$ticketId'
| '/repairs/new'
| '/repairs/templates'
| '/roles/$roleId'
| '/roles/new'
| '/accounts'
@@ -322,6 +334,7 @@ export interface FileRouteTypes {
| '/_authenticated/repair-batches/new'
| '/_authenticated/repairs/$ticketId'
| '/_authenticated/repairs/new'
| '/_authenticated/repairs/templates'
| '/_authenticated/roles/$roleId'
| '/_authenticated/roles/new'
| '/_authenticated/accounts/'
@@ -434,6 +447,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRolesRoleIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/repairs/templates': {
id: '/_authenticated/repairs/templates'
path: '/repairs/templates'
fullPath: '/repairs/templates'
preLoaderRoute: typeof AuthenticatedRepairsTemplatesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/repairs/new': {
id: '/_authenticated/repairs/new'
path: '/repairs/new'
@@ -560,6 +580,7 @@ interface AuthenticatedRouteChildren {
AuthenticatedRepairBatchesNewRoute: typeof AuthenticatedRepairBatchesNewRoute
AuthenticatedRepairsTicketIdRoute: typeof AuthenticatedRepairsTicketIdRoute
AuthenticatedRepairsNewRoute: typeof AuthenticatedRepairsNewRoute
AuthenticatedRepairsTemplatesRoute: typeof AuthenticatedRepairsTemplatesRoute
AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute
AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
@@ -583,6 +604,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedRepairBatchesNewRoute: AuthenticatedRepairBatchesNewRoute,
AuthenticatedRepairsTicketIdRoute: AuthenticatedRepairsTicketIdRoute,
AuthenticatedRepairsNewRoute: AuthenticatedRepairsNewRoute,
AuthenticatedRepairsTemplatesRoute: AuthenticatedRepairsTemplatesRoute,
AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute,
AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute,
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,

View File

@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { myPermissionsOptions } from '@/api/rbac'
import { Avatar } from '@/components/shared/avatar-upload'
import { Button } from '@/components/ui/button'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package } from 'lucide-react'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList } from 'lucide-react'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
@@ -82,6 +82,9 @@ function AuthenticatedLayout() {
<>
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Repairs" />
<NavLink to="/repair-batches" icon={<Package className="h-4 w-4" />} label="Repair Batches" />
{hasPermission('repairs.admin') && (
<NavLink to="/repairs/templates" icon={<ClipboardList className="h-4 w-4" />} label="Repair Templates" />
)}
</>
)}
{canViewUsers && (

View File

@@ -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 ?? []}

View 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>
)
}

View File

@@ -27,8 +27,23 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
})
app.get('/repair-tickets', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await RepairTicketService.list(app.db, request.companyId, params)
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const filters = {
status: query.status?.split(',').filter(Boolean),
conditionIn: query.conditionIn?.split(',').filter(Boolean),
isBatch: query.isBatch === 'true' ? true : query.isBatch === 'false' ? false : undefined,
batchNumber: query.batchNumber,
intakeDateFrom: query.intakeDateFrom,
intakeDateTo: query.intakeDateTo,
promisedDateFrom: query.promisedDateFrom,
promisedDateTo: query.promisedDateTo,
completedDateFrom: query.completedDateFrom,
completedDateTo: query.completedDateTo,
}
const result = await RepairTicketService.list(app.db, request.companyId, params, filters)
return reply.send(result)
})

View File

@@ -1,4 +1,4 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import { eq, and, count, inArray, isNull, isNotNull, gte, lte, type Column, type SQL } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import {
repairTickets,
@@ -83,18 +83,50 @@ export const RepairTicketService = {
return ticket ?? null
},
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = eq(repairTickets.companyId, companyId)
const searchCondition = params.q
? buildSearchCondition(params.q, [
repairTickets.ticketNumber,
repairTickets.customerName,
repairTickets.customerPhone,
repairTickets.instrumentDescription,
repairTickets.serialNumber,
])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput, filters?: {
status?: string[]
conditionIn?: string[]
isBatch?: boolean
batchNumber?: string
intakeDateFrom?: string
intakeDateTo?: string
promisedDateFrom?: string
promisedDateTo?: string
completedDateFrom?: string
completedDateTo?: string
}) {
const conditions: SQL[] = [eq(repairTickets.companyId, companyId)]
if (params.q) {
const search = buildSearchCondition(params.q, [
repairTickets.ticketNumber,
repairTickets.customerName,
repairTickets.customerPhone,
repairTickets.instrumentDescription,
repairTickets.serialNumber,
])
if (search) conditions.push(search)
}
if (filters?.status?.length) {
conditions.push(inArray(repairTickets.status, filters.status as any))
}
if (filters?.conditionIn?.length) {
conditions.push(inArray(repairTickets.conditionIn, filters.conditionIn as any))
}
if (filters?.isBatch === true) {
conditions.push(isNotNull(repairTickets.repairBatchId))
} else if (filters?.isBatch === false) {
conditions.push(isNull(repairTickets.repairBatchId))
}
if (filters?.intakeDateFrom) conditions.push(gte(repairTickets.intakeDate, new Date(filters.intakeDateFrom)))
if (filters?.intakeDateTo) conditions.push(lte(repairTickets.intakeDate, new Date(filters.intakeDateTo)))
if (filters?.promisedDateFrom) conditions.push(gte(repairTickets.promisedDate, new Date(filters.promisedDateFrom)))
if (filters?.promisedDateTo) conditions.push(lte(repairTickets.promisedDate, new Date(filters.promisedDateTo)))
if (filters?.completedDateFrom) conditions.push(gte(repairTickets.completedDate, new Date(filters.completedDateFrom)))
if (filters?.completedDateTo) conditions.push(lte(repairTickets.completedDate, new Date(filters.completedDateTo)))
const where = and(...conditions)
const sortableColumns: Record<string, Column> = {
ticket_number: repairTickets.ticketNumber,