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:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user