Add photo attachments to repair notes
Notes can now have N photos attached. Attach Photo button in the note form allows selecting multiple images before posting. Photos are uploaded after the note is created, linked via entityType repair_note. Photos display inline in each note entry as clickable thumbnails that open via signed URL.
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
import { repairNoteListOptions, repairNoteMutations, repairNoteKeys } from '@/api/repairs'
|
import { repairNoteListOptions, repairNoteMutations, repairNoteKeys } from '@/api/repairs'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
import { useAuthStore } from '@/stores/auth.store'
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Send, Trash2, Eye, Lock } from 'lucide-react'
|
import { Send, Trash2, Eye, Lock, ImageIcon, X } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { RepairNote } from '@/types/repair'
|
import type { RepairNote } from '@/types/repair'
|
||||||
|
|
||||||
@@ -23,6 +25,29 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
cancelled: 'Cancelled',
|
cancelled: 'Cancelled',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileRecord {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteFilesOptions(noteId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['files', 'repair_note', noteId],
|
||||||
|
queryFn: () => api.get<{ data: FileRecord[] }>('/v1/files', { entityType: 'repair_note', entityId: noteId }),
|
||||||
|
enabled: !!noteId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSignedFile(fileId: string) {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ url: string }>(`/v1/files/signed-url/${fileId}`)
|
||||||
|
window.open(res.url, '_blank')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to open file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TicketNotesProps {
|
interface TicketNotesProps {
|
||||||
ticketId: string
|
ticketId: string
|
||||||
}
|
}
|
||||||
@@ -30,22 +55,16 @@ interface TicketNotesProps {
|
|||||||
export function TicketNotes({ ticketId }: TicketNotesProps) {
|
export function TicketNotes({ ticketId }: TicketNotesProps) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
const token = useAuthStore((s) => s.token)
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [visibility, setVisibility] = useState<'internal' | 'customer'>('internal')
|
const [visibility, setVisibility] = useState<'internal' | 'customer'>('internal')
|
||||||
|
const [photos, setPhotos] = useState<File[]>([])
|
||||||
|
const [posting, setPosting] = useState(false)
|
||||||
|
const photoInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const { data } = useQuery(repairNoteListOptions(ticketId))
|
const { data } = useQuery(repairNoteListOptions(ticketId))
|
||||||
const notes = data?.data ?? []
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: repairNoteMutations.delete,
|
mutationFn: repairNoteMutations.delete,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -55,10 +74,48 @@ export function TicketNotes({ ticketId }: TicketNotesProps) {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!content.trim()) return
|
if (!content.trim()) return
|
||||||
createMutation.mutate({ content: content.trim(), visibility })
|
setPosting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the note
|
||||||
|
const note = await repairNoteMutations.create(ticketId, { content: content.trim(), visibility })
|
||||||
|
|
||||||
|
// Upload attached photos to the note
|
||||||
|
for (const photo of photos) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', photo)
|
||||||
|
formData.append('entityType', 'repair_note')
|
||||||
|
formData.append('entityId', note.id)
|
||||||
|
formData.append('category', 'attachment')
|
||||||
|
await fetch('/v1/files', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: repairNoteKeys.all(ticketId) })
|
||||||
|
setContent('')
|
||||||
|
setPhotos([])
|
||||||
|
toast.success('Note added')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to post note')
|
||||||
|
} finally {
|
||||||
|
setPosting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPhotos(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const files = Array.from(e.target.files ?? [])
|
||||||
|
setPhotos((prev) => [...prev, ...files])
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(index: number) {
|
||||||
|
setPhotos((prev) => prev.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
function formatDate(dateStr: string) {
|
||||||
@@ -78,6 +135,25 @@ export function TicketNotes({ ticketId }: TicketNotesProps) {
|
|||||||
placeholder="Add a note..."
|
placeholder="Add a note..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Photo previews */}
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{photos.map((photo, i) => (
|
||||||
|
<div key={i} className="relative group">
|
||||||
|
<img src={URL.createObjectURL(photo)} alt="" className="h-16 w-16 object-cover rounded-md border" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => removePhoto(i)}
|
||||||
|
>
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -98,12 +174,28 @@ export function TicketNotes({ ticketId }: TicketNotesProps) {
|
|||||||
>
|
>
|
||||||
<Eye className="h-3 w-3" />Customer Visible
|
<Eye className="h-3 w-3" />Customer Visible
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => photoInputRef.current?.click()}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border border-border text-muted-foreground hover:border-primary hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-3 w-3" />Attach Photo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm" disabled={createMutation.isPending || !content.trim()}>
|
<Button type="submit" size="sm" disabled={posting || !content.trim()}>
|
||||||
<Send className="mr-1 h-3 w-3" />
|
<Send className="mr-1 h-3 w-3" />
|
||||||
{createMutation.isPending ? 'Posting...' : 'Post Note'}
|
{posting ? 'Posting...' : 'Post Note'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={photoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={addPhotos}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -133,10 +225,13 @@ function NoteEntry({ note, formatDate, canDelete, onDelete }: {
|
|||||||
canDelete: boolean
|
canDelete: boolean
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { data: filesData } = useQuery(noteFilesOptions(note.id))
|
||||||
|
const photos = filesData?.data ?? []
|
||||||
|
|
||||||
return (
|
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={`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-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
|
||||||
<span className="font-semibold text-foreground">{note.authorName}</span>
|
<span className="font-semibold text-foreground">{note.authorName}</span>
|
||||||
<span>{formatDate(note.createdAt)}</span>
|
<span>{formatDate(note.createdAt)}</span>
|
||||||
{note.ticketStatus && (
|
{note.ticketStatus && (
|
||||||
@@ -162,6 +257,21 @@ function NoteEntry({ note, formatDate, canDelete, onDelete }: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm mt-1.5 whitespace-pre-wrap">{note.content}</p>
|
<p className="text-sm mt-1.5 whitespace-pre-wrap">{note.content}</p>
|
||||||
|
|
||||||
|
{/* Inline photos */}
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<button key={photo.id} type="button" onClick={() => openSignedFile(photo.id)}>
|
||||||
|
<img
|
||||||
|
src={`/v1/files/serve/${photo.path}`}
|
||||||
|
alt={photo.filename}
|
||||||
|
className="h-24 w-24 object-cover rounded-md border cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user