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