feat: repairs station — technician workbench
- Focused single-ticket workbench with sections: Work, Parts, Photos, Notes - Template quick-add for line items - Auto-filters to assigned tickets for the logged-in technician - Ticket selector when multiple assigned - Permission routing: repairs.edit → desk view, view-only → tech workbench Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
repairTicketDetailOptions, repairTicketKeys, repairTicketMutations,
|
||||
repairLineItemListOptions, repairLineItemMutations,
|
||||
repairServiceTemplateListOptions,
|
||||
repairNoteListOptions, repairNoteMutations,
|
||||
} from '@/api/repairs'
|
||||
import { StatusProgress } from '@/components/repairs/status-progress'
|
||||
import { TicketPhotos } from '@/components/repairs/ticket-photos'
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ChevronRight, Plus, Camera, MessageSquare, Wrench, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const STATUS_FLOW = ['new', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
|
||||
|
||||
interface RepairWorkbenchProps {
|
||||
ticketId: string
|
||||
}
|
||||
|
||||
export function RepairWorkbench({ ticketId }: RepairWorkbenchProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeSection, setActiveSection] = useState<'work' | 'parts' | 'photos' | 'notes'>('work')
|
||||
const [newNote, setNewNote] = useState('')
|
||||
|
||||
// Add line item state
|
||||
const [addingItem, setAddingItem] = useState(false)
|
||||
const [newItemType, setNewItemType] = useState('part')
|
||||
const [newItemDesc, setNewItemDesc] = useState('')
|
||||
const [newItemQty, setNewItemQty] = useState('1')
|
||||
const [newItemPrice, setNewItemPrice] = useState('')
|
||||
|
||||
const { data: ticket } = useQuery({
|
||||
...repairTicketDetailOptions(ticketId),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
const { data: lineItemsData } = useQuery({
|
||||
...repairLineItemListOptions(ticketId, { page: 1, limit: 100, q: undefined, sort: undefined, order: 'asc' }),
|
||||
})
|
||||
|
||||
const { data: notesData } = useQuery(repairNoteListOptions(ticketId))
|
||||
|
||||
const { data: templatesData } = useQuery({
|
||||
...repairServiceTemplateListOptions({ page: 1, limit: 50, q: undefined, sort: 'sort_order', order: 'asc' }),
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId) })
|
||||
toast.success('Status updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const addNoteMutation = useMutation({
|
||||
mutationFn: () => repairNoteMutations.create(ticketId, { content: newNote, visibility: 'internal' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repair-tickets', ticketId, 'notes'] })
|
||||
setNewNote('')
|
||||
toast.success('Note added')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const addLineItemMutation = useMutation({
|
||||
mutationFn: () => repairLineItemMutations.create(ticketId, {
|
||||
itemType: newItemType,
|
||||
description: newItemDesc,
|
||||
qty: parseInt(newItemQty) || 1,
|
||||
unitPrice: parseFloat(newItemPrice) || 0,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repair-tickets', ticketId, 'line-items'] })
|
||||
setAddingItem(false)
|
||||
setNewItemDesc('')
|
||||
setNewItemPrice('')
|
||||
toast.success('Item added')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteLineItemMutation = useMutation({
|
||||
mutationFn: (id: string) => repairLineItemMutations.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repair-tickets', ticketId, 'line-items'] })
|
||||
toast.success('Item removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const lineItems = lineItemsData?.data ?? []
|
||||
const notes = notesData?.data ?? []
|
||||
const templates = templatesData?.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)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border shrink-0 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">#{ticket.ticketNumber}</h2>
|
||||
<p className="text-sm text-muted-foreground">{ticket.customerName} — {ticket.itemDescription ?? 'No item'}</p>
|
||||
</div>
|
||||
{nextStatus && !isTerminal && (
|
||||
<Button size="lg" className="h-12 gap-2 text-base" onClick={() => statusMutation.mutate(nextStatus)}>
|
||||
Next Step <ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<StatusProgress currentStatus={ticket.status} onStatusClick={(s) => !isTerminal && statusMutation.mutate(s)} />
|
||||
</div>
|
||||
|
||||
{/* Section toggle */}
|
||||
<div className="flex gap-1 px-4 py-2 border-b border-border shrink-0 bg-muted/30">
|
||||
{[
|
||||
{ key: 'work' as const, icon: Wrench, label: 'Work' },
|
||||
{ key: 'parts' as const, icon: Plus, label: 'Parts' },
|
||||
{ key: 'photos' as const, icon: Camera, label: 'Photos' },
|
||||
{ key: 'notes' as const, icon: MessageSquare, label: `Notes (${notes.length})` },
|
||||
].map((s) => (
|
||||
<Button
|
||||
key={s.key}
|
||||
variant={activeSection === s.key ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-9 gap-1.5"
|
||||
onClick={() => setActiveSection(s.key)}
|
||||
>
|
||||
<s.icon className="h-3.5 w-3.5" />
|
||||
{s.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeSection === 'work' && (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Problem Description</p>
|
||||
<p className="text-sm">{ticket.problemDescription}</p>
|
||||
</div>
|
||||
{ticket.conditionIn && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Condition at Intake</p>
|
||||
<Badge variant="outline">{ticket.conditionIn}</Badge>
|
||||
{ticket.conditionInNotes && <p className="text-sm text-muted-foreground mt-1">{ticket.conditionInNotes}</p>}
|
||||
</div>
|
||||
)}
|
||||
{ticket.technicianNotes && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Technician Notes</p>
|
||||
<p className="text-sm">{ticket.technicianNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex gap-4">
|
||||
{ticket.estimatedCost && <div><p className="text-xs text-muted-foreground">Estimate</p><p className="text-lg font-bold">${ticket.estimatedCost}</p></div>}
|
||||
{ticket.actualCost && <div><p className="text-xs text-muted-foreground">Actual</p><p className="text-lg font-bold">${ticket.actualCost}</p></div>}
|
||||
{ticket.promisedDate && <div><p className="text-xs text-muted-foreground">Promised</p><p className="text-lg font-bold">{new Date(ticket.promisedDate).toLocaleDateString()}</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'parts' && (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">Line Items</h3>
|
||||
<Button size="sm" onClick={() => setAddingItem(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Template quick-add */}
|
||||
{templates.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{templates.slice(0, 8).map(t => (
|
||||
<Button
|
||||
key={t.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={async () => {
|
||||
await repairLineItemMutations.create(ticketId, {
|
||||
itemType: t.itemType,
|
||||
description: t.description ?? t.name,
|
||||
qty: 1,
|
||||
unitPrice: parseFloat(t.defaultPrice),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['repair-tickets', ticketId, 'line-items'] })
|
||||
toast.success(`Added ${t.name}`)
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addingItem && (
|
||||
<div className="flex items-end gap-2 p-3 border rounded-lg bg-muted/30">
|
||||
<div className="w-24 space-y-1">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select value={newItemType} onValueChange={setNewItemType}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="labor">Labor</SelectItem>
|
||||
<SelectItem value="part">Part</SelectItem>
|
||||
<SelectItem value="flat_rate">Flat Rate</SelectItem>
|
||||
<SelectItem value="misc">Misc</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input className="h-8" value={newItemDesc} onChange={(e) => setNewItemDesc(e.target.value)} />
|
||||
</div>
|
||||
<div className="w-16 space-y-1">
|
||||
<Label className="text-xs">Qty</Label>
|
||||
<Input className="h-8" type="number" value={newItemQty} onChange={(e) => setNewItemQty(e.target.value)} />
|
||||
</div>
|
||||
<div className="w-24 space-y-1">
|
||||
<Label className="text-xs">Price</Label>
|
||||
<Input className="h-8" type="number" step="0.01" value={newItemPrice} onChange={(e) => setNewItemPrice(e.target.value)} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={() => addLineItemMutation.mutate()} disabled={!newItemDesc}>Add</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => setAddingItem(false)}>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{lineItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between py-2 px-3 rounded hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] h-5">{item.itemType}</Badge>
|
||||
<span className="text-sm">{item.description}</span>
|
||||
<span className="text-xs text-muted-foreground">x{item.qty}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">${item.totalPrice}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => deleteLineItemMutation.mutate(item.id)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{lineItems.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-between px-3 py-2 font-semibold">
|
||||
<span>Total</span>
|
||||
<span>${lineItems.reduce((s, i) => s + parseFloat(i.totalPrice), 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{lineItems.length === 0 && !addingItem && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No line items yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'photos' && (
|
||||
<TicketPhotos ticketId={ticketId} currentStatus={ticket.status} />
|
||||
)}
|
||||
|
||||
{activeSection === 'notes' && (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder="Add a note..."
|
||||
rows={2}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => addNoteMutation.mutate()}
|
||||
disabled={!newNote.trim() || addNoteMutation.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
<div key={note.id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">{note.authorName}</span>
|
||||
<span className="text-xs text-muted-foreground">{new Date(note.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="text-sm">{note.content}</p>
|
||||
{note.visibility === 'internal' && <Badge variant="outline" className="text-[10px] mt-1">Internal</Badge>}
|
||||
</div>
|
||||
))}
|
||||
{notes.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No notes yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user