From 01cff80f2b207b54cc0b90483571d7d8d927845c Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sun, 29 Mar 2026 10:13:38 -0500 Subject: [PATCH] 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. --- .../src/components/repairs/repair-filters.tsx | 200 ++++++++++++++++ packages/admin/src/routeTree.gen.ts | 22 ++ packages/admin/src/routes/_authenticated.tsx | 5 +- .../routes/_authenticated/repairs/index.tsx | 24 +- .../_authenticated/repairs/templates.tsx | 216 ++++++++++++++++++ packages/backend/src/routes/v1/repairs.ts | 19 +- .../backend/src/services/repair.service.ts | 58 +++-- 7 files changed, 527 insertions(+), 17 deletions(-) create mode 100644 packages/admin/src/components/repairs/repair-filters.tsx create mode 100644 packages/admin/src/routes/_authenticated/repairs/templates.tsx diff --git a/packages/admin/src/components/repairs/repair-filters.tsx b/packages/admin/src/components/repairs/repair-filters.tsx new file mode 100644 index 0000000..e5a22a1 --- /dev/null +++ b/packages/admin/src/components/repairs/repair-filters.tsx @@ -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 ( +
+
+ + {activeFilterCount > 0 && ( + + )} + + {/* Active filter tags */} + {filters.isBatch === true && ( + + Batch only + + + )} + {filters.isBatch === false && ( + + Individual only + + + )} +
+ + {expanded && ( +
+ {/* Status filter */} +
+ +
+ {ALL_STATUSES.map((s) => { + const active = (filters.status ?? ACTIVE_STATUSES).includes(s.value) + return ( + + ) + })} +
+
+ + {/* Condition filter */} +
+ +
+ {CONDITIONS.map((c) => { + const active = (filters.conditionIn ?? []).includes(c.value) + return ( + + ) + })} +
+
+ + {/* Batch filter */} +
+ +
+ {[ + { value: undefined, label: 'All' }, + { value: true, label: 'Batch only' }, + { value: false, label: 'Individual only' }, + ].map((opt) => ( + + ))} +
+
+ + {/* Date filters */} +
+
+ +
+ onChange({ ...filters, intakeDateFrom: e.target.value || undefined })} /> + to + onChange({ ...filters, intakeDateTo: e.target.value || undefined })} /> +
+
+
+ +
+ onChange({ ...filters, promisedDateFrom: e.target.value || undefined })} /> + to + onChange({ ...filters, promisedDateTo: e.target.value || undefined })} /> +
+
+
+
+ )} +
+ ) +} diff --git a/packages/admin/src/routeTree.gen.ts b/packages/admin/src/routeTree.gen.ts index 1103826..1ba2840 100644 --- a/packages/admin/src/routeTree.gen.ts +++ b/packages/admin/src/routeTree.gen.ts @@ -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, diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index 9dab793..ef65bb1 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -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() { <> } label="Repairs" /> } label="Repair Batches" /> + {hasPermission('repairs.admin') && ( + } label="Repair Templates" /> + )} )} {canViewUsers && ( diff --git a/packages/admin/src/routes/_authenticated/repairs/index.tsx b/packages/admin/src/routes/_authenticated/repairs/index.tsx index 9ac5579..ce243b1 100644 --- a/packages/admin/src/routes/_authenticated/repairs/index.tsx +++ b/packages/admin/src/routes/_authenticated/repairs/index.tsx @@ -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) => ({ @@ -24,6 +26,7 @@ export const Route = createFileRoute('/_authenticated/repairs/')({ function statusBadge(status: string) { const variants: Record = { + in_transit: 'secondary', intake: 'outline', diagnosing: 'secondary', pending_approval: 'secondary', @@ -36,6 +39,7 @@ function statusBadge(status: string) { cancelled: 'destructive', } const labels: Record = { + in_transit: 'In Transit', intake: 'Intake', diagnosing: 'Diagnosing', pending_approval: 'Pending Approval', @@ -74,6 +78,11 @@ const columns: Column[] = [ sortable: true, render: (t) => statusBadge(t.status), }, + { + key: 'batch', + header: 'Batch', + render: (t) => t.repairBatchId ? Batch : -, + }, { 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(DEFAULT_FILTERS) - const { data, isLoading } = useQuery(repairTicketListOptions(params)) + // Build query params with filters + const queryParams: Record = { ...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() { + + ) => ({ + 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[] = [ + { key: 'name', header: 'Name', sortable: true, render: (t) => {t.name} }, + { 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) => {t.itemType.replace('_', ' ')} }, + { 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[] = [ + ...columns, + { + key: 'actions', + header: '', + render: (t) => ( + + ), + }, + ] + + return ( +
+
+

Repair Templates

+ +
+ +
+
+ + setSearchInput(e.target.value)} + className="pl-9" + /> +
+ +
+ + +
+ ) +} + +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 ( + { onOpenChange(o); if (!o) resetForm() }}> + + + + + Create Repair Template +
+
+ + setName(e.target.value)} placeholder="e.g. Bow Rehair, String Change, Valve Overhaul" required /> +
+
+
+ + setInstrumentType(e.target.value)} placeholder="e.g. Violin, Trumpet, Guitar" /> +
+
+ + setSize(e.target.value)} placeholder="e.g. 4/4, 3/4, Full" /> +
+
+
+ +