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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user