From 7d55fbe7effdf854fcbe553ef6116c670f43a06a Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sun, 29 Mar 2026 09:56:28 -0500 Subject: [PATCH] Add repair ticket detail improvements and intake estimate builder Status progress bar component with visual step indicator, in_transit status for instruments being transported to shop. Ticket detail page reworked with inline edit form, reopen for cancelled tickets, photos grouped by repair phase (intake/in_progress/completed). Intake form now supports building estimates with template picker and manual line items that carry over to the ticket. Service template API client and types added for template search in line item dialogs. --- packages/admin/src/api/repairs.ts | 25 +- .../components/repairs/status-progress.tsx | 115 ++++++ .../src/components/repairs/ticket-photos.tsx | 171 +++++++++ .../_authenticated/repairs/$ticketId.tsx | 344 ++++++++++++++---- .../src/routes/_authenticated/repairs/new.tsx | 282 ++++++++++---- packages/admin/src/types/repair.ts | 18 +- .../0017_repair_in_transit_status.sql | 1 + .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/repairs.ts | 1 + packages/shared/src/schemas/repairs.schema.ts | 2 +- 10 files changed, 839 insertions(+), 127 deletions(-) create mode 100644 packages/admin/src/components/repairs/status-progress.tsx create mode 100644 packages/admin/src/components/repairs/ticket-photos.tsx create mode 100644 packages/backend/src/db/migrations/0017_repair_in_transit_status.sql diff --git a/packages/admin/src/api/repairs.ts b/packages/admin/src/api/repairs.ts index 65f425d..a2d9b71 100644 --- a/packages/admin/src/api/repairs.ts +++ b/packages/admin/src/api/repairs.ts @@ -1,6 +1,6 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' -import type { RepairTicket, RepairLineItem, RepairBatch } from '@/types/repair' +import type { RepairTicket, RepairLineItem, RepairBatch, RepairServiceTemplate } from '@/types/repair' import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas' // --- Repair Tickets --- @@ -89,6 +89,29 @@ export function repairBatchTicketsOptions(batchId: string, params: PaginationInp }) } +// --- Repair Service Templates --- + +export const repairServiceTemplateKeys = { + all: ['repair-service-templates'] as const, + list: (params: PaginationInput) => [...repairServiceTemplateKeys.all, 'list', params] as const, +} + +export function repairServiceTemplateListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: repairServiceTemplateKeys.list(params), + queryFn: () => api.get>('/v1/repair-service-templates', params), + }) +} + +export const repairServiceTemplateMutations = { + create: (data: Record) => + api.post('/v1/repair-service-templates', data), + update: (id: string, data: Record) => + api.patch(`/v1/repair-service-templates/${id}`, data), + delete: (id: string) => + api.del(`/v1/repair-service-templates/${id}`), +} + export const repairBatchMutations = { create: (data: Record) => api.post('/v1/repair-batches', data), diff --git a/packages/admin/src/components/repairs/status-progress.tsx b/packages/admin/src/components/repairs/status-progress.tsx new file mode 100644 index 0000000..63bcade --- /dev/null +++ b/packages/admin/src/components/repairs/status-progress.tsx @@ -0,0 +1,115 @@ +import { Check, Truck, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban } from 'lucide-react' + +const STEPS = [ + { key: 'in_transit', label: 'In Transit', icon: Truck }, + { key: 'intake', label: 'Intake', icon: ClipboardList }, + { key: 'diagnosing', label: 'Diagnosing', icon: Search }, + { key: 'pending_approval', label: 'Pending Approval', icon: Clock }, + { key: 'approved', label: 'Approved', icon: ThumbsUp }, + { key: 'in_progress', label: 'In Progress', icon: Wrench }, + { key: 'ready', label: 'Ready', icon: Package }, + { key: 'picked_up', label: 'Picked Up', icon: HandMetal }, +] as const + +const BRANCH_STATUSES: Record = { + pending_parts: { label: 'Pending Parts', parentStep: 'in_progress' }, + delivered: { label: 'Delivered', parentStep: 'picked_up' }, +} + +interface StatusProgressProps { + currentStatus: string + onStatusClick?: (status: string) => void +} + +export function StatusProgress({ currentStatus, onStatusClick }: StatusProgressProps) { + const isCancelled = currentStatus === 'cancelled' + const isBranch = currentStatus in BRANCH_STATUSES + + // Find the effective step index for branch statuses + const effectiveStatus = isBranch ? BRANCH_STATUSES[currentStatus].parentStep : currentStatus + const currentIdx = STEPS.findIndex((s) => s.key === effectiveStatus) + + return ( +
+
+ {STEPS.map((step, idx) => { + const isCompleted = !isCancelled && currentIdx > idx + const isCurrent = !isCancelled && currentIdx === idx + const isFuture = isCancelled || currentIdx < idx + + return ( +
+ {/* Step circle */} + + + {/* Connector line */} + {idx < STEPS.length - 1 && ( +
idx ? 'bg-primary' : 'bg-muted-foreground/20'} + ${isCancelled ? 'bg-destructive/20' : ''} + `} + /> + )} +
+ ) + })} +
+ + {/* Branch status indicator */} + {isBranch && ( +
+
+ + {BRANCH_STATUSES[currentStatus].label} + +
+ )} + + {/* Cancelled overlay */} + {isCancelled && ( +
+ + Cancelled +
+ )} +
+ ) +} diff --git a/packages/admin/src/components/repairs/ticket-photos.tsx b/packages/admin/src/components/repairs/ticket-photos.tsx new file mode 100644 index 0000000..2470938 --- /dev/null +++ b/packages/admin/src/components/repairs/ticket-photos.tsx @@ -0,0 +1,171 @@ +import { useRef } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { useAuthStore } from '@/stores/auth.store' +import { Button } from '@/components/ui/button' +import { ImageIcon, Plus, Trash2 } from 'lucide-react' +import { toast } from 'sonner' + +interface FileRecord { + id: string + path: string + category: string + filename: string +} + +function ticketFilesOptions(ticketId: string) { + return queryOptions({ + queryKey: ['files', 'repair_ticket', ticketId], + queryFn: () => api.get<{ data: FileRecord[] }>('/v1/files', { entityType: 'repair_ticket', entityId: ticketId }), + enabled: !!ticketId, + }) +} + +const PHOTO_CATEGORIES = [ + { key: 'intake', label: 'Intake Photos', description: 'Condition at intake' }, + { key: 'in_progress', label: 'Work in Progress', description: 'During repair' }, + { key: 'completed', label: 'Completed', description: 'Final result' }, +] as const + +interface TicketPhotosProps { + ticketId: string + currentStatus: string +} + +export function TicketPhotos({ ticketId, currentStatus }: TicketPhotosProps) { + const queryClient = useQueryClient() + const token = useAuthStore((s) => s.token) + const { data: filesData } = useQuery(ticketFilesOptions(ticketId)) + + const files = filesData?.data ?? [] + + // Determine which category to highlight based on current status + const activeCategory = + ['in_progress', 'pending_parts'].includes(currentStatus) ? 'in_progress' + : ['ready', 'picked_up', 'delivered'].includes(currentStatus) ? 'completed' + : 'intake' + + return ( +
+ {PHOTO_CATEGORIES.map((cat) => ( + f.category === cat.key)} + isActive={activeCategory === cat.key} + token={token} + onUpdate={() => queryClient.invalidateQueries({ queryKey: ['files', 'repair_ticket', ticketId] })} + /> + ))} +
+ ) +} + +function PhotoSection({ + ticketId, + category, + label, + description, + photos, + isActive, + token, + onUpdate, +}: { + ticketId: string + category: string + label: string + description: string + photos: FileRecord[] + isActive: boolean + token: string | null + onUpdate: () => void +}) { + const fileInputRef = useRef(null) + + const deleteMutation = useMutation({ + mutationFn: (fileId: string) => api.del(`/v1/files/${fileId}`), + onSuccess: () => { + onUpdate() + toast.success('Photo removed') + }, + onError: (err) => toast.error(err.message), + }) + + async function handleUpload(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []) + for (const file of files) { + const formData = new FormData() + formData.append('file', file) + formData.append('entityType', 'repair_ticket') + formData.append('entityId', ticketId) + formData.append('category', category) + + try { + const res = await fetch('/v1/files', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }) + if (!res.ok) { + const err = await res.json() + throw new Error(err.error?.message ?? 'Upload failed') + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed') + } + } + onUpdate() + toast.success(`${files.length} photo(s) uploaded`) + e.target.value = '' + } + + return ( +
+
+
+

{label}

+

{description}

+
+ +
+ + {photos.length === 0 ? ( +

No photos

+ ) : ( +
+ {photos.map((photo) => ( +
+ {photo.filename} + +
+ ))} +
+ )} + + +
+ ) +} diff --git a/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx b/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx index 97887d3..65941c7 100644 --- a/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx +++ b/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx @@ -1,18 +1,25 @@ import { useState } from 'react' import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { repairTicketDetailOptions, repairTicketMutations, repairTicketKeys, repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys } from '@/api/repairs' +import { + repairTicketDetailOptions, repairTicketMutations, repairTicketKeys, + repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys, + repairServiceTemplateListOptions, +} from '@/api/repairs' import { usePagination } from '@/hooks/use-pagination' +import { StatusProgress } from '@/components/repairs/status-progress' +import { TicketPhotos } from '@/components/repairs/ticket-photos' 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { ArrowLeft, Plus, Trash2 } from 'lucide-react' +import { ArrowLeft, Plus, Trash2, RotateCcw, Save } from 'lucide-react' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth.store' import type { RepairLineItem } from '@/types/repair' @@ -29,6 +36,7 @@ export const Route = createFileRoute('/_authenticated/repairs/$ticketId')({ }) const STATUS_LABELS: Record = { + in_transit: 'In Transit', intake: 'Intake', diagnosing: 'Diagnosing', pending_approval: 'Pending Approval', @@ -41,7 +49,7 @@ const STATUS_LABELS: Record = { cancelled: 'Cancelled', } -const STATUS_FLOW = ['intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up'] +const STATUS_FLOW = ['in_transit', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up'] function RepairTicketDetailPage() { const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' }) @@ -49,11 +57,15 @@ function RepairTicketDetailPage() { const queryClient = useQueryClient() const hasPermission = useAuthStore((s) => s.hasPermission) const [addItemOpen, setAddItemOpen] = useState(false) + const [editing, setEditing] = useState(false) const { params, setPage, setSort } = usePagination() const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId)) const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params)) + // Edit form state + const [editFields, setEditFields] = useState>({}) + const statusMutation = useMutation({ mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status), onSuccess: () => { @@ -63,6 +75,16 @@ function RepairTicketDetailPage() { onError: (err) => toast.error(err.message), }) + const updateMutation = useMutation({ + mutationFn: (data: Record) => repairTicketMutations.update(ticketId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId) }) + toast.success('Ticket updated') + setEditing(false) + }, + onError: (err) => toast.error(err.message), + }) + const deleteItemMutation = useMutation({ mutationFn: repairLineItemMutations.delete, onSuccess: () => { @@ -73,36 +95,67 @@ function RepairTicketDetailPage() { }) if (isLoading) { - return ( -
- - -
- ) + return
} if (!ticket) { return

Repair ticket not found

} + function startEdit() { + setEditFields({ + customerName: ticket!.customerName, + customerPhone: ticket!.customerPhone ?? '', + instrumentDescription: ticket!.instrumentDescription ?? '', + serialNumber: ticket!.serialNumber ?? '', + conditionIn: ticket!.conditionIn ?? '', + conditionInNotes: ticket!.conditionInNotes ?? '', + problemDescription: ticket!.problemDescription, + technicianNotes: ticket!.technicianNotes ?? '', + estimatedCost: ticket!.estimatedCost ?? '', + actualCost: ticket!.actualCost ?? '', + }) + setEditing(true) + } + + function saveEdit() { + const data: Record = {} + if (editFields.customerName !== ticket!.customerName) data.customerName = editFields.customerName + if (editFields.customerPhone !== (ticket!.customerPhone ?? '')) data.customerPhone = editFields.customerPhone || undefined + if (editFields.instrumentDescription !== (ticket!.instrumentDescription ?? '')) data.instrumentDescription = editFields.instrumentDescription || undefined + if (editFields.serialNumber !== (ticket!.serialNumber ?? '')) data.serialNumber = editFields.serialNumber || undefined + if (editFields.conditionIn !== (ticket!.conditionIn ?? '')) data.conditionIn = editFields.conditionIn || undefined + if (editFields.conditionInNotes !== (ticket!.conditionInNotes ?? '')) data.conditionInNotes = editFields.conditionInNotes || undefined + if (editFields.problemDescription !== ticket!.problemDescription) data.problemDescription = editFields.problemDescription + if (editFields.technicianNotes !== (ticket!.technicianNotes ?? '')) data.technicianNotes = editFields.technicianNotes || undefined + if (editFields.estimatedCost !== (ticket!.estimatedCost ?? '')) data.estimatedCost = editFields.estimatedCost ? parseFloat(editFields.estimatedCost) : undefined + if (editFields.actualCost !== (ticket!.actualCost ?? '')) data.actualCost = editFields.actualCost ? parseFloat(editFields.actualCost) : undefined + + if (Object.keys(data).length === 0) { + setEditing(false) + return + } + updateMutation.mutate(data) + } + const currentIdx = STATUS_FLOW.indexOf(ticket.status) const nextStatus = currentIdx >= 0 && currentIdx < STATUS_FLOW.length - 1 ? STATUS_FLOW[currentIdx + 1] : null + const isTerminal = ['picked_up', 'delivered', 'cancelled'].includes(ticket.status) + + function handleStatusClick(status: string) { + if (hasPermission('repairs.edit') && ticket && status !== ticket.status) { + statusMutation.mutate(status) + } + } const lineItemColumns: Column[] = [ - { - key: 'item_type', - header: 'Type', - sortable: true, - render: (i) => {i.itemType.replace('_', ' ')}, - }, + { key: 'item_type', header: 'Type', sortable: true, render: (i) => {i.itemType.replace('_', ' ')} }, { key: 'description', header: 'Description', render: (i) => <>{i.description} }, { key: 'qty', header: 'Qty', render: (i) => <>{i.qty} }, { key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice} }, { key: 'total_price', header: 'Total', render: (i) => ${i.totalPrice} }, { - key: 'actions', - header: '', - render: (i) => hasPermission('repairs.admin') ? ( + key: 'actions', header: '', render: (i) => hasPermission('repairs.admin') ? ( @@ -112,6 +165,7 @@ function RepairTicketDetailPage() { return (
+ {/* Header */}
- - {STATUS_LABELS[ticket.status]} -
+ {/* Status Progress Bar */} + + + + + + {/* Status Actions */} - {hasPermission('repairs.edit') && ticket.status !== 'cancelled' && ticket.status !== 'picked_up' && ticket.status !== 'delivered' && ( + {hasPermission('repairs.edit') && !isTerminal && (
{nextStatus && ( )} + {ticket.status === 'pending_parts' && ( + + )} {hasPermission('repairs.admin') && (
)} - {/* Ticket Details */} -
- - Customer - -
Name: {ticket.customerName}
-
Phone: {ticket.customerPhone ?? '-'}
-
Account: {ticket.accountId ?? 'Walk-in'}
-
-
- - Instrument - -
Description: {ticket.instrumentDescription ?? '-'}
-
Serial: {ticket.serialNumber ?? '-'}
-
Condition: {ticket.conditionIn ?? '-'}
-
-
-
+ {/* Reopen for cancelled tickets */} + {ticket.status === 'cancelled' && hasPermission('repairs.admin') && ( + + )} + {/* Ticket Details — View or Edit */} - Problem & Notes - -
Problem: {ticket.problemDescription}
- {ticket.conditionInNotes &&
Condition Notes: {ticket.conditionInNotes}
} - {ticket.technicianNotes &&
Tech Notes: {ticket.technicianNotes}
} -
-
Estimate: {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}
-
Actual: {ticket.actualCost ? `$${ticket.actualCost}` : '-'}
-
Promised: {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}
-
+ + Details + {hasPermission('repairs.edit') && !editing && ( + + )} + {editing && ( +
+ + +
+ )} +
+ + {editing ? ( +
+
+
+ + setEditFields((p) => ({ ...p, customerName: e.target.value }))} /> +
+
+ + setEditFields((p) => ({ ...p, customerPhone: e.target.value }))} /> +
+
+
+
+ + setEditFields((p) => ({ ...p, instrumentDescription: e.target.value }))} /> +
+
+ + setEditFields((p) => ({ ...p, serialNumber: e.target.value }))} /> +
+
+
+
+ + +
+
+ + setEditFields((p) => ({ ...p, estimatedCost: e.target.value }))} /> +
+
+ + setEditFields((p) => ({ ...p, actualCost: e.target.value }))} /> +
+
+
+ +