From f17bbff02c9c6b81cc632111fe068f269a6ffecd Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sun, 29 Mar 2026 09:12:40 -0500 Subject: [PATCH] Add repairs domain with tickets, line items, batches, and service templates Full-stack implementation of instrument repair tracking: DB schema with repair_ticket, repair_line_item, repair_batch, and repair_service_template tables. Backend services and routes with pagination/search/sort. 20 API tests covering CRUD, status workflow, line items, and batch operations. Admin frontend with ticket list, detail with status progression, line item management, batch list/detail with approval workflow, and new ticket form with searchable account picker and intake photo uploads. --- packages/admin/src/api/repairs.ts | 103 +++++ packages/admin/src/routeTree.gen.ts | 132 ++++++ packages/admin/src/routes/_authenticated.tsx | 9 +- .../repair-batches/$batchId.tsx | 160 +++++++ .../_authenticated/repair-batches/index.tsx | 121 +++++ .../_authenticated/repair-batches/new.tsx | 124 +++++ .../_authenticated/repairs/$ticketId.tsx | 284 ++++++++++++ .../routes/_authenticated/repairs/index.tsx | 153 +++++++ .../src/routes/_authenticated/repairs/new.tsx | 314 +++++++++++++ packages/admin/src/types/repair.ts | 68 +++ packages/backend/api-tests/suites/repairs.ts | 265 +++++++++++ .../src/db/migrations/0015_repairs.sql | 78 ++++ .../0016_repair_service_templates.sql | 15 + .../src/db/migrations/meta/_journal.json | 14 + packages/backend/src/db/schema/repairs.ts | 169 +++++++ packages/backend/src/main.ts | 2 + packages/backend/src/routes/v1/repairs.ts | 209 +++++++++ .../backend/src/services/repair.service.ts | 427 ++++++++++++++++++ packages/shared/src/schemas/index.ts | 30 ++ packages/shared/src/schemas/repairs.schema.ts | 115 +++++ 20 files changed, 2791 insertions(+), 1 deletion(-) create mode 100644 packages/admin/src/api/repairs.ts create mode 100644 packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx create mode 100644 packages/admin/src/routes/_authenticated/repair-batches/index.tsx create mode 100644 packages/admin/src/routes/_authenticated/repair-batches/new.tsx create mode 100644 packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx create mode 100644 packages/admin/src/routes/_authenticated/repairs/index.tsx create mode 100644 packages/admin/src/routes/_authenticated/repairs/new.tsx create mode 100644 packages/admin/src/types/repair.ts create mode 100644 packages/backend/api-tests/suites/repairs.ts create mode 100644 packages/backend/src/db/migrations/0015_repairs.sql create mode 100644 packages/backend/src/db/migrations/0016_repair_service_templates.sql create mode 100644 packages/backend/src/db/schema/repairs.ts create mode 100644 packages/backend/src/routes/v1/repairs.ts create mode 100644 packages/backend/src/services/repair.service.ts create mode 100644 packages/shared/src/schemas/repairs.schema.ts diff --git a/packages/admin/src/api/repairs.ts b/packages/admin/src/api/repairs.ts new file mode 100644 index 0000000..65f425d --- /dev/null +++ b/packages/admin/src/api/repairs.ts @@ -0,0 +1,103 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import type { RepairTicket, RepairLineItem, RepairBatch } from '@/types/repair' +import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas' + +// --- Repair Tickets --- + +export const repairTicketKeys = { + all: ['repair-tickets'] as const, + list: (params: PaginationInput) => [...repairTicketKeys.all, 'list', params] as const, + detail: (id: string) => [...repairTicketKeys.all, 'detail', id] as const, +} + +export function repairTicketListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: repairTicketKeys.list(params), + queryFn: () => api.get>('/v1/repair-tickets', params), + }) +} + +export function repairTicketDetailOptions(id: string) { + return queryOptions({ + queryKey: repairTicketKeys.detail(id), + queryFn: () => api.get(`/v1/repair-tickets/${id}`), + }) +} + +export const repairTicketMutations = { + create: (data: Record) => + api.post('/v1/repair-tickets', data), + update: (id: string, data: Record) => + api.patch(`/v1/repair-tickets/${id}`, data), + updateStatus: (id: string, status: string) => + api.post(`/v1/repair-tickets/${id}/status`, { status }), + delete: (id: string) => + api.del(`/v1/repair-tickets/${id}`), +} + +// --- Repair Line Items --- + +export const repairLineItemKeys = { + all: (ticketId: string) => ['repair-tickets', ticketId, 'line-items'] as const, + list: (ticketId: string, params: PaginationInput) => [...repairLineItemKeys.all(ticketId), params] as const, +} + +export function repairLineItemListOptions(ticketId: string, params: PaginationInput) { + return queryOptions({ + queryKey: repairLineItemKeys.list(ticketId, params), + queryFn: () => api.get>(`/v1/repair-tickets/${ticketId}/line-items`, params), + }) +} + +export const repairLineItemMutations = { + create: (ticketId: string, data: Record) => + api.post(`/v1/repair-tickets/${ticketId}/line-items`, data), + update: (id: string, data: Record) => + api.patch(`/v1/repair-line-items/${id}`, data), + delete: (id: string) => + api.del(`/v1/repair-line-items/${id}`), +} + +// --- Repair Batches --- + +export const repairBatchKeys = { + all: ['repair-batches'] as const, + list: (params: PaginationInput) => [...repairBatchKeys.all, 'list', params] as const, + detail: (id: string) => [...repairBatchKeys.all, 'detail', id] as const, + tickets: (batchId: string, params: PaginationInput) => [...repairBatchKeys.all, batchId, 'tickets', params] as const, +} + +export function repairBatchListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: repairBatchKeys.list(params), + queryFn: () => api.get>('/v1/repair-batches', params), + }) +} + +export function repairBatchDetailOptions(id: string) { + return queryOptions({ + queryKey: repairBatchKeys.detail(id), + queryFn: () => api.get(`/v1/repair-batches/${id}`), + }) +} + +export function repairBatchTicketsOptions(batchId: string, params: PaginationInput) { + return queryOptions({ + queryKey: repairBatchKeys.tickets(batchId, params), + queryFn: () => api.get>(`/v1/repair-batches/${batchId}/tickets`, params), + }) +} + +export const repairBatchMutations = { + create: (data: Record) => + api.post('/v1/repair-batches', data), + update: (id: string, data: Record) => + api.patch(`/v1/repair-batches/${id}`, data), + updateStatus: (id: string, status: string) => + api.post(`/v1/repair-batches/${id}/status`, { status }), + approve: (id: string) => + api.post(`/v1/repair-batches/${id}/approve`, {}), + reject: (id: string) => + api.post(`/v1/repair-batches/${id}/reject`, {}), +} diff --git a/packages/admin/src/routeTree.gen.ts b/packages/admin/src/routeTree.gen.ts index d49dc15..1103826 100644 --- a/packages/admin/src/routeTree.gen.ts +++ b/packages/admin/src/routeTree.gen.ts @@ -16,10 +16,16 @@ import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/ import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile' import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help' import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index' +import { Route as AuthenticatedRepairsIndexRouteImport } from './routes/_authenticated/repairs/index' +import { Route as AuthenticatedRepairBatchesIndexRouteImport } from './routes/_authenticated/repair-batches/index' import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index' 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 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' +import { Route as AuthenticatedRepairBatchesBatchIdRouteImport } from './routes/_authenticated/repair-batches/$batchId' import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId' import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new' import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId' @@ -63,6 +69,18 @@ const AuthenticatedRolesIndexRoute = AuthenticatedRolesIndexRouteImport.update({ path: '/roles/', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedRepairsIndexRoute = + AuthenticatedRepairsIndexRouteImport.update({ + id: '/repairs/', + path: '/repairs/', + getParentRoute: () => AuthenticatedRoute, + } as any) +const AuthenticatedRepairBatchesIndexRoute = + AuthenticatedRepairBatchesIndexRouteImport.update({ + id: '/repair-batches/', + path: '/repair-batches/', + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthenticatedMembersIndexRoute = AuthenticatedMembersIndexRouteImport.update({ id: '/members/', @@ -86,6 +104,29 @@ const AuthenticatedRolesRoleIdRoute = path: '/roles/$roleId', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedRepairsNewRoute = AuthenticatedRepairsNewRouteImport.update({ + id: '/repairs/new', + path: '/repairs/new', + getParentRoute: () => AuthenticatedRoute, +} as any) +const AuthenticatedRepairsTicketIdRoute = + AuthenticatedRepairsTicketIdRouteImport.update({ + id: '/repairs/$ticketId', + path: '/repairs/$ticketId', + getParentRoute: () => AuthenticatedRoute, + } as any) +const AuthenticatedRepairBatchesNewRoute = + AuthenticatedRepairBatchesNewRouteImport.update({ + id: '/repair-batches/new', + path: '/repair-batches/new', + getParentRoute: () => AuthenticatedRoute, + } as any) +const AuthenticatedRepairBatchesBatchIdRoute = + AuthenticatedRepairBatchesBatchIdRouteImport.update({ + id: '/repair-batches/$batchId', + path: '/repair-batches/$batchId', + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthenticatedMembersMemberIdRoute = AuthenticatedMembersMemberIdRouteImport.update({ id: '/members/$memberId', @@ -144,10 +185,16 @@ export interface FileRoutesByFullPath { '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/accounts/new': typeof AuthenticatedAccountsNewRoute '/members/$memberId': typeof AuthenticatedMembersMemberIdRoute + '/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute + '/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute + '/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute + '/repairs/new': typeof AuthenticatedRepairsNewRoute '/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/roles/new': typeof AuthenticatedRolesNewRoute '/accounts/': typeof AuthenticatedAccountsIndexRoute '/members/': typeof AuthenticatedMembersIndexRoute + '/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute + '/repairs/': typeof AuthenticatedRepairsIndexRoute '/roles/': typeof AuthenticatedRolesIndexRoute '/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute '/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute @@ -163,10 +210,16 @@ export interface FileRoutesByTo { '/': typeof AuthenticatedIndexRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute '/members/$memberId': typeof AuthenticatedMembersMemberIdRoute + '/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute + '/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute + '/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute + '/repairs/new': typeof AuthenticatedRepairsNewRoute '/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/roles/new': typeof AuthenticatedRolesNewRoute '/accounts': typeof AuthenticatedAccountsIndexRoute '/members': typeof AuthenticatedMembersIndexRoute + '/repair-batches': typeof AuthenticatedRepairBatchesIndexRoute + '/repairs': typeof AuthenticatedRepairsIndexRoute '/roles': typeof AuthenticatedRolesIndexRoute '/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute '/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute @@ -185,10 +238,16 @@ export interface FileRoutesById { '/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute '/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute + '/_authenticated/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute + '/_authenticated/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute + '/_authenticated/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute + '/_authenticated/repairs/new': typeof AuthenticatedRepairsNewRoute '/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute '/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute '/_authenticated/members/': typeof AuthenticatedMembersIndexRoute + '/_authenticated/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute + '/_authenticated/repairs/': typeof AuthenticatedRepairsIndexRoute '/_authenticated/roles/': typeof AuthenticatedRolesIndexRoute '/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute '/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute @@ -207,10 +266,16 @@ export interface FileRouteTypes { | '/accounts/$accountId' | '/accounts/new' | '/members/$memberId' + | '/repair-batches/$batchId' + | '/repair-batches/new' + | '/repairs/$ticketId' + | '/repairs/new' | '/roles/$roleId' | '/roles/new' | '/accounts/' | '/members/' + | '/repair-batches/' + | '/repairs/' | '/roles/' | '/accounts/$accountId/members' | '/accounts/$accountId/payment-methods' @@ -226,10 +291,16 @@ export interface FileRouteTypes { | '/' | '/accounts/new' | '/members/$memberId' + | '/repair-batches/$batchId' + | '/repair-batches/new' + | '/repairs/$ticketId' + | '/repairs/new' | '/roles/$roleId' | '/roles/new' | '/accounts' | '/members' + | '/repair-batches' + | '/repairs' | '/roles' | '/accounts/$accountId/members' | '/accounts/$accountId/payment-methods' @@ -247,10 +318,16 @@ export interface FileRouteTypes { | '/_authenticated/accounts/$accountId' | '/_authenticated/accounts/new' | '/_authenticated/members/$memberId' + | '/_authenticated/repair-batches/$batchId' + | '/_authenticated/repair-batches/new' + | '/_authenticated/repairs/$ticketId' + | '/_authenticated/repairs/new' | '/_authenticated/roles/$roleId' | '/_authenticated/roles/new' | '/_authenticated/accounts/' | '/_authenticated/members/' + | '/_authenticated/repair-batches/' + | '/_authenticated/repairs/' | '/_authenticated/roles/' | '/_authenticated/accounts/$accountId/members' | '/_authenticated/accounts/$accountId/payment-methods' @@ -315,6 +392,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedRolesIndexRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/repairs/': { + id: '/_authenticated/repairs/' + path: '/repairs' + fullPath: '/repairs/' + preLoaderRoute: typeof AuthenticatedRepairsIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/repair-batches/': { + id: '/_authenticated/repair-batches/' + path: '/repair-batches' + fullPath: '/repair-batches/' + preLoaderRoute: typeof AuthenticatedRepairBatchesIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/members/': { id: '/_authenticated/members/' path: '/members' @@ -343,6 +434,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedRolesRoleIdRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/repairs/new': { + id: '/_authenticated/repairs/new' + path: '/repairs/new' + fullPath: '/repairs/new' + preLoaderRoute: typeof AuthenticatedRepairsNewRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/repairs/$ticketId': { + id: '/_authenticated/repairs/$ticketId' + path: '/repairs/$ticketId' + fullPath: '/repairs/$ticketId' + preLoaderRoute: typeof AuthenticatedRepairsTicketIdRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/repair-batches/new': { + id: '/_authenticated/repair-batches/new' + path: '/repair-batches/new' + fullPath: '/repair-batches/new' + preLoaderRoute: typeof AuthenticatedRepairBatchesNewRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/repair-batches/$batchId': { + id: '/_authenticated/repair-batches/$batchId' + path: '/repair-batches/$batchId' + fullPath: '/repair-batches/$batchId' + preLoaderRoute: typeof AuthenticatedRepairBatchesBatchIdRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/members/$memberId': { id: '/_authenticated/members/$memberId' path: '/members/$memberId' @@ -437,10 +556,16 @@ interface AuthenticatedRouteChildren { AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute + AuthenticatedRepairBatchesBatchIdRoute: typeof AuthenticatedRepairBatchesBatchIdRoute + AuthenticatedRepairBatchesNewRoute: typeof AuthenticatedRepairBatchesNewRoute + AuthenticatedRepairsTicketIdRoute: typeof AuthenticatedRepairsTicketIdRoute + AuthenticatedRepairsNewRoute: typeof AuthenticatedRepairsNewRoute AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute + AuthenticatedRepairBatchesIndexRoute: typeof AuthenticatedRepairBatchesIndexRoute + AuthenticatedRepairsIndexRoute: typeof AuthenticatedRepairsIndexRoute AuthenticatedRolesIndexRoute: typeof AuthenticatedRolesIndexRoute } @@ -453,10 +578,17 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedAccountsAccountIdRouteWithChildren, AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute, AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute, + AuthenticatedRepairBatchesBatchIdRoute: + AuthenticatedRepairBatchesBatchIdRoute, + AuthenticatedRepairBatchesNewRoute: AuthenticatedRepairBatchesNewRoute, + AuthenticatedRepairsTicketIdRoute: AuthenticatedRepairsTicketIdRoute, + AuthenticatedRepairsNewRoute: AuthenticatedRepairsNewRoute, AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute, AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute, AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute, AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute, + AuthenticatedRepairBatchesIndexRoute: AuthenticatedRepairBatchesIndexRoute, + AuthenticatedRepairsIndexRoute: AuthenticatedRepairsIndexRoute, AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute, } diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index 1fa94b3..9dab793 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 } from 'lucide-react' +import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package } from 'lucide-react' export const Route = createFileRoute('/_authenticated')({ beforeLoad: () => { @@ -57,6 +57,7 @@ function AuthenticatedLayout() { } const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view') + const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view') const canViewUsers = !permissionsLoaded || hasPermission('users.view') return ( @@ -77,6 +78,12 @@ function AuthenticatedLayout() { } label="Members" /> )} + {canViewRepairs && ( + <> + } label="Repairs" /> + } label="Repair Batches" /> + + )} {canViewUsers && (
Admin diff --git a/packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx b/packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx new file mode 100644 index 0000000..dbbbbc4 --- /dev/null +++ b/packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx @@ -0,0 +1,160 @@ +import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { repairBatchDetailOptions, repairBatchMutations, repairBatchKeys, repairBatchTicketsOptions } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { ArrowLeft, Check, X } from 'lucide-react' +import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth.store' +import type { RepairTicket } from '@/types/repair' + +export const Route = createFileRoute('/_authenticated/repair-batches/$batchId')({ + validateSearch: (search: Record) => ({ + 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: RepairBatchDetailPage, +}) + +const ticketColumns: Column[] = [ + { key: 'ticket_number', header: 'Ticket #', sortable: true, render: (t) => {t.ticketNumber} }, + { key: 'customer_name', header: 'Instrument', render: (t) => <>{t.instrumentDescription ?? '-'} }, + { key: 'status', header: 'Status', sortable: true, render: (t) => {t.status.replace('_', ' ')} }, + { key: 'problem', header: 'Problem', render: (t) => {t.problemDescription} }, +] + +function RepairBatchDetailPage() { + const { batchId } = useParams({ from: '/_authenticated/repair-batches/$batchId' }) + const navigate = useNavigate() + const queryClient = useQueryClient() + const hasPermission = useAuthStore((s) => s.hasPermission) + const { params, setPage, setSort } = usePagination() + + const { data: batch, isLoading } = useQuery(repairBatchDetailOptions(batchId)) + const { data: ticketsData, isLoading: ticketsLoading } = useQuery(repairBatchTicketsOptions(batchId, params)) + + const approveMutation = useMutation({ + mutationFn: () => repairBatchMutations.approve(batchId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: repairBatchKeys.detail(batchId) }) + toast.success('Batch approved') + }, + onError: (err) => toast.error(err.message), + }) + + const rejectMutation = useMutation({ + mutationFn: () => repairBatchMutations.reject(batchId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: repairBatchKeys.detail(batchId) }) + toast.success('Batch rejected') + }, + onError: (err) => toast.error(err.message), + }) + + const statusMutation = useMutation({ + mutationFn: (status: string) => repairBatchMutations.updateStatus(batchId, status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: repairBatchKeys.detail(batchId) }) + toast.success('Status updated') + }, + onError: (err) => toast.error(err.message), + }) + + if (isLoading) { + return
+ } + + if (!batch) { + return

Batch not found

+ } + + function handleTicketClick(ticket: RepairTicket) { + navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any }) + } + + return ( +
+
+ +
+

Batch #{batch.batchNumber}

+

{batch.contactName ?? 'No contact'}

+
+ {batch.status.replace('_', ' ')} + + {batch.approvalStatus} + +
+ + {/* Actions */} +
+ {hasPermission('repairs.admin') && batch.approvalStatus === 'pending' && ( + <> + + + + )} + {hasPermission('repairs.edit') && batch.status === 'intake' && ( + + )} + {hasPermission('repairs.edit') && batch.status === 'in_progress' && ( + + )} +
+ + {/* Batch Info */} +
+ + Contact + +
Name: {batch.contactName ?? '-'}
+
Phone: {batch.contactPhone ?? '-'}
+
Email: {batch.contactEmail ?? '-'}
+
+
+ + Details + +
Instruments: {batch.receivedCount}/{batch.instrumentCount}
+
Due: {batch.dueDate ? new Date(batch.dueDate).toLocaleDateString() : '-'}
+
Estimated Total: {batch.estimatedTotal ? `$${batch.estimatedTotal}` : '-'}
+ {batch.notes &&
Notes: {batch.notes}
} +
+
+
+ + {/* Tickets in batch */} + + Tickets ({ticketsData?.pagination.total ?? 0}) + + + + +
+ ) +} diff --git a/packages/admin/src/routes/_authenticated/repair-batches/index.tsx b/packages/admin/src/routes/_authenticated/repair-batches/index.tsx new file mode 100644 index 0000000..cf84441 --- /dev/null +++ b/packages/admin/src/routes/_authenticated/repair-batches/index.tsx @@ -0,0 +1,121 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { repairBatchListOptions } 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 { 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 { RepairBatch } from '@/types/repair' + +export const Route = createFileRoute('/_authenticated/repair-batches/')({ + validateSearch: (search: Record) => ({ + 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') || 'desc', + }), + component: RepairBatchesListPage, +}) + +const columns: Column[] = [ + { + key: 'batch_number', + header: 'Batch #', + sortable: true, + render: (b) => {b.batchNumber ?? '-'}, + }, + { + key: 'contact', + header: 'Contact', + render: (b) => {b.contactName ?? '-'}, + }, + { + key: 'status', + header: 'Status', + sortable: true, + render: (b) => {b.status.replace('_', ' ')}, + }, + { + key: 'approval', + header: 'Approval', + render: (b) => { + const v = b.approvalStatus === 'approved' ? 'default' : b.approvalStatus === 'rejected' ? 'destructive' : 'secondary' + return {b.approvalStatus} + }, + }, + { + key: 'instruments', + header: 'Instruments', + render: (b) => <>{b.receivedCount}/{b.instrumentCount}, + }, + { + key: 'due_date', + header: 'Due', + sortable: true, + render: (b) => <>{b.dueDate ? new Date(b.dueDate).toLocaleDateString() : '-'}, + }, +] + +function RepairBatchesListPage() { + const navigate = useNavigate() + const hasPermission = useAuthStore((s) => s.hasPermission) + const { params, setPage, setSearch, setSort } = usePagination() + const [searchInput, setSearchInput] = useState(params.q ?? '') + + const { data, isLoading } = useQuery(repairBatchListOptions(params)) + + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + setSearch(searchInput) + } + + function handleRowClick(batch: RepairBatch) { + navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as any }) + } + + return ( +
+
+

Repair Batches

+ {hasPermission('repairs.edit') && ( + + )} +
+ +
+
+ + setSearchInput(e.target.value)} + className="pl-9" + /> +
+ +
+ + +
+ ) +} diff --git a/packages/admin/src/routes/_authenticated/repair-batches/new.tsx b/packages/admin/src/routes/_authenticated/repair-batches/new.tsx new file mode 100644 index 0000000..766c4f9 --- /dev/null +++ b/packages/admin/src/routes/_authenticated/repair-batches/new.tsx @@ -0,0 +1,124 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useQuery, useMutation } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { RepairBatchCreateSchema } from '@forte/shared/schemas' +import { repairBatchMutations } from '@/api/repairs' +import { accountListOptions } from '@/api/accounts' +import { Button } from '@/components/ui/button' +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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { ArrowLeft } from 'lucide-react' +import { toast } from 'sonner' + +export const Route = createFileRoute('/_authenticated/repair-batches/new')({ + component: NewRepairBatchPage, +}) + +function NewRepairBatchPage() { + const navigate = useNavigate() + + const { data: accountsData } = useQuery(accountListOptions({ page: 1, limit: 100, order: 'asc', sort: 'name' })) + + const { + register, + handleSubmit, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(RepairBatchCreateSchema), + defaultValues: { + accountId: '', + contactName: '', + contactPhone: '', + contactEmail: '', + instrumentCount: 0, + notes: '', + }, + }) + + const mutation = useMutation({ + mutationFn: repairBatchMutations.create, + onSuccess: (batch) => { + toast.success('Repair batch created') + navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as any }) + }, + onError: (err) => toast.error(err.message), + }) + + const accounts = accountsData?.data ?? [] + + return ( +
+
+ +

New Repair Batch

+
+ + + + Batch Details + + +
mutation.mutate(data))} className="space-y-4"> +
+ + + {errors.accountId &&

{errors.accountId.message}

} +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +