diff --git a/packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx b/packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx index dbbbbc4..961d842 100644 --- a/packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx +++ b/packages/admin/src/routes/_authenticated/repair-batches/$batchId.tsx @@ -1,21 +1,22 @@ import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { repairBatchDetailOptions, repairBatchMutations, repairBatchKeys, repairBatchTicketsOptions } from '@/api/repairs' +import { repairBatchDetailOptions, repairBatchMutations, repairBatchKeys, repairBatchTicketsOptions, repairLineItemListOptions } from '@/api/repairs' import { usePagination } from '@/hooks/use-pagination' import { DataTable, type Column } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { ArrowLeft, Check, X } from 'lucide-react' +import { ArrowLeft, Check, X, Plus, FileText, Download } from 'lucide-react' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth.store' +import jsPDF from 'jspdf' import type { RepairTicket } from '@/types/repair' export const Route = createFileRoute('/_authenticated/repair-batches/$batchId')({ validateSearch: (search: Record) => ({ page: Number(search.page) || 1, - limit: Number(search.limit) || 25, + limit: Number(search.limit) || 100, q: (search.q as string) || undefined, sort: (search.sort as string) || undefined, order: (search.order as 'asc' | 'desc') || 'asc', @@ -23,11 +24,23 @@ export const Route = createFileRoute('/_authenticated/repair-batches/$batchId')( component: RepairBatchDetailPage, }) +const STATUS_LABELS: Record = { + new: 'New', in_transit: 'In Transit', intake: 'Intake', diagnosing: 'Diagnosing', + pending_approval: 'Pending Approval', approved: 'Approved', in_progress: 'In Progress', + pending_parts: 'Pending Parts', ready: 'Ready', picked_up: 'Picked Up', + delivered: 'Delivered', cancelled: 'Cancelled', +} + const ticketColumns: Column[] = [ { key: 'ticket_number', header: 'Ticket #', sortable: true, render: (t) => {t.ticketNumber} }, - { key: 'customer_name', header: 'Instrument', render: (t) => <>{t.instrumentDescription ?? '-'} }, - { key: 'status', header: 'Status', sortable: true, render: (t) => {t.status.replace('_', ' ')} }, + { key: 'instrument', header: 'Instrument', render: (t) => <>{t.instrumentDescription ?? '-'} }, { key: 'problem', header: 'Problem', render: (t) => {t.problemDescription} }, + { key: 'status', header: 'Status', sortable: true, render: (t) => {STATUS_LABELS[t.status] ?? t.status} }, + { + key: 'estimated_cost', + header: 'Estimate', + render: (t) => <>{t.estimatedCost ? `$${t.estimatedCost}` : '-'}, + }, ] function RepairBatchDetailPage() { @@ -35,6 +48,7 @@ function RepairBatchDetailPage() { const navigate = useNavigate() const queryClient = useQueryClient() const hasPermission = useAuthStore((s) => s.hasPermission) + const token = useAuthStore((s) => s.token) const { params, setPage, setSort } = usePagination() const { data: batch, isLoading } = useQuery(repairBatchDetailOptions(batchId)) @@ -75,12 +89,148 @@ function RepairBatchDetailPage() { return

Batch not found

} + const tickets = ticketsData?.data ?? [] + const repairCount = ticketsData?.pagination.total ?? 0 + const totalEstimate = tickets.reduce((sum, t) => sum + (t.estimatedCost ? parseFloat(t.estimatedCost) : 0), 0) + const totalActual = tickets.reduce((sum, t) => sum + (t.actualCost ? parseFloat(t.actualCost) : 0), 0) + function handleTicketClick(ticket: RepairTicket) { navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any }) } + function handleAddRepair() { + // Navigate to new repair with batch pre-linked + navigate({ to: '/repairs/new', search: { batchId, batchName: batch!.batchNumber ?? '' } as any }) + } + + async function generateBatchPdf() { + if (!batch) return + const doc = new jsPDF() + let y = 20 + + doc.setFontSize(18) + doc.setFont('helvetica', 'bold') + doc.text('Forte Music', 14, y) + y += 8 + doc.setFontSize(12) + doc.setFont('helvetica', 'normal') + doc.text('Repair Batch Summary', 14, y) + + doc.setFontSize(14) + doc.setFont('helvetica', 'bold') + doc.text(`Batch #${batch.batchNumber ?? ''}`, 196, 20, { align: 'right' }) + doc.setFontSize(10) + doc.setFont('helvetica', 'normal') + doc.text(STATUS_LABELS[batch.status] ?? batch.status, 196, 28, { align: 'right' }) + + y += 12 + doc.setDrawColor(200) + doc.line(14, y, 196, y) + y += 8 + + // Contact info + doc.setFontSize(10) + doc.setFont('helvetica', 'bold') + doc.text('Contact', 14, y) + y += 5 + doc.setFont('helvetica', 'normal') + if (batch.contactName) { doc.text(batch.contactName, 14, y); y += 5 } + if (batch.contactPhone) { doc.text(batch.contactPhone, 14, y); y += 5 } + if (batch.contactEmail) { doc.text(batch.contactEmail, 14, y); y += 5 } + y += 3 + + // Summary + doc.setFont('helvetica', 'bold') + doc.text(`Repairs: ${repairCount}`, 14, y) + if (batch.dueDate) doc.text(`Due: ${new Date(batch.dueDate).toLocaleDateString()}`, 100, y) + y += 8 + + if (batch.notes) { + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + const noteLines = doc.splitTextToSize(batch.notes, 180) + doc.text(noteLines, 14, y) + y += noteLines.length * 4 + 4 + } + + // Repairs table + doc.setDrawColor(200) + doc.line(14, y, 196, y) + y += 6 + + doc.setFontSize(10) + doc.setFont('helvetica', 'bold') + doc.text('Repairs', 14, y) + y += 6 + + // Table header + doc.setFontSize(8) + doc.setFillColor(245, 245, 245) + doc.rect(14, y - 3, 182, 6, 'F') + doc.text('Ticket #', 16, y) + doc.text('Instrument', 40, y) + doc.text('Problem', 100, y) + doc.text('Status', 155, y) + doc.text('Estimate', 190, y, { align: 'right' }) + y += 5 + + doc.setFont('helvetica', 'normal') + for (const ticket of tickets) { + if (y > 270) { doc.addPage(); y = 20 } + doc.text(ticket.ticketNumber ?? '-', 16, y) + doc.text((ticket.instrumentDescription ?? '-').slice(0, 30), 40, y) + doc.text(ticket.problemDescription.slice(0, 28), 100, y) + doc.text(STATUS_LABELS[ticket.status] ?? ticket.status, 155, y) + doc.text(ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-', 190, y, { align: 'right' }) + y += 5 + } + + // Totals + y += 3 + doc.setDrawColor(200) + doc.line(140, y, 196, y) + y += 5 + doc.setFont('helvetica', 'bold') + doc.setFontSize(10) + doc.text('Estimated Total:', 155, y, { align: 'right' }) + doc.text(`$${totalEstimate.toFixed(2)}`, 190, y, { align: 'right' }) + y += 5 + if (totalActual > 0) { + doc.text('Actual Total:', 155, y, { align: 'right' }) + doc.text(`$${totalActual.toFixed(2)}`, 190, y, { align: 'right' }) + y += 5 + } + + // Footer + y = 280 + doc.setFontSize(8) + doc.setFont('helvetica', 'normal') + doc.setTextColor(150) + doc.text(`Generated ${new Date().toLocaleString()} — Batch #${batch.batchNumber}`, 105, y, { align: 'center' }) + + const filename = `repair-batch-${batch.batchNumber}.pdf` + doc.save(filename) + + // Upload to batch documents + const blob = doc.output('blob') + const formData = new FormData() + formData.append('file', new File([blob], filename, { type: 'application/pdf' })) + formData.append('entityType', 'repair_ticket') + formData.append('entityId', batchId) + formData.append('category', 'document') + try { + await fetch('/v1/files', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }) + } catch { /* non-critical */ } + + toast.success('Batch PDF downloaded') + } + return ( -
+
- {batch.status.replace('_', ' ')} + + {STATUS_LABELS[batch.status] ?? batch.status} {batch.approvalStatus}
{/* Actions */} -
+
{hasPermission('repairs.admin') && batch.approvalStatus === 'pending' && ( <> )} + {hasPermission('repairs.edit') && batch.status === 'completed' && ( + + )}
{/* Batch Info */} -
+
Contact @@ -126,11 +282,12 @@ function RepairBatchDetailPage() { - Details + Summary -
Instruments: {batch.receivedCount}/{batch.instrumentCount}
+
Repairs: {repairCount}
Due: {batch.dueDate ? new Date(batch.dueDate).toLocaleDateString() : '-'}
-
Estimated Total: {batch.estimatedTotal ? `$${batch.estimatedTotal}` : '-'}
+
Estimated Total: ${totalEstimate.toFixed(2)}
+ {totalActual > 0 &&
Actual Total: ${totalActual.toFixed(2)}
} {batch.notes &&
Notes: {batch.notes}
}
@@ -138,15 +295,22 @@ function RepairBatchDetailPage() { {/* Tickets in batch */} - Tickets ({ticketsData?.pagination.total ?? 0}) + + Repairs ({repairCount}) + {hasPermission('repairs.edit') && ( + + )} + (null) + const [showAccountDropdown, setShowAccountDropdown] = useState(false) + + const { data: accountsData } = useQuery( + accountListOptions({ page: 1, limit: 20, q: accountSearch || undefined, order: 'asc', sort: 'name' }), + ) const { register, @@ -35,7 +42,6 @@ function NewRepairBatchPage() { contactName: '', contactPhone: '', contactEmail: '', - instrumentCount: 0, notes: '', }, }) @@ -49,10 +55,28 @@ function NewRepairBatchPage() { onError: (err) => toast.error(err.message), }) + function selectAccount(account: Account) { + setSelectedAccount(account) + setShowAccountDropdown(false) + setAccountSearch('') + setValue('accountId', account.id) + if (account.phone) setValue('contactPhone', account.phone) + if (account.email) setValue('contactEmail', account.email) + setValue('contactName', account.name) + } + + function clearAccount() { + setSelectedAccount(null) + setValue('accountId', '') + setValue('contactName', '') + setValue('contactPhone', '') + setValue('contactEmail', '') + } + const accounts = accountsData?.data ?? [] return ( -
+
- - - Batch Details - - -
mutation.mutate(data))} className="space-y-4"> -
- - - {errors.accountId &&

{errors.accountId.message}

} + mutation.mutate(data))} className="space-y-6"> + + +
+ Account + + New Account +
+
+ + {!selectedAccount ? ( +
+ +
+ + { setAccountSearch(e.target.value); setShowAccountDropdown(true) }} + onFocus={() => setShowAccountDropdown(true)} + className="pl-9" + /> +
+ {showAccountDropdown && accountSearch.length > 0 && ( +
+ {accounts.length === 0 ? ( +
No accounts found
+ ) : ( + accounts.map((a) => ( + + )) + )} +
+ )} + {errors.accountId &&

{errors.accountId.message}

} +
+ ) : ( +
+
+

{selectedAccount.name}

+
+ {selectedAccount.phone && {selectedAccount.phone}} + {selectedAccount.email && {selectedAccount.email}} + {selectedAccount.accountNumber && #{selectedAccount.accountNumber}} +
+
+ +
+ )} +
+
-
+ + + Contact & Details + + +
@@ -92,33 +160,27 @@ function NewRepairBatchPage() {
-
-
- - -
-
- - -
+
+ +
-