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

@@ -148,6 +148,64 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
}
[Fact]
public async Task Attachment_upload_list_serve_delete_lifecycle()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Foto vom Fellschlag.",
context = "gerbil-detail",
});
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// 1×1-PNG hochladen.
const string pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
{
fileName = "maus.png",
contentType = "image/png",
dataBase64 = pngB64,
});
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
var attId = att.GetProperty("id").GetString();
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
Assert.True(att.GetProperty("size").GetInt32() > 0);
// GET /feedback liefert die Metadaten (ohne Bytes).
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
// Bytes ausliefern.
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
// Löschen -> danach keine Anhänge mehr.
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
}
[Fact]
public async Task Attachment_upload_rejects_invalid_base64()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
{
fileName = "x.png",
contentType = "image/png",
dataBase64 = "###nicht base64###",
});
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
}
[Fact]
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
{

View File

@@ -25,6 +25,7 @@ public class ApplicationContext : DbContext
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();

View File

@@ -20,6 +20,19 @@ namespace GerbilManagerWebAPI.Dtos
string Text,
DateTimeOffset? At);
/// <summary>FEEDBACK: lightweight metadata for an attachment (no bytes).</summary>
public record FeedbackAttachmentDto(
Guid Id,
string FileName,
string ContentType,
int Size);
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
public record FeedbackAttachmentInput(
string FileName,
string ContentType,
string DataBase64);
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
public record FeedbackDto(
Guid Id,
@@ -51,7 +64,9 @@ namespace GerbilManagerWebAPI.Dtos
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
string? Category = null,
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
bool? Helpful = null);
bool? Helpful = null,
/// <summary>Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}.</summary>
IReadOnlyList<FeedbackAttachmentDto>? Attachments = null);
/// <summary>
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,

View File

@@ -21,6 +21,9 @@ namespace GerbilManagerWebAPI.Endpoints
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
private const int TrashRetentionDays = 30;
/// <summary>Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB.</summary>
private const int MaxAttachmentBytes = 10 * 1024 * 1024;
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
@@ -69,9 +72,21 @@ namespace GerbilManagerWebAPI.Endpoints
rows = rows.Except(expired).ToList();
}
// Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen.
var attMeta = await db.FeedbackAttachments
.Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size })
.ToListAsync();
var byTicket = attMeta
.GroupBy(a => a.FeedbackId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<FeedbackAttachmentDto>)g
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
.ToList());
return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt)
.Select(ToDto)
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
.ToList());
});
@@ -222,6 +237,73 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(ToDto(entity));
});
// ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes.
group.MapPost("/{id:guid}/attachments", async Task<Results<Created<FeedbackAttachmentDto>, NotFound, BadRequest<string>>> (
Guid id, FeedbackAttachmentInput input, ApplicationContext db) =>
{
var ticket = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (ticket is null)
return TypedResults.NotFound();
if (string.IsNullOrWhiteSpace(input.DataBase64) || string.IsNullOrWhiteSpace(input.FileName))
return TypedResults.BadRequest("FileName und Daten sind erforderlich.");
byte[] bytes;
try
{
// erlaubt sowohl reines base64 als auch eine data:-URL
var raw = input.DataBase64;
var comma = raw.IndexOf(',');
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
raw = raw[(comma + 1)..];
bytes = Convert.FromBase64String(raw);
}
catch (FormatException)
{
return TypedResults.BadRequest("Daten sind kein gültiges base64.");
}
if (bytes.Length == 0)
return TypedResults.BadRequest("Datei ist leer.");
if (bytes.Length > MaxAttachmentBytes)
return TypedResults.BadRequest($"Datei zu groß (max. {MaxAttachmentBytes / (1024 * 1024)} MB).");
var att = new FeedbackAttachment
{
Id = Guid.NewGuid(),
FeedbackId = id,
FileName = input.FileName.Trim(),
ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? "application/octet-stream" : input.ContentType.Trim(),
Size = bytes.Length,
Data = bytes,
CreatedAt = DateTimeOffset.UtcNow,
};
db.FeedbackAttachments.Add(att);
await db.SaveChangesAsync();
return TypedResults.Created($"/feedback/attachments/{att.Id}",
new FeedbackAttachmentDto(att.Id, att.FileName, att.ContentType, att.Size));
});
// ANHANG-Bytes ausliefern (für <img>/Download).
group.MapGet("/attachments/{attId:guid}", async Task<Results<FileContentHttpResult, NotFound>> (
Guid attId, ApplicationContext db) =>
{
var att = await db.FeedbackAttachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == attId);
if (att is null)
return TypedResults.NotFound();
return TypedResults.File(att.Data, att.ContentType, att.FileName);
});
// ANHANG löschen.
group.MapDelete("/attachments/{attId:guid}", async Task<Results<NoContent, NotFound>> (
Guid attId, ApplicationContext db) =>
{
var att = await db.FeedbackAttachments.FirstOrDefaultAsync(a => a.Id == attId);
if (att is null)
return TypedResults.NotFound();
db.FeedbackAttachments.Remove(att);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
@@ -271,10 +353,11 @@ namespace GerbilManagerWebAPI.Endpoints
}
}
private static FeedbackDto ToDto(Feedback f) =>
private static FeedbackDto ToDto(Feedback f, IReadOnlyList<FeedbackAttachmentDto>? attachments = null) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful);
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful,
attachments ?? Array.Empty<FeedbackAttachmentDto>());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class FeedbackAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
ContentType = table.Column<string>(type: "text", nullable: false),
Size = table.Column<int>(type: "integer", nullable: false),
Data = table.Column<byte[]>(type: "bytea", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackAttachments", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackAttachments");
}
}
}

View File

@@ -960,6 +960,38 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Feedback");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("bytea");
b.Property<Guid>("FeedbackId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Size")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("FeedbackAttachments");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// An image/file attached to a feedback ticket (z. B. ein Foto vom Tier/Fellschlag/Stammbaum).
/// Like <see cref="Feedback"/> it is decoupled (loose FeedbackId, no FK) so it survives the
/// import re-ingest wipe. The bytes live in the DB (single-user app, gelegentliche Fotos) —
/// die Liste GET /feedback liefert nur Metadaten, die Bytes kommen über einen eigenen Endpoint.
/// </summary>
public class FeedbackAttachment
{
[Key]
public Guid Id { get; set; }
/// <summary>Loose reference (no FK) to the feedback ticket this belongs to.</summary>
public Guid FeedbackId { get; set; }
public required string FileName { get; set; }
public required string ContentType { get; set; }
/// <summary>Größe in Bytes (separat gespeichert, damit Listen-Abfragen die Bytes nicht laden).</summary>
public int Size { get; set; }
/// <summary>The raw file bytes.</summary>
public required byte[] Data { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -435,15 +435,51 @@ export async function installMockApi(page: Page): Promise<MockDb> {
deletedAt: null,
category: null,
helpful: null,
attachments: [],
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
// attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht).
const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] }))
return json(route, 200, rows)
}
return json(route, 405)
}
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
if (attUpload && method === 'POST') {
const fid = decodeURIComponent(attUpload[1])
const row = db.feedback.find((f) => f.id === fid)
if (!row) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string }
const meta = {
id: newId('att'),
fileName: body.fileName ?? 'datei',
contentType: body.contentType ?? 'application/octet-stream',
size: (body.dataBase64 ?? '').length,
}
const list = (row.attachments as unknown[] | undefined) ?? []
list.push(meta)
row.attachments = list
return json(route, 201, meta)
}
// FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}).
const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/)
if (attDelete && method === 'DELETE') {
const attId = decodeURIComponent(attDelete[1])
for (const f of db.feedback) {
const list = (f.attachments as { id: string }[] | undefined) ?? []
const i = list.findIndex((a) => a.id === attId)
if (i >= 0) {
list.splice(i, 1)
f.attachments = list
return json(route, 204)
}
}
return json(route, 404, { title: 'Not Found' })
}
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
if (restoreMatch) {

View File

@@ -311,4 +311,25 @@ test.describe('Meine Tickets', () => {
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible()
})
test('Foto an ein Ticket anhängen erscheint als Vorschaubild', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Offenes Ticket (Fridolin) — Anhang-Bereich mit „Foto anhängen".
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
await expect(card.getByText(tt.attachmentsLabel, { exact: true })).toBeVisible()
// 1×1-PNG als Datei hochladen.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
)
await card.locator('input[type="file"]').setInputFiles({
name: 'maus.png',
mimeType: 'image/png',
buffer: png,
})
// Nach dem Upload erscheint ein Vorschaubild im Anhang-Raster.
await expect(card.locator('.ticket-card__attachment img')).toHaveCount(1)
})
})

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). */