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 */}
+
+
+ )}
+
+ )
+}
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
+
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+ )
+}
diff --git a/packages/backend/src/routes/v1/repairs.ts b/packages/backend/src/routes/v1/repairs.ts
index 121b9f6..7355e93 100644
--- a/packages/backend/src/routes/v1/repairs.ts
+++ b/packages/backend/src/routes/v1/repairs.ts
@@ -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
+ 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)
})
diff --git a/packages/backend/src/services/repair.service.ts b/packages/backend/src/services/repair.service.ts
index 5bfadbc..65078d8 100644
--- a/packages/backend/src/services/repair.service.ts
+++ b/packages/backend/src/services/repair.service.ts
@@ -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, 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, 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 = {
ticket_number: repairTickets.ticketNumber,