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:
ryan
2026-04-06 01:37:01 +00:00
parent 082e388799
commit 0411df57eb
3 changed files with 424 additions and 2 deletions

View File

@@ -0,0 +1,93 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { usePOSStore } from '@/stores/pos.store'
import type { RepairTicket } from '@/types/repair'
import type { PaginatedResponse } from '@lunarfront/shared/schemas'
import { RepairWorkbench } from './repair-workbench'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Wrench } from 'lucide-react'
const STATUS_LABELS: Record<string, string> = {
new: 'New', intake: 'Intake', diagnosing: 'Diagnosing',
pending_approval: 'Pending', approved: 'Approved',
in_progress: 'In Progress', pending_parts: 'Parts',
ready: 'Ready',
}
export function RepairTechView() {
const cashier = usePOSStore((s) => s.cashier)
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
// Fetch tickets assigned to current user (active statuses only)
const { data } = useQuery({
queryKey: ['repair-tickets', 'tech-assigned', cashier?.id],
queryFn: () => api.get<PaginatedResponse<RepairTicket>>('/v1/repair-tickets', {
page: 1,
limit: 50,
sort: 'created_at',
order: 'asc',
q: undefined,
// Filter to active statuses — the API will return all if no status filter, we filter client-side
}),
enabled: !!cashier?.id,
staleTime: 15_000,
refetchInterval: 30_000,
})
// Filter to tickets assigned to this technician in active statuses
const activeStatuses = ['diagnosing', 'pending_approval', 'approved', 'in_progress', 'pending_parts', 'ready']
const myTickets = (data?.data ?? []).filter(t =>
t.assignedTechnicianId === cashier?.id && activeStatuses.includes(t.status)
)
// Auto-select first ticket
if (!selectedTicketId && myTickets.length > 0) {
setSelectedTicketId(myTickets[0].id)
}
if (myTickets.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center space-y-2">
<Wrench className="h-12 w-12 mx-auto opacity-20" />
<p className="text-lg font-medium">No assigned tickets</p>
<p className="text-sm">Tickets assigned to you will appear here</p>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Ticket selector */}
{myTickets.length > 1 && (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border shrink-0 bg-muted/30">
<span className="text-sm text-muted-foreground">Ticket:</span>
<Select value={selectedTicketId ?? ''} onValueChange={setSelectedTicketId}>
<SelectTrigger className="h-8 w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{myTickets.map(t => (
<SelectItem key={t.id} value={t.id}>
<span className="flex items-center gap-2">
#{t.ticketNumber} {t.customerName}
<Badge variant="outline" className="text-[10px]">{STATUS_LABELS[t.status] ?? t.status}</Badge>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{myTickets.length} active</span>
</div>
)}
{/* Workbench */}
<div className="flex-1 min-h-0 overflow-hidden">
{selectedTicketId && <RepairWorkbench ticketId={selectedTicketId} />}
</div>
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -1,4 +1,5 @@
import { RepairDeskView } from './repair-desk-view'
import { RepairTechView } from './repair-tech-view'
interface RepairsStationProps {
permissions: string[]
@@ -7,6 +8,11 @@ interface RepairsStationProps {
export function RepairsStation({ permissions }: RepairsStationProps) {
const canEdit = permissions.includes('repairs.edit')
// TODO: Phase 3 — if user only has repairs.view (technician), show RepairTechView instead
// Front desk (can edit/intake) gets the desk view with queue + intake
// Technician (view only) gets the focused workbench
if (canEdit) {
return <RepairDeskView canEdit={canEdit} />
}
return <RepairTechView />
}