diff --git a/packages/admin/src/components/repairs/pdf-modal.tsx b/packages/admin/src/components/repairs/pdf-modal.tsx new file mode 100644 index 0000000..51d481e --- /dev/null +++ b/packages/admin/src/components/repairs/pdf-modal.tsx @@ -0,0 +1,265 @@ +import { useState, useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +import { repairNoteListOptions } from '@/api/repairs' +import { api } from '@/lib/api-client' +import { useAuthStore } from '@/stores/auth.store' +import { generateAndUploadPdf } from './generate-pdf' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { FileText, Download, Check, Eye, Lock } from 'lucide-react' +import { toast } from 'sonner' +import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair' + +interface FileRecord { + id: string + path: string + filename: string + category: 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, + }) +} + +interface PdfModalProps { + ticket: RepairTicket + lineItems: RepairLineItem[] + ticketId: string +} + +export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) { + const [open, setOpen] = useState(false) + const [generating, setGenerating] = useState(false) + const token = useAuthStore((s) => s.token) + + // Fetch notes and photos + const { data: notesData } = useQuery(repairNoteListOptions(ticketId)) + const { data: filesData } = useQuery(ticketFilesOptions(ticketId)) + + const allNotes = notesData?.data ?? [] + const customerNotes = allNotes.filter((n) => n.visibility === 'customer') + const allPhotos = (filesData?.data ?? []).filter((f) => f.category !== 'document') + + // Selection state + const [selectedNoteIds, setSelectedNoteIds] = useState>(new Set()) + const [selectedPhotoIds, setSelectedPhotoIds] = useState>(new Set()) + const [includeLineItems, setIncludeLineItems] = useState(true) + + // Default: select all customer-visible notes and completed photos + useEffect(() => { + if (open) { + setSelectedNoteIds(new Set(customerNotes.map((n) => n.id))) + const completedPhotos = allPhotos.filter((p) => p.category === 'completed') + setSelectedPhotoIds(new Set(completedPhotos.length > 0 ? completedPhotos.map((p) => p.id) : [])) + setIncludeLineItems(true) + } + }, [open, notesData, filesData]) + + function toggleNote(id: string) { + setSelectedNoteIds((prev) => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + } + + function togglePhoto(id: string) { + setSelectedPhotoIds((prev) => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + } + + function selectAllNotes() { + setSelectedNoteIds(new Set(customerNotes.map((n) => n.id))) + } + + function selectNone() { + setSelectedNoteIds(new Set()) + } + + async function handleGenerate() { + setGenerating(true) + try { + const selectedNotes = allNotes.filter((n) => selectedNoteIds.has(n.id)) + await generateAndUploadPdf( + { + ticket, + lineItems: includeLineItems ? lineItems : [], + notes: selectedNotes, + includeNotes: selectedNotes.length > 0, + }, + ticketId, + token, + ) + toast.success('PDF generated and saved to documents') + setOpen(false) + } catch (err) { + toast.error('Failed to generate PDF') + } finally { + setGenerating(false) + } + } + + function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + } + + return ( + + + + + + + Generate PDF — Ticket #{ticket.ticketNumber} + + +
+ {/* Line Items toggle */} +
+ +
+ + {/* Notes selection */} +
+
+ +
+ + | + +
+
+ {customerNotes.length === 0 ? ( +

No customer-visible notes

+ ) : ( +
+ {customerNotes.map((note) => ( + + ))} +
+ )} +
+ + {/* Photos selection */} + {allPhotos.length > 0 && ( +
+ +
+ {allPhotos.map((photo) => ( + + ))} +
+
+ )} + + {/* Summary */} +
+ PDF will include: ticket details, {includeLineItems ? `${lineItems.length} line items` : 'no line items'}, {selectedNoteIds.size} notes{selectedPhotoIds.size > 0 ? `, ${selectedPhotoIds.size} photos` : ''} +
+ + {/* Generate button */} +
+ + +
+
+
+
+ ) +} + +function AuthThumbnail({ path }: { path: string }) { + const token = useAuthStore((s) => s.token) + const [src, setSrc] = useState(null) + + useEffect(() => { + let cancelled = false + let blobUrl: string | null = null + async function load() { + try { + const res = await fetch(`/v1/files/serve/${path}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!res.ok || cancelled) return + const blob = await res.blob() + if (!cancelled) { + blobUrl = URL.createObjectURL(blob) + setSrc(blobUrl) + } + } catch { /* ignore */ } + } + load() + return () => { + cancelled = true + if (blobUrl) URL.revokeObjectURL(blobUrl) + } + }, [path, token]) + + if (!src) return
+ return +} diff --git a/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx b/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx index 4afb009..963d957 100644 --- a/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx +++ b/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx @@ -20,9 +20,8 @@ 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, RotateCcw, Save, FileText, Search } from 'lucide-react' -import { generateAndUploadPdf } from '@/components/repairs/generate-pdf' -import { repairNoteListOptions } from '@/api/repairs' +import { ArrowLeft, Plus, Trash2, RotateCcw, Save, Search } from 'lucide-react' +import { PdfModal } from '@/components/repairs/pdf-modal' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth.store' import type { RepairLineItem } from '@/types/repair' @@ -76,8 +75,6 @@ function RepairTicketDetailPage() { const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId)) const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params)) - const { data: notesData } = useQuery(repairNoteListOptions(ticketId)) - const token = useAuthStore((s) => s.token) const [editFields, setEditFields] = useState>({}) @@ -185,18 +182,7 @@ function RepairTicketDetailPage() {

Ticket #{ticket.ticketNumber}

{ticket.customerName} — {ticket.instrumentDescription ?? 'No instrument'}

- + {/* Status Progress Bar */} diff --git a/packages/admin/src/wiki/index.ts b/packages/admin/src/wiki/index.ts index 4d106f8..0faee77 100644 --- a/packages/admin/src/wiki/index.ts +++ b/packages/admin/src/wiki/index.ts @@ -34,6 +34,8 @@ Use the sidebar on the left to navigate between sections: - **Accounts** — manage customer accounts and their members - **Members** — find and manage individual people across all accounts +- **Repairs** — track instrument repair tickets +- **Repair Batches** — manage bulk school repair jobs - **Help** — you're here! ## Need Help? @@ -297,6 +299,193 @@ Choose your preferred mode and color theme: Your preferences are saved in your browser and persist across sessions. `.trim(), }, + { + slug: 'repairs-overview', + title: 'Repairs Overview', + category: 'Repairs', + content: ` +# Repairs + +The Repairs module tracks instrument repair tickets from intake through completion. It supports walk-in customers, account-linked repairs, and bulk school batch jobs. + +## Creating a Repair Ticket + +1. Go to **Repairs** in the sidebar +2. Click **New Repair** +3. Search for an existing account or enter customer details manually for walk-ins +4. Describe the instrument and the problem +5. Optionally add line items for the estimate (use templates for common services) +6. Add intake photos to document the instrument's condition +7. Click **Create Ticket** + +## Ticket Status Flow + +Each ticket moves through these stages: + +- **New** — ticket just created, not yet examined +- **In Transit** — instrument being transported to the shop (for school pickups or shipped instruments) +- **Intake** — instrument received, condition documented +- **Diagnosing** — technician examining the instrument +- **Pending Approval** — estimate provided, waiting for customer OK +- **Approved** — customer authorized the work +- **In Progress** — actively being repaired +- **Pending Parts** — waiting on parts order +- **Ready** — repair complete, awaiting pickup +- **Picked Up** — customer collected the instrument +- **Delivered** — instrument returned via delivery (for school batches) + +Click the status buttons on the ticket detail page to advance through the workflow. You can also click steps on the progress bar. + +## Ticket Detail Page + +The ticket detail has four tabs: + +- **Details** — customer info, instrument, condition, costs. Click **Edit** to modify. +- **Line Items** — labor, parts, flat-rate services, and misc charges. Use the template picker for common repairs. +- **Notes** — running journal of notes. Choose **Internal** (staff only) or **Customer Visible**. You can attach photos to notes. +- **Photos & Docs** — photos organized by repair phase (intake, in progress, completed) plus a documents section for signed approvals, quotes, and receipts. + +## Generating a PDF + +Click the **PDF** button in the ticket header to generate a customer-facing document: + +1. Choose whether to include line items +2. Select which customer-visible notes to include +3. Select which photos to include +4. Click **Generate & Download PDF** + +The PDF is both downloaded and automatically saved to the ticket's Documents section. + `.trim(), + }, + { + slug: 'repair-templates', + title: 'Repair Templates', + category: 'Repairs', + content: ` +# Repair Service Templates + +Templates are pre-defined common repairs (e.g. "Bow Rehair — Violin — 4/4") that staff can quickly add to tickets instead of typing everything manually. + +## Managing Templates + +1. Go to **Repair Templates** in the sidebar (admin only) +2. Click **New Template** +3. Fill in: + - **Name** — e.g. "Bow Rehair", "String Change", "Valve Overhaul" + - **Instrument Type** — e.g. "Violin", "Guitar", "Trumpet" + - **Size** — e.g. "4/4", "3/4", "Full" + - **Type** — Labor, Part, Flat Rate, or Misc + - **Default Price** — the customer-facing price + - **Internal Cost** — your cost (for margin tracking) +4. Click **Create Template** + +## Using Templates + +When creating a ticket or adding line items, type in the **Quick Add from Template** search box. Select a template to auto-fill the type, description, and price. You can modify the values after selection. + +## Examples + +- Bow Rehair — Violin — 4/4 — $65 +- Bow Rehair — Cello — $80 +- String Change — Guitar — $25 +- Valve Overhaul — Trumpet — $85 +- Pad Replacement — Clarinet — $120 +- Cork Replacement — Clarinet — $45 + `.trim(), + }, + { + slug: 'repair-batches', + title: 'Repair Batches', + category: 'Repairs', + content: ` +# Repair Batches + +Batches group multiple repair tickets under one job — typically for schools bringing in many instruments at once. + +## Creating a Batch + +1. Go to **Repair Batches** in the sidebar +2. Click **New Batch** +3. Select the school's account +4. Enter contact info, instrument count, and any notes +5. Click **Create Batch** + +## Adding Tickets to a Batch + +When creating new repair tickets, select the batch in the form. Each instrument gets its own ticket linked to the batch. + +## Batch Approval + +Batches have a separate approval workflow: + +- **Pending** — batch created, not yet approved +- **Approved** — work authorized (click **Approve** on the batch detail page) +- **Rejected** — work declined + +Only admins can approve or reject batches. + +## Batch Status + +- **Intake** — receiving instruments +- **In Progress** — work underway +- **Completed** — all repairs done +- **Delivered** — instruments returned to school + +## Filtering + +On the Repairs list, use the **Filters** panel to: + +- Toggle **Batch only** to see only batch tickets +- Toggle **Individual only** to see only non-batch tickets + `.trim(), + }, + { + slug: 'repair-notes-photos', + title: 'Notes & Photos', + category: 'Repairs', + content: ` +# Repair Notes & Photos + +## Notes + +Notes are a running journal on each repair ticket. Every note records who wrote it, when, and what status the ticket was in at the time. + +**Visibility options:** + +- **Internal** — only visible to staff. Use for technician observations, internal discussions, customer contact notes (e.g. "Called customer, approved via phone"). +- **Customer Visible** — will appear in PDFs and (eventually) the customer portal. Use for lesson summaries, work completed descriptions, and pickup instructions. + +**Attaching photos to notes:** + +1. Click **Attach Photo** in the note form +2. Select one or more images +3. Preview appears below the text area +4. Click **Post Note** — photos upload with the note + +Photos appear inline in the note entry. Click to view full size. + +## Photos by Phase + +The Photos & Docs tab organizes photos into categories: + +- **Intake Photos** — document instrument condition when received +- **Work in Progress** — during the repair +- **Completed** — final result after repair +- **Documents** — signed approvals, quotes, receipts (accepts PDFs) + +The active category highlights based on the ticket's current status, so the most relevant section is always prominent. + +## Signed Approvals + +For pending approval tickets, you can: + +1. Generate a PDF quote (via the PDF button) +2. Send it to the customer +3. When they approve, upload the signed copy to Documents +4. Or add a note: "Customer approved via phone call on [date]" +5. Then move the ticket to **Approved** status + `.trim(), + }, ] export function getWikiPages(): WikiPage[] {