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 }))} /> +
+
+
+ +