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:
265
packages/admin/src/components/repairs/pdf-modal.tsx
Normal file
265
packages/admin/src/components/repairs/pdf-modal.tsx
Normal 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" />
|
||||||
|
}
|
||||||
@@ -20,9 +20,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import { ArrowLeft, Plus, Trash2, RotateCcw, Save, FileText, Search } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, RotateCcw, Save, Search } from 'lucide-react'
|
||||||
import { generateAndUploadPdf } from '@/components/repairs/generate-pdf'
|
import { PdfModal } from '@/components/repairs/pdf-modal'
|
||||||
import { repairNoteListOptions } from '@/api/repairs'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useAuthStore } from '@/stores/auth.store'
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
import type { RepairLineItem } from '@/types/repair'
|
import type { RepairLineItem } from '@/types/repair'
|
||||||
@@ -76,8 +75,6 @@ function RepairTicketDetailPage() {
|
|||||||
|
|
||||||
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
|
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
|
||||||
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
|
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>>({})
|
const [editFields, setEditFields] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
@@ -185,18 +182,7 @@ function RepairTicketDetailPage() {
|
|||||||
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
|
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{ticket.customerName} — {ticket.instrumentDescription ?? 'No instrument'}</p>
|
<p className="text-sm text-muted-foreground">{ticket.customerName} — {ticket.instrumentDescription ?? 'No instrument'}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => {
|
<PdfModal ticket={ticket} lineItems={lineItemsData?.data ?? []} ticketId={ticketId} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Progress Bar */}
|
{/* Status Progress Bar */}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ Use the sidebar on the left to navigate between sections:
|
|||||||
|
|
||||||
- **Accounts** — manage customer accounts and their members
|
- **Accounts** — manage customer accounts and their members
|
||||||
- **Members** — find and manage individual people across all accounts
|
- **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!
|
- **Help** — you're here!
|
||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
@@ -297,6 +299,193 @@ Choose your preferred mode and color theme:
|
|||||||
Your preferences are saved in your browser and persist across sessions.
|
Your preferences are saved in your browser and persist across sessions.
|
||||||
`.trim(),
|
`.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[] {
|
export function getWikiPages(): WikiPage[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user