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:
162
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
162
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
25
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
25
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal 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);
|
||||||
|
}
|
||||||
62
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
62
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal file
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
44
GerbilManagerWebAPI/Models/Feedback.cs
Normal file
44
GerbilManagerWebAPI/Models/Feedback.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ app.MapExportEndpoints();
|
|||||||
app.MapCmsEndpoints();
|
app.MapCmsEndpoints();
|
||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
app.MapNamesEndpoints();
|
app.MapNamesEndpoints();
|
||||||
|
app.MapFeedbackEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
85
gerbil-manager-web/e2e/feedback.spec.ts
Normal file
85
gerbil-manager-web/e2e/feedback.spec.ts
Normal 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' })
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
gerbil-manager-web/src/api/feedback.ts
Normal file
35
gerbil-manager-web/src/api/feedback.ts
Normal 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)
|
||||||
|
}
|
||||||
171
gerbil-manager-web/src/components/ReportErrorDialog.tsx
Normal file
171
gerbil-manager-web/src/components/ReportErrorDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
gerbil-manager-web/src/components/reportErrorDialog.css
Normal file
149
gerbil-manager-web/src/components/reportErrorDialog.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user