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

@@ -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