feat(feedback): Fehler-melden-Dialog + ID-kopieren (Stammbaum/Akte/Wurf)

Rechtsklick auf eine Maus im Stammbaum öffnet ein Kontextmenü mit „ID kopieren"
(Clipboard + Toast) und „Fehler melden". Zusätzlich „Fehler melden"-Buttons in
der Rennmausakte und der Wurf-Ansicht. Der Dialog erfasst eine Beschreibung und
sendet sie samt Debug-Kontext (Ansicht, Tier-/Wurf-ID + Name, URL, Zeitstempel,
User-Agent) an POST /feedback; gespeichert in einer neuen Feedback-Tabelle.

Backend: Feedback-Entity (lose nullable GerbilId/LitterId ohne FK), Endpoints
POST/GET /feedback, EF-Migration AddFeedback. Die Tabelle wird vom Import-Ingest
NICHT geleert — Feedback überlebt Re-Ingests (Test deckt das ab).

Tests: FeedbackEndpointTests (persistiert, 400 bei leer, übersteht Ingest-Wipe);
e2e feedback.spec.ts. tsc/eslint/vitest(129)/playwright(6)/dotnet(212) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:30:42 +02:00
parent 1845d37476
commit b9e5b031c4
20 changed files with 2586 additions and 2 deletions

View File

@@ -0,0 +1,162 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests;
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
/// - Validation: an empty message is rejected with 400.
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
/// </summary>
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Post_feedback_persists_row_with_debug_context()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var resp = await client.PostAsJsonAsync("/feedback", new
{
message = "Der Stammbaum zeigt den falschen Vater.",
context = "stammbaum",
gerbilId,
litterId = (Guid?)null,
entityName = "Krümel",
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
clientTimestamp = "2026-06-22T12:00:00Z",
});
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
// GET returns it (newest first)
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("entityName").GetString() == "Krümel"
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater.");
}
[Fact]
public async Task Post_feedback_rejects_empty_message()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Feedback_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false }
},
Litters = new[]
{
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
},
Gerbils = new[]
{
Animal(fatherId, "Papa", "male", contactId),
Animal(motherId, "Mama", "female", contactId),
},
GerbilPhotos = Array.Empty<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// A feedback report referencing the gerbil + litter that the wipe will delete.
var feedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = feedbackId,
Message = "Bitte prüfen.",
Context = "gerbil-detail",
GerbilId = fatherId,
LitterId = litterId,
EntityName = "Papa",
Url = "http://localhost/rennmaeuse/papa",
CreatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
.Build();
// Run the ingest wipe + reload.
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/litters were wiped & re-created, but feedback is untouched.
var survivor = await db.Feedback.SingleAsync();
Assert.Equal(feedbackId, survivor.Id);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
Assert.Equal(litterId, survivor.LitterId);
Assert.Equal("Papa", survivor.EntityName);
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
{
Id = id,
Name = name,
Gender = gender,
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-" + name,
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = name.ToLowerInvariant(),
CharacterTraits = Array.Empty<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}

View File

@@ -24,6 +24,7 @@ public class ApplicationContext : DbContext
public DbSet<Media> Media => Set<Media>(); public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>(); public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>(); public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key), // Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity. // so it can never drift from Name regardless of which code path mutates the entity.
@@ -202,6 +203,15 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>() modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId }); .HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
// Guid columns (no navigation properties → EF creates NO foreign key), so the
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
// feedback rows. They survive re-ingest, which is the whole point.
modelBuilder.Entity<Feedback>(e =>
{
e.HasIndex(f => f.CreatedAt);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql) if (isNpgsql)
{ {

View File

@@ -0,0 +1,25 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
public record FeedbackInput(
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp);
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
public record FeedbackDto(
Guid Id,
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp,
string? UserAgent,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,62 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
/// GET /feedback -> list reports, newest first (for later review).
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
/// rows survive the import re-ingest wipe.
/// </summary>
public static class FeedbackEndpoints
{
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
FeedbackInput input, ApplicationContext db, HttpContext http) =>
{
if (string.IsNullOrWhiteSpace(input.Message))
return TypedResults.BadRequest("Message darf nicht leer sein.");
var entity = new Feedback
{
Id = Guid.NewGuid(),
Message = input.Message.Trim(),
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
GerbilId = input.GerbilId,
LitterId = input.LitterId,
EntityName = input.EntityName,
Url = input.Url,
ClientTimestamp = input.ClientTimestamp,
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
CreatedAt = DateTimeOffset.UtcNow,
};
db.Feedback.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
});
group.MapGet("/", async (ApplicationContext db) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
var rows = await db.Feedback.AsNoTracking().ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt)
.Select(ToDto)
.ToList());
});
return app;
}
private static FeedbackDto ToDto(Feedback f) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedback : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Feedback",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Context = table.Column<string>(type: "text", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
EntityName = table.Column<string>(type: "text", nullable: true),
Url = table.Column<string>(type: "text", nullable: true),
ClientTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
UserAgent = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Feedback", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Feedback_CreatedAt",
table: "Feedback",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Feedback");
}
}
}

View File

@@ -793,6 +793,48 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("EnclosurePhotos"); b.ToTable("EnclosurePhotos");
}); });
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Context")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName")
.HasColumnType("text");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.HasColumnType("text");
b.Property<string>("UserAgent")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("Feedback");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the
/// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced
/// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete
/// gerbils/litters without deleting or breaking feedback rows. The captured EntityName
/// keeps the report human-readable even after the referenced animal is gone.
/// </summary>
public class Feedback
{
[Key]
public Guid Id { get; set; }
/// <summary>The user's free-text description of the problem.</summary>
public required string Message { get; set; }
/// <summary>Which view the report came from: stammbaum | gerbil-detail | litter-detail.</summary>
public required string Context { get; set; }
/// <summary>Loose reference (no FK) to the gerbil the report is about, if any.</summary>
public Guid? GerbilId { get; set; }
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
public Guid? LitterId { get; set; }
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
public string? EntityName { get; set; }
/// <summary>The client URL/route the report was filed from.</summary>
public string? Url { get; set; }
/// <summary>Client-supplied timestamp (when the user submitted, in their browser).</summary>
public DateTimeOffset? ClientTimestamp { get; set; }
/// <summary>Optional browser user-agent for diagnostics.</summary>
public string? UserAgent { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -126,6 +126,7 @@ app.MapExportEndpoints();
app.MapCmsEndpoints(); app.MapCmsEndpoints();
app.MapRequestEndpoints(); app.MapRequestEndpoints();
app.MapNamesEndpoints(); app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.Run(); app.Run();

View File

@@ -0,0 +1,85 @@
/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const f = de.feedback
const st = de.pages.stammbaum
test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
// Debug-Kontext ist sichtbar (automatisch erfasst).
await expect(dialog).toContainText(f.debugTitle)
await expect(dialog).toContainText(f.contexts['gerbil-detail'])
// Beschreibung eintragen + senden.
await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
// Erfolgs-Toast + Dialog schließt.
await expect(page.getByText(f.success)).toBeVisible()
await expect(dialog).not.toBeVisible()
// Der Bericht wurde mit Debug-Kontext gespeichert.
expect(mockDb).not.toBeNull()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({
message: 'Der Farbschlag stimmt nicht.',
context: 'gerbil-detail',
gerbilId: 'kruemel',
})
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
expect(typeof reports[0].clientTimestamp).toBe('string')
})
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['litter-detail'])
await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
})
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Rechtsklick auf die Wurzelkarte (Krümel).
await page.locator('.pedigree-card--root').click({ button: 'right' })
const menu = page.locator('.stammbaum-context-menu')
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible()
// "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext.
await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts.stammbaum)
await dialog.getByLabel(f.label).fill('Stammbaum-Bug.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
})

View File

@@ -397,6 +397,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, result) return json(route, 200, result)
} }
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
if (path === '/feedback') {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = {
id: newId('feedback'),
...body,
userAgent: request.headers()['user-agent'] ?? null,
createdAt: new Date().toISOString(),
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id> // Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined const col = m ? collections[m[1]] : undefined

View File

@@ -76,6 +76,8 @@ export interface MockDb {
saleAdConfigured: boolean saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren // FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
} }
function gerbil( function gerbil(
@@ -324,5 +326,6 @@ export function seedDb(): MockDb {
contracts: [], contracts: [],
saleAdConfigured: true, saleAdConfigured: true,
namesConfigured: true, namesConfigured: true,
feedback: [],
} }
} }

View File

@@ -0,0 +1,35 @@
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
import { api } from './client'
const RESOURCE = '/feedback'
/** Which view a report was filed from (matches the backend Context contract). */
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail'
/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */
export interface FeedbackInput {
message: string
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
entityName?: string | null
url?: string | null
clientTimestamp?: string | null
}
export interface Feedback {
id: string
message: string
context: string
gerbilId: string | null
litterId: string | null
entityName: string | null
url: string | null
clientTimestamp: string | null
userAgent: string | null
createdAt: string
}
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
return api.post<Feedback>(RESOURCE, body)
}

View File

@@ -0,0 +1,171 @@
/**
* FEEDBACK: "Fehler melden" — a small accessible modal with a textarea + submit.
*
* The caller passes the debug context (which view, the gerbil/litter id + name); the
* dialog captures the current URL and a client timestamp itself and POSTs everything
* to /feedback. Success/error is surfaced via the existing toast system.
*/
import { useEffect, useRef, useState } from 'react'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { submitFeedback, type FeedbackContext } from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
entityName?: string | null
}
interface ReportErrorDialogProps {
open: boolean
onClose: () => void
context: ReportErrorContext
}
/**
* Thin wrapper: mounts the dialog body only while open, so its form state starts
* fresh on every open (no reset-in-effect needed).
*/
export default function ReportErrorDialog({ open, onClose, context }: ReportErrorDialogProps) {
if (!open) return null
return <ReportErrorDialogBody onClose={onClose} context={context} />
}
function ReportErrorDialogBody({
onClose,
context,
}: {
onClose: () => void
context: ReportErrorContext
}) {
const t = de.feedback
const toast = useToast()
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Focus the textarea once on mount.
useEffect(() => {
const id = window.setTimeout(() => textareaRef.current?.focus(), 0)
return () => window.clearTimeout(id)
}, [])
// Close on Escape.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const contextLabel = t.contexts[context.context]
async function handleSubmit() {
const trimmed = message.trim()
if (!trimmed) {
toast.error(t.emptyMessage)
textareaRef.current?.focus()
return
}
setSubmitting(true)
try {
await submitFeedback({
message: trimmed,
context: context.context,
gerbilId: context.gerbilId ?? null,
litterId: context.litterId ?? null,
entityName: context.entityName ?? null,
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
toast.success(t.success)
onClose()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.error)
setSubmitting(false)
}
}
return (
<div
className="report-error__backdrop"
onClick={onClose}
role="presentation"
>
<div
className="report-error__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="report-error-title"
onClick={(e) => e.stopPropagation()}
>
<div className="report-error__header">
<h2 id="report-error-title" className="report-error__title">
{t.dialogTitle}
</h2>
<button
type="button"
className="report-error__close"
onClick={onClose}
aria-label={t.close}
>
</button>
</div>
<p className="report-error__intro">{t.intro}</p>
<label className="report-error__label" htmlFor="report-error-message">
{t.label}
</label>
<textarea
id="report-error-message"
ref={textareaRef}
className="report-error__textarea"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={t.placeholder}
rows={5}
/>
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">
<div className="report-error__debug-row">
<dt>{t.debugFields.context}</dt>
<dd>{contextLabel}</dd>
</div>
{context.entityName && (
<div className="report-error__debug-row">
<dt>{t.debugFields.entity}</dt>
<dd>{context.entityName}</dd>
</div>
)}
<div className="report-error__debug-row">
<dt>{t.debugFields.url}</dt>
<dd className="report-error__debug-url">{window.location.href}</dd>
</div>
</dl>
</div>
<div className="report-error__actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>
{t.cancel}
</button>
<button
type="button"
className="btn btn--primary"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? t.submitting : t.submit}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,149 @@
/* FEEDBACK: "Fehler melden" modal dialog. */
.report-error__backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(43, 33, 25, 0.45);
}
.report-error__dialog {
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
padding: 1.25rem;
animation: reportErrorIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes reportErrorIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.report-error__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.report-error__title {
margin: 0;
font-size: 1.15rem;
}
.report-error__close {
background: none;
border: none;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted, #666);
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.report-error__close:hover {
background: var(--color-border, #eee);
}
.report-error__intro {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.report-error__label {
display: block;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.3rem;
}
.report-error__textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 0.95rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 8px;
resize: vertical;
min-height: 5.5rem;
background: var(--color-bg, #fff);
color: var(--color-text);
}
.report-error__textarea:focus {
outline: 2px solid var(--color-accent, #2563eb);
outline-offset: 1px;
}
.report-error__debug {
margin-top: 0.9rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 8px;
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
}
.report-error__debug-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.report-error__debug-list {
margin: 0;
display: grid;
gap: 0.25rem;
}
.report-error__debug-row {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.report-error__debug-row dt {
flex: 0 0 5.5rem;
font-weight: 600;
color: var(--color-text-muted, #777);
}
.report-error__debug-row dd {
margin: 0;
min-width: 0;
word-break: break-word;
}
.report-error__debug-url {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
}
.report-error__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

View File

@@ -14,6 +14,7 @@ import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab' import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto' import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
import GerbilWeightTab from '../components/GerbilWeightTab' import GerbilWeightTab from '../components/GerbilWeightTab'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix' import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast' import { useToast } from '../components/toast'
import './rennmausakte.css' import './rennmausakte.css'
@@ -57,6 +58,7 @@ export default function GerbilDetailPage() {
const toast = useToast() const toast = useToast()
const { id = '' } = useParams() const { id = '' } = useParams()
const [tab, setTab] = useState<DetailTab>('photos') const [tab, setTab] = useState<DetailTab>('photos')
const [reportOpen, setReportOpen] = useState(false)
const gerbil = useApi(() => getGerbil(id), [id]) const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' })) const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
@@ -256,6 +258,9 @@ export default function GerbilDetailPage() {
{de.pages.vertraege.wizard.title} {de.pages.vertraege.wizard.title}
</Link> </Link>
)} )}
<button type="button" className="ak-btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/rennmaeuse" className="ak-btn"> <Link to="/rennmaeuse" className="ak-btn">
{t.detail.back} {t.detail.back}
</Link> </Link>
@@ -471,6 +476,12 @@ export default function GerbilDetailPage() {
</div> </div>
</section> </section>
</div> </div>
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }}
/>
</section> </section>
) )
} }

View File

@@ -16,7 +16,7 @@
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per * - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export. * @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree' import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree' import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
@@ -29,6 +29,8 @@ import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon' import GerbilIcon from '../components/GerbilIcon'
import ReportErrorDialog, { type ReportErrorContext } from '../components/ReportErrorDialog'
import { useToast } from '../components/toast'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
@@ -90,6 +92,64 @@ export default function StammbaumPage() {
const t = de.pages.stammbaum const t = de.pages.stammbaum
const { id = '' } = useParams() const { id = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
/* ── Rechtsklick-Kontextmenü auf einer Ahnenkarte ── */
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
gerbil: Gerbil
} | null>(null)
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
const openContextMenu = useCallback((e: ReactMouseEvent, gerbil: Gerbil) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, gerbil })
}, [])
const closeContextMenu = useCallback(() => setContextMenu(null), [])
const copyId = useCallback(
async (gerbilId: string) => {
closeContextMenu()
try {
await navigator.clipboard.writeText(gerbilId)
toast.success(de.feedback.idCopied)
} catch {
toast.error(de.feedback.idCopyFailed)
}
},
[closeContextMenu, toast],
)
const openReport = useCallback(
(gerbil: Gerbil) => {
closeContextMenu()
setReportContext({
context: 'stammbaum',
gerbilId: gerbil.id,
entityName: gerbil.name || de.pages.gerbils.nameless,
})
},
[closeContextMenu],
)
/* Menü schließt bei Klick/Scroll/Escape außerhalb. */
useEffect(() => {
if (!contextMenu) return
const onAway = () => closeContextMenu()
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu()
}
window.addEventListener('click', onAway)
window.addEventListener('scroll', onAway, true)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('click', onAway)
window.removeEventListener('scroll', onAway, true)
window.removeEventListener('keydown', onKey)
}
}, [contextMenu, closeContextMenu])
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */ /* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
const [source] = useState(createApiPedigreeSource) const [source] = useState(createApiPedigreeSource)
@@ -288,13 +348,14 @@ export default function StammbaumPage() {
farbschlag={farbschlagOf(node.gerbil)} farbschlag={farbschlagOf(node.gerbil)}
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)} onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
onExpand={() => handleExpand(path)} onExpand={() => handleExpand(path)}
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
/> />
)} )}
</foreignObject> </foreignObject>
</g> </g>
) )
}, },
[nodesByPath, farbschlagOf, navigate, handleExpand, t], [nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, t],
) )
/* ── Zustände: Laden / Fehler ── */ /* ── Zustände: Laden / Fehler ── */
@@ -388,6 +449,42 @@ export default function StammbaumPage() {
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */} {/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} /> <PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
{contextMenu && (
<ul
className="stammbaum-context-menu"
role="menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => copyId(contextMenu.gerbil.id)}
>
{t.contextMenu.copyId}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => openReport(contextMenu.gerbil)}
>
{t.contextMenu.reportError}
</button>
</li>
</ul>
)}
<ReportErrorDialog
open={reportContext !== null}
onClose={() => setReportContext(null)}
context={reportContext ?? { context: 'stammbaum' }}
/>
</> </>
) )
} }
@@ -400,12 +497,14 @@ function PedigreeCard({
farbschlag, farbschlag,
onOpen, onOpen,
onExpand, onExpand,
onContextMenu,
}: { }: {
node: AnimalNode node: AnimalNode
isRoot: boolean isRoot: boolean
farbschlag: string | null farbschlag: string | null
onOpen: () => void onOpen: () => void
onExpand: () => void onExpand: () => void
onContextMenu: (e: ReactMouseEvent) => void
}) { }) {
const t = de.pages.stammbaum const t = de.pages.stammbaum
const g = node.gerbil const g = node.gerbil
@@ -422,6 +521,7 @@ function PedigreeCard({
<div <div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'} className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
onClick={isRoot ? undefined : onOpen} onClick={isRoot ? undefined : onOpen}
onContextMenu={onContextMenu}
role={isRoot ? undefined : 'button'} role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint} title={isRoot ? undefined : t.tapHint}
> >

View File

@@ -13,6 +13,7 @@ import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics' import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView' import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon' import GerbilIcon from '../components/GerbilIcon'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix' import { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css' import './wuerfe.css'
@@ -64,6 +65,7 @@ export default function WurfDetailPage() {
const t = de.pages.litters const t = de.pages.litters
const gerbilName = useGerbilName() const gerbilName = useGerbilName()
const { id = '' } = useParams() const { id = '' } = useParams()
const [reportOpen, setReportOpen] = useState(false)
const litter = useApi(() => getLitter(id), [id]) const litter = useApi(() => getLitter(id), [id])
const fatherId = litter.data?.fatherId ?? null const fatherId = litter.data?.fatherId ?? null
@@ -151,6 +153,9 @@ export default function WurfDetailPage() {
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary"> <Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit} {t.detail.edit}
</Link> </Link>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/wuerfe" className="btn"> <Link to="/wuerfe" className="btn">
{t.detail.back} {t.detail.back}
</Link> </Link>
@@ -224,6 +229,12 @@ export default function WurfDetailPage() {
) : ( ) : (
<p className="muted">{t.detail.needParentsGenotype}</p> <p className="muted">{t.detail.needParentsGenotype}</p>
)} )}
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
/>
</section> </section>
) )
} }

View File

@@ -488,3 +488,38 @@
font-size: 0.8em; font-size: 0.8em;
color: #555; color: #555;
} }
/* FEEDBACK: Rechtsklick-Kontextmenü auf einer Ahnenkarte. */
.stammbaum-context-menu {
position: fixed;
z-index: 150;
margin: 0;
padding: 0.25rem;
list-style: none;
min-width: 11rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(43, 33, 25, 0.22);
}
.stammbaum-context-menu__item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
font: inherit;
font-size: 0.9rem;
color: var(--color-text);
padding: 0.5rem 0.7rem;
border-radius: 5px;
cursor: pointer;
}
.stammbaum-context-menu__item:hover,
.stammbaum-context-menu__item:focus-visible {
background: var(--color-accent, #2563eb);
color: #fff;
outline: none;
}

View File

@@ -438,6 +438,11 @@ export const de = {
createdOn: 'Erstellt am', createdOn: 'Erstellt am',
born: 'geb.', born: 'geb.',
}, },
/** FEEDBACK: Rechtsklick-Kontextmenü auf einer Stammbaum-Karte. */
contextMenu: {
copyId: 'ID kopieren',
reportError: 'Fehler melden',
},
}, },
// ── FEAT-7 (Kelly): Statistik & Berichte ── // ── FEAT-7 (Kelly): Statistik & Berichte ──
statistik: { statistik: {
@@ -842,6 +847,37 @@ export const de = {
unknown: 'Ein unbekannter Fehler ist aufgetreten.', unknown: 'Ein unbekannter Fehler ist aufgetreten.',
}, },
}, },
// ── FEEDBACK: "Fehler melden" Dialog + "ID kopieren" Aktion ──
feedback: {
/** Button-Beschriftung (Tierakte, Wurf-Ansicht). */
button: 'Fehler melden',
dialogTitle: 'Fehler melden',
intro: 'Beschreibe kurz, was nicht stimmt. Technische Angaben werden automatisch mitgesendet.',
label: 'Beschreibung',
placeholder: 'Was funktioniert nicht oder ist falsch dargestellt?',
/** Überschrift über den automatisch erfassten Debug-Angaben. */
debugTitle: 'Automatisch erfasst',
debugFields: {
context: 'Ansicht',
entity: 'Datensatz',
url: 'Adresse',
},
contexts: {
stammbaum: 'Stammbaum',
'gerbil-detail': 'Rennmausakte',
'litter-detail': 'Wurf',
},
submit: 'Senden',
submitting: 'Wird gesendet …',
cancel: 'Abbrechen',
close: 'Schließen',
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
error: 'Fehlerbericht konnte nicht gesendet werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
/** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.',
},
common: { common: {
loading: 'Lädt …', loading: 'Lädt …',
retry: 'Erneut versuchen', retry: 'Erneut versuchen',