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" /> +
+
+
+ +