diff --git a/packages/admin/src/components/station-repairs/repair-tech-view.tsx b/packages/admin/src/components/station-repairs/repair-tech-view.tsx new file mode 100644 index 0000000..dc91125 --- /dev/null +++ b/packages/admin/src/components/station-repairs/repair-tech-view.tsx @@ -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 = { + 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(null) + + // Fetch tickets assigned to current user (active statuses only) + const { data } = useQuery({ + queryKey: ['repair-tickets', 'tech-assigned', cashier?.id], + queryFn: () => api.get>('/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 ( +
+
+ +

No assigned tickets

+

Tickets assigned to you will appear here

+
+
+ ) + } + + return ( +
+ {/* Ticket selector */} + {myTickets.length > 1 && ( +
+ Ticket: + + {myTickets.length} active +
+ )} + + {/* Workbench */} +
+ {selectedTicketId && } +
+
+ ) +} diff --git a/packages/admin/src/components/station-repairs/repair-workbench.tsx b/packages/admin/src/components/station-repairs/repair-workbench.tsx new file mode 100644 index 0000000..0705e73 --- /dev/null +++ b/packages/admin/src/components/station-repairs/repair-workbench.tsx @@ -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 ( +
+
+
+ ) + } + + 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 ( +
+ {/* Header */} +
+
+
+

#{ticket.ticketNumber}

+

{ticket.customerName} — {ticket.itemDescription ?? 'No item'}

+
+ {nextStatus && !isTerminal && ( + + )} +
+ !isTerminal && statusMutation.mutate(s)} /> +
+ + {/* Section toggle */} +
+ {[ + { 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) => ( + + ))} +
+ + {/* Content */} +
+ {activeSection === 'work' && ( +
+
+

Problem Description

+

{ticket.problemDescription}

+
+ {ticket.conditionIn && ( +
+

Condition at Intake

+ {ticket.conditionIn} + {ticket.conditionInNotes &&

{ticket.conditionInNotes}

} +
+ )} + {ticket.technicianNotes && ( +
+

Technician Notes

+

{ticket.technicianNotes}

+
+ )} + +
+ {ticket.estimatedCost &&

Estimate

${ticket.estimatedCost}

} + {ticket.actualCost &&

Actual

${ticket.actualCost}

} + {ticket.promisedDate &&

Promised

{new Date(ticket.promisedDate).toLocaleDateString()}

} +
+
+ )} + + {activeSection === 'parts' && ( +
+
+

Line Items

+ +
+ + {/* Template quick-add */} + {templates.length > 0 && ( +
+ {templates.slice(0, 8).map(t => ( + + ))} +
+ )} + + {addingItem && ( +
+
+ + +
+
+ + setNewItemDesc(e.target.value)} /> +
+
+ + setNewItemQty(e.target.value)} /> +
+
+ + setNewItemPrice(e.target.value)} /> +
+ + +
+ )} + +
+ {lineItems.map((item) => ( +
+
+ {item.itemType} + {item.description} + x{item.qty} +
+
+ ${item.totalPrice} + +
+
+ ))} + {lineItems.length > 0 && ( + <> + +
+ Total + ${lineItems.reduce((s, i) => s + parseFloat(i.totalPrice), 0).toFixed(2)} +
+ + )} + {lineItems.length === 0 && !addingItem && ( +

No line items yet

+ )} +
+
+ )} + + {activeSection === 'photos' && ( + + )} + + {activeSection === 'notes' && ( +
+
+