Add PDF generation modal with content picker, repairs help pages

PDF button now opens a modal where staff can select which line items,
customer-visible notes, and photos to include before generating. Defaults
to all customer notes and completed photos. Replaces the old one-click
generation.

Added 4 help/wiki pages for the Repairs module: Repairs Overview, Repair
Templates, Repair Batches, and Notes & Photos. Covers ticket workflow,
template usage, batch management, note visibility, photo phases, and
signed approval process. Updated Getting Started nav to include Repairs.
This commit is contained in:
Ryan Moon
2026-03-29 13:20:32 -05:00
parent 591be589f0
commit 916eb29895
3 changed files with 457 additions and 17 deletions

View File

@@ -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<Set<string>>(new Set())
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<FileText className="mr-2 h-4 w-4" />PDF
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Generate PDF Ticket #{ticket.ticketNumber}</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Line Items toggle */}
<div>
<button
type="button"
onClick={() => setIncludeLineItems(!includeLineItems)}
className={`flex items-center gap-2 w-full text-left px-3 py-2 rounded-md border text-sm transition-colors ${
includeLineItems ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className={`h-4 w-4 rounded border flex items-center justify-center ${includeLineItems ? 'bg-primary border-primary' : 'border-muted-foreground/40'}`}>
{includeLineItems && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span className="font-medium">Include Line Items</span>
<span className="text-muted-foreground ml-auto">{lineItems.length} items</span>
</button>
</div>
{/* Notes selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Customer Notes</Label>
<div className="flex gap-1">
<button type="button" onClick={selectAllNotes} className="text-xs text-primary hover:underline">All</button>
<span className="text-xs text-muted-foreground">|</span>
<button type="button" onClick={selectNone} className="text-xs text-primary hover:underline">None</button>
</div>
</div>
{customerNotes.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">No customer-visible notes</p>
) : (
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{customerNotes.map((note) => (
<button
key={note.id}
type="button"
onClick={() => toggleNote(note.id)}
className={`flex items-start gap-2 w-full text-left px-3 py-2 rounded-md border text-sm transition-colors ${
selectedNoteIds.has(note.id) ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className={`h-4 w-4 rounded border flex-shrink-0 mt-0.5 flex items-center justify-center ${selectedNoteIds.has(note.id) ? 'bg-primary border-primary' : 'border-muted-foreground/40'}`}>
{selectedNoteIds.has(note.id) && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{note.authorName}</span>
<span>{formatDate(note.createdAt)}</span>
<Badge variant="secondary" className="text-[9px] px-1 py-0 gap-0.5">
<Eye className="h-2 w-2" />Customer
</Badge>
</div>
<p className="text-xs mt-0.5 line-clamp-2">{note.content}</p>
</div>
</button>
))}
</div>
)}
</div>
{/* Photos selection */}
{allPhotos.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Photos ({selectedPhotoIds.size} selected)</Label>
<div className="grid grid-cols-5 gap-2 max-h-40 overflow-y-auto">
{allPhotos.map((photo) => (
<button
key={photo.id}
type="button"
onClick={() => togglePhoto(photo.id)}
className={`relative rounded-md border-2 overflow-hidden transition-colors ${
selectedPhotoIds.has(photo.id) ? 'border-primary' : 'border-transparent'
}`}
>
<AuthThumbnail path={photo.path} />
{selectedPhotoIds.has(photo.id) && (
<div className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-primary flex items-center justify-center">
<Check className="h-2.5 w-2.5 text-primary-foreground" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-[8px] text-white px-1 py-0.5 text-center">
{photo.category}
</div>
</button>
))}
</div>
</div>
)}
{/* Summary */}
<div className="text-xs text-muted-foreground border-t pt-3">
PDF will include: ticket details, {includeLineItems ? `${lineItems.length} line items` : 'no line items'}, {selectedNoteIds.size} notes{selectedPhotoIds.size > 0 ? `, ${selectedPhotoIds.size} photos` : ''}
</div>
{/* Generate button */}
<div className="flex gap-2">
<Button onClick={handleGenerate} disabled={generating} className="flex-1">
<Download className="mr-2 h-4 w-4" />
{generating ? 'Generating...' : 'Generate & Download PDF'}
</Button>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function AuthThumbnail({ path }: { path: string }) {
const token = useAuthStore((s) => s.token)
const [src, setSrc] = useState<string | null>(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 <div className="h-14 w-full bg-muted animate-pulse rounded" />
return <img src={src} alt="" className="h-14 w-full object-cover" />
}

View File

@@ -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<Record<string, string>>({})
@@ -185,18 +182,7 @@ function RepairTicketDetailPage() {
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
<p className="text-sm text-muted-foreground">{ticket.customerName} {ticket.instrumentDescription ?? 'No instrument'}</p>
</div>
<Button variant="outline" size="sm" onClick={() => {
if (!ticket) return
generateAndUploadPdf({
ticket,
lineItems: lineItemsData?.data ?? [],
notes: notesData?.data ?? [],
includeNotes: true,
}, ticketId, token)
toast.success('PDF downloaded and saved to documents')
}}>
<FileText className="mr-2 h-4 w-4" />PDF
</Button>
<PdfModal ticket={ticket} lineItems={lineItemsData?.data ?? []} ticketId={ticketId} />
</div>
{/* Status Progress Bar */}

View File

@@ -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[] {