feat(tickets): Foto-/Datei-Anhänge an Tickets

- Anhänge im Melde-Fenster (beim Erstellen) und direkt an bestehenden Tickets:
  Vorschaubilder, Öffnen im neuen Tab, Entfernen.
- Bytes liegen in eigener Tabelle (FeedbackAttachment, lose FeedbackId ohne FK →
  übersteht den Ingest-Wipe); GET /feedback liefert nur Metadaten (id/Name/Typ/Größe),
  die Bytes über /feedback/attachments/{id}. Größenlimit 10 MB.
- Endpoints: POST /feedback/{id}/attachments (base64), GET /feedback/attachments/{id}
  (Bytes), DELETE /feedback/attachments/{id}.

Migration FeedbackAttachments. Tests: 262 Backend grün (+Upload/Serve/Delete +Validierung),
e2e Tickets Desktop+Phone grün (+Foto-Upload), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:24:02 +02:00
parent 48b138bbfd
commit 750619d3d7
15 changed files with 2423 additions and 8 deletions

View File

@@ -1,8 +1,16 @@
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
import { api } from './client'
import { api, API_BASE_URL } from './client'
const RESOURCE = '/feedback'
/** Metadaten eines Ticket-Anhangs (Bytes separat über attachmentUrl). */
export interface FeedbackAttachment {
id: string
fileName: string
contentType: string
size: number
}
/** Which view a report was filed from (matches the backend Context contract). */
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
@@ -64,6 +72,8 @@ export interface Feedback {
category: string | null
/** War die Lösung hilfreich? true=👍, false=👎, null=keine Rückmeldung. */
helpful: boolean | null
/** Angehängte Dateien/Fotos (nur Metadaten; Bytes über attachmentUrl laden). */
attachments: FeedbackAttachment[]
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
question: string | null
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
@@ -133,3 +143,41 @@ export function deleteFeedback(id: string): Promise<void> {
export function restoreFeedback(id: string): Promise<Feedback> {
return api.post<Feedback>(`${RESOURCE}/${id}/restore`, {})
}
/** Volle URL zu den Bytes eines Anhangs (für <img src> / Download). */
export function attachmentUrl(attachmentId: string): string {
return `${API_BASE_URL}${RESOURCE}/attachments/${attachmentId}`
}
/** Einen Anhang (base64) an ein Ticket hochladen. */
export function uploadAttachment(
feedbackId: string,
body: { fileName: string; contentType: string; dataBase64: string },
): Promise<FeedbackAttachment> {
return api.post<FeedbackAttachment>(`${RESOURCE}/${feedbackId}/attachments`, body)
}
/** Einen Anhang löschen. */
export function deleteAttachment(attachmentId: string): Promise<void> {
return api.delete(`${RESOURCE}/attachments/${attachmentId}`)
}
/** Eine Browser-Datei als Upload-Payload (base64) einlesen. */
export function readFileAsUpload(
file: File,
): Promise<{ fileName: string; contentType: string; dataBase64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = String(reader.result)
const comma = result.indexOf(',')
resolve({
fileName: file.name,
contentType: file.type || 'application/octet-stream',
dataBase64: comma >= 0 ? result.slice(comma + 1) : result,
})
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}

View File

@@ -9,7 +9,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { listFeedback, submitFeedback, type FeedbackContext, type FeedbackTicket } from '../api/feedback'
import {
listFeedback,
readFileAsUpload,
submitFeedback,
uploadAttachment,
type FeedbackContext,
type FeedbackTicket,
} from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
@@ -64,6 +71,7 @@ function ReportErrorDialogBody({
}
})
const [submitting, setSubmitting] = useState(false)
const [files, setFiles] = useState<File[]>([])
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Bereits gelöste Tickets einmalig laden, um beim Tippen ähnliche vorzuschlagen.
@@ -142,7 +150,7 @@ function ReportErrorDialogBody({
}
setSubmitting(true)
try {
await submitFeedback({
const created = await submitFeedback({
message: trimmed,
context: context.context,
gerbilId: context.gerbilId ?? null,
@@ -152,6 +160,14 @@ function ReportErrorDialogBody({
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
// Ausgewählte Anhänge nach dem Anlegen hochladen (Ticket-ID liegt erst jetzt vor).
for (const file of files) {
try {
await uploadAttachment(created.id, await readFileAsUpload(file))
} catch {
toast.error(t.attachmentError)
}
}
try {
localStorage.removeItem(draftKey)
} catch {
@@ -222,6 +238,26 @@ function ReportErrorDialogBody({
</div>
)}
<div className="report-error__attach">
<label className="report-error__label" htmlFor="report-error-files">
{t.attachLabel}
</label>
<input
id="report-error-files"
type="file"
accept="image/*"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
{files.length > 0 && (
<ul className="report-error__attach-list">
{files.map((f, i) => (
<li key={i}>{f.name}</li>
))}
</ul>
)}
</div>
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">

View File

@@ -20,6 +20,10 @@ import {
updateFeedback,
deleteFeedback,
restoreFeedback,
uploadAttachment,
deleteAttachment,
attachmentUrl,
readFileAsUpload,
type FeedbackTicket,
} from '../api/feedback'
import { getGerbil } from '../api/gerbils'
@@ -342,6 +346,26 @@ export default function TicketsPage() {
}
}
async function handleAddAttachment(ticket: FeedbackTicket, file: File) {
try {
await uploadAttachment(ticket.id, await readFileAsUpload(file))
tickets.reload()
toast.success(t.attachmentAdded)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
}
}
async function handleDeleteAttachment(attachmentId: string) {
try {
await deleteAttachment(attachmentId)
tickets.reload()
toast.success(t.attachmentRemoved)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
}
}
const rows = tickets.data
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
@@ -610,6 +634,8 @@ export default function TicketsPage() {
onDelete={() => handleDelete(ticket)}
onRestore={() => handleRestore(ticket)}
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
onAddAttachment={(file) => handleAddAttachment(ticket, file)}
onDeleteAttachment={handleDeleteAttachment}
/>
))}
</ul>
@@ -629,6 +655,8 @@ interface TicketCardProps {
onDelete: () => void
onRestore: () => void
onHelpful: (helpful: boolean) => void
onAddAttachment: (file: File) => void
onDeleteAttachment: (attachmentId: string) => void
}
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
@@ -656,6 +684,8 @@ function TicketCard({
onDelete,
onRestore,
onHelpful,
onAddAttachment,
onDeleteAttachment,
}: TicketCardProps) {
const toast = useToast()
const [editing, setEditing] = useState(false)
@@ -915,6 +945,73 @@ function TicketCard({
</div>
)}
{(ticket.attachments.length > 0 || !deleted) && (
<div className="ticket-card__attachments">
<span className="ticket-card__attachments-label">{t.attachmentsLabel}</span>
<div className="ticket-card__attachments-grid">
{ticket.attachments.map((att) =>
att.contentType.startsWith('image/') ? (
<a
key={att.id}
href={attachmentUrl(att.id)}
target="_blank"
rel="noopener noreferrer"
className="ticket-card__attachment"
>
<img src={attachmentUrl(att.id)} alt={att.fileName} loading="lazy" />
{!deleted && (
<button
type="button"
className="ticket-card__attachment-remove"
title={t.removeAttachment}
aria-label={t.removeAttachment}
onClick={(e) => {
e.preventDefault()
onDeleteAttachment(att.id)
}}
>
</button>
)}
</a>
) : (
<span key={att.id} className="ticket-card__attachment ticket-card__attachment--file">
<a href={attachmentUrl(att.id)} target="_blank" rel="noopener noreferrer">
{att.fileName}
</a>
{!deleted && (
<button
type="button"
className="ticket-card__attachment-remove"
title={t.removeAttachment}
aria-label={t.removeAttachment}
onClick={() => onDeleteAttachment(att.id)}
>
</button>
)}
</span>
),
)}
{!deleted && (
<label className="ticket-card__attachment-add">
+ {t.addAttachment}
<input
type="file"
accept="image/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onAddAttachment(f)
e.currentTarget.value = ''
}}
/>
</label>
)}
</div>
</div>
)}
{deleted && trashDaysLeft !== null && (
<p className="ticket-card__trash-notice">
{trashDaysLeft === 0

View File

@@ -365,6 +365,82 @@
font-size: 0.85rem;
}
/* Anhänge (Fotos/Dateien) auf einem Ticket. */
.ticket-card__attachments {
margin: 0 0 0.85rem;
}
.ticket-card__attachments-label {
display: block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-muted, #6b7280);
margin-bottom: 0.35rem;
}
.ticket-card__attachments-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.ticket-card__attachment {
position: relative;
display: inline-flex;
border-radius: 8px;
overflow: hidden;
}
.ticket-card__attachment img {
width: 72px;
height: 72px;
object-fit: cover;
display: block;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
}
.ticket-card__attachment--file {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
font-size: 0.85rem;
gap: 0.4rem;
align-items: center;
}
.ticket-card__attachment-remove {
position: absolute;
top: 2px;
right: 2px;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
width: 18px;
height: 18px;
line-height: 1;
font-size: 0.7rem;
cursor: pointer;
padding: 0;
}
.ticket-card__attachment--file .ticket-card__attachment-remove {
position: static;
background: transparent;
color: var(--color-danger, #ef4444);
}
.ticket-card__attachment-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border: 1px dashed var(--color-border, #9ca3af);
border-radius: 8px;
font-size: 0.75rem;
text-align: center;
cursor: pointer;
color: var(--color-muted, #6b7280);
padding: 0.25rem;
}
/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
.ticket-card__trash-notice {
border-left: 3px solid var(--color-danger, #ef4444);

View File

@@ -1204,6 +1204,9 @@ export const de = {
/** „Ähnliche bereits gelöste Tickets" im Melde-Fenster. */
similarTitle: 'Schon mal gelöst? Vielleicht hilft eines davon:',
similarOpen: 'ansehen',
/** Datei-Anhänge. */
attachLabel: 'Fotos anhängen (optional)',
attachmentError: 'Ein Anhang konnte nicht hochgeladen werden.',
/** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.',
@@ -1290,6 +1293,13 @@ export const de = {
helpfulNo: '👎 Nein',
helpfulThanks: 'Danke für die Rückmeldung!',
helpfulReopenNote: 'Schade — ich öffne das Ticket wieder. Bitte schreib kurz, was noch fehlt.',
/** Anhänge auf einem Ticket. */
attachmentsLabel: 'Anhänge',
addAttachment: 'Foto anhängen',
removeAttachment: 'Anhang entfernen',
attachmentAdded: 'Anhang hinzugefügt.',
attachmentRemoved: 'Anhang entfernt.',
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
/** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */