Add repair notes journal with running feed, visibility, and status tagging

New repair_note table for timestamped journal entries on tickets. Each
note captures author, content, visibility (internal or customer-facing),
and the ticket status at time of writing. Notes display as a running
feed on the ticket detail page with newest first. Internal notes have
a lock icon, customer-visible notes highlighted in blue. Supports add
and delete with appropriate permission gating.
This commit is contained in:
Ryan Moon
2026-03-29 10:27:39 -05:00
parent 01cff80f2b
commit 7eac03f6c2
11 changed files with 334 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { RepairTicket, RepairLineItem, RepairBatch, RepairServiceTemplate } from '@/types/repair'
import type { RepairTicket, RepairLineItem, RepairBatch, RepairNote, RepairServiceTemplate } from '@/types/repair'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
// --- Repair Tickets ---
@@ -89,6 +89,27 @@ export function repairBatchTicketsOptions(batchId: string, params: PaginationInp
})
}
// --- Repair Notes ---
export const repairNoteKeys = {
all: (ticketId: string) => ['repair-tickets', ticketId, 'notes'] as const,
}
export function repairNoteListOptions(ticketId: string) {
return queryOptions({
queryKey: repairNoteKeys.all(ticketId),
queryFn: () => api.get<{ data: RepairNote[] }>(`/v1/repair-tickets/${ticketId}/notes`),
enabled: !!ticketId,
})
}
export const repairNoteMutations = {
create: (ticketId: string, data: Record<string, unknown>) =>
api.post<RepairNote>(`/v1/repair-tickets/${ticketId}/notes`, data),
delete: (id: string) =>
api.del<RepairNote>(`/v1/repair-notes/${id}`),
}
// --- Repair Service Templates ---
export const repairServiceTemplateKeys = {

View File

@@ -0,0 +1,167 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { repairNoteListOptions, repairNoteMutations, repairNoteKeys } from '@/api/repairs'
import { useAuthStore } from '@/stores/auth.store'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Send, Trash2, Eye, Lock } from 'lucide-react'
import { toast } from 'sonner'
import type { RepairNote } from '@/types/repair'
const STATUS_LABELS: Record<string, string> = {
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',
}
interface TicketNotesProps {
ticketId: string
}
export function TicketNotes({ ticketId }: TicketNotesProps) {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const [content, setContent] = useState('')
const [visibility, setVisibility] = useState<'internal' | 'customer'>('internal')
const { data } = useQuery(repairNoteListOptions(ticketId))
const notes = data?.data ?? []
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairNoteMutations.create(ticketId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: repairNoteKeys.all(ticketId) })
setContent('')
toast.success('Note added')
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: repairNoteMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: repairNoteKeys.all(ticketId) })
toast.success('Note removed')
},
onError: (err) => toast.error(err.message),
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!content.trim()) return
createMutation.mutate({ content: content.trim(), visibility })
}
function formatDate(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
}
return (
<div className="space-y-4">
{/* Add note form */}
{hasPermission('repairs.edit') && (
<form onSubmit={handleSubmit} className="space-y-2">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a note..."
rows={3}
/>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
type="button"
onClick={() => setVisibility('internal')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
visibility === 'internal' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border'
}`}
>
<Lock className="h-3 w-3" />Internal
</button>
<button
type="button"
onClick={() => setVisibility('customer')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
visibility === 'customer' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border'
}`}
>
<Eye className="h-3 w-3" />Customer Visible
</button>
</div>
<Button type="submit" size="sm" disabled={createMutation.isPending || !content.trim()}>
<Send className="mr-1 h-3 w-3" />
{createMutation.isPending ? 'Posting...' : 'Post Note'}
</Button>
</div>
</form>
)}
{/* Notes feed */}
{notes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No notes yet</p>
) : (
<div className="space-y-3">
{[...notes].reverse().map((note) => (
<NoteEntry
key={note.id}
note={note}
formatDate={formatDate}
canDelete={hasPermission('repairs.admin')}
onDelete={() => deleteMutation.mutate(note.id)}
/>
))}
</div>
)}
</div>
)
}
function NoteEntry({ note, formatDate, canDelete, onDelete }: {
note: RepairNote
formatDate: (d: string) => string
canDelete: boolean
onDelete: () => void
}) {
return (
<div className={`rounded-md border p-3 ${note.visibility === 'customer' ? 'border-blue-300/50 bg-blue-50/30 dark:border-blue-800/50 dark:bg-blue-950/20' : ''}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">{note.authorName}</span>
<span>{formatDate(note.createdAt)}</span>
{note.ticketStatus && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{STATUS_LABELS[note.ticketStatus] ?? note.ticketStatus}
</Badge>
)}
{note.visibility === 'customer' && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 gap-0.5">
<Eye className="h-2.5 w-2.5" />Customer
</Badge>
)}
{note.visibility === 'internal' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 gap-0.5">
<Lock className="h-2.5 w-2.5" />Internal
</Badge>
)}
</div>
{canDelete && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onDelete}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
<p className="text-sm mt-1.5 whitespace-pre-wrap">{note.content}</p>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import {
import { usePagination } from '@/hooks/use-pagination'
import { StatusProgress } from '@/components/repairs/status-progress'
import { TicketPhotos } from '@/components/repairs/ticket-photos'
import { TicketNotes } from '@/components/repairs/ticket-notes'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -334,6 +335,16 @@ function RepairTicketDetailPage() {
</CardContent>
</Card>
{/* Notes Journal */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Notes</CardTitle>
</CardHeader>
<CardContent>
<TicketNotes ticketId={ticketId} />
</CardContent>
</Card>
{/* Line Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">

View File

@@ -67,6 +67,17 @@ export interface RepairBatch {
updatedAt: string
}
export interface RepairNote {
id: string
repairTicketId: string
authorId: string
authorName: string
content: string
visibility: 'internal' | 'customer'
ticketStatus: string | null
createdAt: string
}
export interface RepairServiceTemplate {
id: string
companyId: string