Compare commits

..

29 Commits

Author SHA1 Message Date
2befe53df9 feat(stammbaum): Desktop-Ansicht passt den ganzen Baum ein + höhere Zeichenfläche
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m3s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m30s
Auf dem Desktop wurde der Baum bei festem Zoom 1 rechts abgeschnitten (man sah
nur 1–2 Generationen, der Rest lag außerhalb) und viel Fläche blieb leer. Die
Standard-Ansicht passt jetzt den gesamten geladenen Baum ein — gedeckelt auf
Zoom 1, damit Karten bei kleinen Bäumen nicht vergrößert/unscharf werden. Mobil
bleibt es bei „Wurzel links + Pan" (sonst würden die Karten winzig). Zeichen-
fläche von 62dvh auf 72dvh erhöht (füllt die Desktop-Höhe besser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:26:50 +02:00
cf672c0933 feat(provenance): Datenherkunft als chronologischer, datei-attribuierter Verlauf
Die Herkunft liest sich jetzt wie eine History: pro Feld wird genannt, aus
welcher Quelldatei der Wert kam, plus Merge-/Entscheidungs-/Wurfchronik-Schritte.
Beispiel: „In ‚X.xlsx' gefunden." → „Geburtsdatum (…) aus ‚X.xlsx'." → „Auch in
‚Y.docx' gefunden → zusammengeführt." → „Eltern über Position erkannt (Quelle …)."

Import: build_entity_provenance() um history[] erweitert; ein field_source-
Tracking im Gerbil-Dedup hält fest, welcher Datensatz/welche Datei jedes Feld
(DOB/Genotyp/Geschlecht/Farbschlag/Sterbedatum) lieferte — auch über Empty-Fill
und Mehrheitsentscheid hinweg. Kontakte und Würfe erhalten analoge, datei-
attribuierte Verläufe. Kein DB-Migration nötig (history liegt im Provenance-JSON).

Frontend: ProvenanceDialog rendert den Verlauf als nummerierte Timeline.
Tests: history wird erzeugt, nennt Dateien, überlebt Ingest (dotnet 212,
vitest 129, playwright 17, python merge/extract grün).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:21:24 +02:00
a31fea17fa feat(stammbaum): Kontextmenü per Long-Press auf Touch-Geräten öffnen
Das Rechtsklick-Kontextmenü (ID kopieren / Fehler melden) war auf Mobil nicht
erreichbar (onContextMenu feuert auf Touch nicht zuverlässig). Jetzt öffnet ein
langer Druck (~500 ms ohne Verschieben) auf eine Ahnenkarte dasselbe Menü; der
darauf folgende synthetische Klick (Umwurzeln) wird unterdrückt. Bewegung >10 px
bricht den Long-Press ab (kollidiert nicht mit Pan/Zoom). Legenden-Hinweis
ergänzt. e2e: Long-Press öffnet das Menü (desktop; phone-Projekt übersprungen,
da synthetischer Touch dort instabil).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:09:47 +02:00
438c816820 feat: Fehler-melden + Datenherkunft auf Kontakte & Würfe erweitern
- Kontakt-Detailseite: „Fehler melden"- und „Datenherkunft"-Button.
- Wurf-Ansicht: „Datenherkunft"-Button (Feedback war bereits vorhanden).
- Feedback-Entity um loses, nullable ContactId erweitert (kein FK → übersteht
  Ingest-Wipe); Migration AddFeedbackContactId.
- Contact.Provenance + Litter.Provenance (nullable text); Migration
  AddContactLitterProvenance; im Ingest gemappt und in den DTOs zurückgegeben.
- Import: build_entity_provenance() generalisiert; Kontakte (sourceFiles,
  Züchter/Abnehmer-Hinweise) und Würfe (Wurfchronik vs. Diagramm-rekonstruiert,
  Geschwister-Merge) erhalten Herkunftsdaten in resolved_import.json.
- Frontend: ProvenanceDialog generalisiert (EntityProvenance + entityLabel).

Tests erweitert (Ingest-Round-trip Kontakt/Wurf, contact-scoped Feedback
übersteht Wipe). dotnet(212)/vitest(129)/playwright(36)/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:02:11 +02:00
9c98c37d2a feat(rennmausakte): Datenherkunft/Nachverfolgung pro Rennmaus
Neuer „Datenherkunft"-Button in der Rennmausakte öffnet einen Dialog, der zeigt,
aus welchen Quellen der Eintrag erzeugt wurde: Quelldateien (Stammbäume +
Wurfchronik), Anzahl zusammengeführter Datensätze, Eltern-Herleitung
(Methode/Konfidenz) und Hinweise (z. B. „per manueller Entscheidung zugeordnet",
„Konflikt gelöst", „aus N Datensätzen zusammengeführt").

Import: build_provenance() in merge_and_resolve.py sammelt die Herkunft über alle
deduplizierten Datensätze und schreibt sie als Provenance-JSON je Tier in
resolved_import.json. Backend: Gerbil.Provenance (nullable text) + Migration
AddGerbilProvenance, gemappt im Ingest und im GerbilDto zurückgegeben. Frontend:
ProvenanceDialog + Typen + Strings.

Tests: Ingest-Round-trip (vorhanden/abwesend), e2e provenance.spec.ts.
dotnet(212)/vitest(129)/playwright/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:45:15 +02:00
b9e5b031c4 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>
2026-06-22 15:30:42 +02:00
1845d37476 fix(import): Eltern-Ref-Auswahl geschlechts-bewusst machen (Regression behoben)
Die in 03ad9e6 eingeführte DOB-Bevorzugung war zu grob: ein datierter, aber
geschlechts-falscher Ref (z. B. weibliche „Danielle") gewann den Vater-Slot
gegen einen undatieren männlichen/unbekannten (z. B. „Hagrid Rubeus") — wodurch
Mollys korrekte Mutter Arya Stark in der Rollen-Normalisierung verloren ging.

pick_parent_ref wertet jetzt zuerst das (aus den Tierdaten ermittelte) Geschlecht
für die Rolle: ein klar falsches Geschlecht wird stark abgewertet, erst danach
zählt plausibles DOB > kein DOB > unplausibles DOB. Fixt Molly (Hagrid × Arya)
UND behält Solice (Lui × Molly = Geschwister von Silvain). Keine Abdeckungs-
Regression (0 Selbst-Verpaarungen / 0 Gender-Fehler / 0 Alter-Verletzungen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:11:53 +02:00
2868aad369 feat(stammbaum): gleiches Geburtsdatum der Eltern als Geschwister-Indiz werten
Ein identisches Geburtsdatum bei Vater und Mutter ist ein starkes Indiz für eine
Vollgeschwister-Verpaarung — ein gemeinsamer Wurf ist praktisch der einzige
Grund dafür, und die Eltern-Würfe sind in den importierten Daten nicht immer
verknüpft (gleiche litterId fehlt dann).

Die Geschwister-Erkennung (Diagramm-Zusammenführung in build.ts + Akte-Chip in
GerbilDetailPage) greift jetzt bei gleicher litterId ODER gleichem, gesetztem
Geburtsdatum beider Eltern (distinkte Tiere vorausgesetzt).

Tests: gleiches DOB bei fehlender/verschiedener litterId → erkannt; unterschied-
liches DOB → nicht erkannt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:02:19 +02:00
03ad9e6e81 fix(import): Eltern-Alter-Sanity-Check + plausiblere Eltern-Auswahl aus Chart-Refs
Eine Rennmaus lebt max ~6 Jahre, kann also nicht Elternteil eines Tiers sein,
das nach ihrem Tod (oder vor ihrer Geburt) geboren wurde. Bisher fehlte diese
Prüfung: z. B. war Jayjay (*2013) als Vater von Solice (*2022) eingetragen, und
Solice/Silvain (Vollgeschwister, beide *27.03.2022) landeten in getrennten
Würfen mit verschiedenen Eltern.

- parent_age_plausible(): Elternteil muss vor dem Kind UND innerhalb der
  Lebensspanne (≤6 J.) geboren sein. Genutzt im Namens-Resolver (Kandidaten-
  filter) und als finaler Sanity-Pass, der unmögliche FatherId/MotherId verwirft
  und im Log auflistet.
- pick_parent_ref(): bei mehreren widersprüchlichen Chart-parentRefs wird nicht
  mehr blind der erste genommen, sondern ein alters-plausibler bevorzugt und das
  Tier der jeweils anderen Elternrolle gemieden (Vollgeschwister-Charts nennen
  denselben Namen in beiden Slots).

Ergebnis: Solice = Lui × Molly = Geschwister von Silvain (gemeinsamer Wurf);
0 Selbst-Verpaarungen, 0 Gender-Rollen-Fehler, 0 Eltern-Alter-Verletzungen über
alle 977 Würfe. Tests in test_merge_resolve.py ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:59:34 +02:00
18efab6996 fix(import): Foto-Generations-Shift beim Stammbaum-Import beheben
Fotos liegen in den Stammbaum-xlsx je nach Datei links, rechts oder auf der
Namenszelle. Die alte Seiten-Erkennung hing an festen Spalten (col 1 / col 4):
Blätter ohne solche Anker wurden als Rechts-Layout fehlgedeutet, wodurch jedes
Foto eine Generation zum Probanden hin verrutschte. Folge: Kazuya trug das Foto
seines Vaters (Wilbur), Wilbur das seines Großvaters (Elay), Elay hatte keins.

Fix (extract._attach_photos): beide Interpretationen (links/rechts) durchrechnen
und die wählen, die jedes Foto am nächsten an die Namensspalte seines Tiers legt
(minimale horizontale Fehlausrichtung). Diagnose über alle 42 Dateien: keine ist
echt rechts-seitig; ~21 waren fehlklassifiziert. Selbstjustierend statt fester
Spalten-Annahme.

Regressionstest in test_extract.py: Kazuya ohne Foto, Wilbur/Elay mit ihren
eigenen (gated auf vorhandene Quelldatei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:49:38 +02:00
070a896072 feat(rennmausakte): Hinweis-Chip für Geschwisterverpaarung in der Tierakte
Stammt ein Tier aus einer Vollgeschwister-Verpaarung (Vater und Mutter teilen
dieselbe litterId), zeigt die Rennmausakte jetzt einen Chip
"⚭ Geschwisterverpaarung" in der Fakten-Zeile — neben Geschlecht, Geburtsdatum
und Farbschlag, in Akzentfarbe hervorgehoben (zuchtrelevant).

e2e-Test: Chip erscheint für 'Inzucht Kind' (Eltern aus demselben Wurf),
nicht für 'Krümel' (Eltern aus verschiedenen Würfen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:36 +02:00
3715e32303 fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen
Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.

Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
  bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
  unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
  vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
  auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
  entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
  Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
  extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.

Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
  teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
  Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
  Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:02:45 +02:00
cb2ee03a14 fix: Naho Eltern + Foto-Layout-Erkennung + Eltern-Fallback-Lookup
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m34s
CI / Docker Build & Push (push) Failing after 9s
2026-06-21 22:55:50 +02:00
41ff973f9a feat(import): merge animals with different DOBs if their parent names match
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Failing after 12s
2026-06-21 22:23:59 +02:00
d2993418b6 fix(import): remap Kazuya DOB and correct parents using Stammbaum von Kazuya.xlsx
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-21 22:18:04 +02:00
4cb39cb180 fix(import): align child-parent pedigree mapping by using name cell row for vertical alignment
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m9s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-21 22:11:53 +02:00
b53b8cf2ff fix: show nameless fallback (ohne Namen) for nameless parents in litters list and detail pages 2026-06-21 22:06:10 +02:00
e460cd5905 feat(import): resolve color variety ID mappings mismatch, support unnamed parents in virtual litters, and implement SaleContract updates 2026-06-21 21:57:40 +02:00
b83e96f552 feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll
Detailseite (Rennmausakte):
- Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen,
  Charakter als Chips, getabte Fotos/Gesundheit/Gewicht.
- Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status
  Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen).
- Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular).

Listen:
- Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge,
  Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile
  Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button.
- Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header.

Zucht-Nachname:
- Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt
  (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix".
- „Eigene Zucht" ist die Standard-Herkunft neuer Tiere.

Weiteres:
- Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend
  EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage).
- Toast-Rückmeldungen für alle Speichern-Aktionen.
- Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:35:30 +02:00
bddbaf59d3 chore(import): document import workflow and apply Round 2 contact deduplications
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m53s
CI / Docker Build & Push (push) Has been skipped
2026-06-13 09:27:13 +02:00
3021149ddd chore(import): apply user contact deduplication decisions 2026-06-13 09:22:04 +02:00
46f987422b FIX: Combine Birgitta Büskens and Birgitta Müller-Büskens contacts 2026-06-13 08:35:15 +02:00
5d2316d670 FIX: Combine Barbara R / Barbara Riegler contacts 2026-06-13 08:22:54 +02:00
c6968b9968 FEAT: Merge Anja Sch. into Anja Schaumburg, and keep infinite scroll layout pages 2026-06-13 08:14:31 +02:00
f3f4534392 FEAT: Normalize Anke B./Anke Busch contact, and save other infinite scroll layout updates 2026-06-13 08:11:17 +02:00
d189da954f FEAT: Wipe Contacts table on ingest to prevent duplicates, and commit infinite scroll contacts list 2026-06-13 08:06:56 +02:00
5b32c09076 FIX: Keep Ag(o)uti('s sind) net(t) as a valid breeder contact 2026-06-13 07:59:52 +02:00
ea760fba06 FEAT: Apply contact normalization and misclassification discarding mappings during import 2026-06-13 01:45:45 +02:00
396d8f05d8 FEAT: Implement deceased/givenaway enclosure visibility rules, preserve external clan name, and hide receiver fields for deceased gerbils 2026-06-13 01:40:19 +02:00
130 changed files with 25806 additions and 748 deletions

View File

@@ -0,0 +1,181 @@
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, NameSuffix = (string?)null, Provenance = (string?)null }
},
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,
});
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
// so it must survive the contact-table wipe just like gerbil/litter ids.
var contactFeedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = contactFeedbackId,
Message = "Adresse stimmt nicht.",
Context = "contact-detail",
ContactId = contactId,
EntityName = "Test Breeder",
Url = "http://localhost/kontakte/test-breeder",
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/contacts were wiped & re-created, but feedback is untouched.
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
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);
// The contact-scoped report also survives the contacts wipe (loose ContactId).
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
Assert.Equal(contactId, contactSurvivor.ContactId);
Assert.Equal("contact-detail", contactSurvivor.Context);
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
Assert.Equal(2, await db.Feedback.CountAsync());
}
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

@@ -38,9 +38,9 @@ public class GerbilStatusTests
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
[Fact]
public void OlderThan7y_without_death_returns_Deceased()
public void OlderThan6y_without_death_returns_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
var status = GerbilStatusService.Derive(
@@ -48,14 +48,14 @@ public class GerbilStatusTests
Assert.Equal(GerbilStatus.Deceased, status);
}
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
[Fact]
public void ExactlyMaxAge_is_not_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
// (threshold = "older than" so >= means Deceased)
Assert.Equal(GerbilStatus.Deceased, status);
}
@@ -70,11 +70,11 @@ public class GerbilStatusTests
Assert.Equal(GerbilStatus.Breeding, status);
}
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
[Fact]
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
{
var dob = Today.AddYears(-8);
var dob = Today.AddYears(-7);
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);

View File

@@ -122,7 +122,7 @@ namespace GerbilManager.Tests
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
Assert.False(gerbil.IsResident);
Assert.Equal("docx", gerbil.ImportSource);
Assert.StartsWith("docx-", gerbil.ExternalRef);

View File

@@ -36,7 +36,8 @@ namespace GerbilManager.Tests
Email = "test@example.com",
Homepage = "",
Phone = "",
Address = ""
Address = "",
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
}
},
Litters = new[]
@@ -54,7 +55,8 @@ namespace GerbilManager.Tests
Notes = "Test litter notes",
PairingCode = "PC01",
ExternalRef = "ext-litter-1",
LitterLetter = "A"
LitterLetter = "A",
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
}
},
Gerbils = new[]
@@ -84,7 +86,8 @@ namespace GerbilManager.Tests
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
IsResident = true,
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
},
new
{
@@ -111,7 +114,8 @@ namespace GerbilManager.Tests
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
IsResident = true,
Provenance = (string?)null
}
},
GerbilPhotos = new[]
@@ -176,6 +180,25 @@ namespace GerbilManager.Tests
Assert.Equal(mother.Id, litter.MotherId);
Assert.Equal(father.Id, photo.GerbilId);
Assert.Equal(father.OriginContactId, mother.OriginContactId);
// Provenance round-trips verbatim through ingest (and stays null when absent).
Assert.NotNull(father.Provenance);
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
Assert.Contains("mergedRecordCount", father.Provenance);
// The chronological, file-attributed history survives ingest verbatim
// (stored as opaque JSON on the text column — no schema for it).
Assert.Contains("history", father.Provenance);
Assert.Contains("Genotyp aus", father.Provenance);
Assert.Null(mother.Provenance);
// Contact + litter provenance also round-trips through the ingest.
var contact = await db.Contacts.SingleAsync();
Assert.NotNull(contact.Provenance);
Assert.Contains("als Züchter erkannt", contact.Provenance);
Assert.NotNull(litter.Provenance);
Assert.Contains("aus Wurfchronik", litter.Provenance);
Assert.Contains("fromWurfchronik", litter.Provenance);
}
}
}

View File

@@ -36,7 +36,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
name,
gender = "female",
genotype = "aa CC DD ee GG PP spsp rere",
originBreeder = "Zucht der Kleinen Chaoten",
originBreeder = "Zucht der kleinen Chaoten",
isDeaf = false,
characterTraits = new[] { "neugierig", "zutraulich" },
characterNote = "Liebling der Familie",
@@ -67,7 +67,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
@@ -95,7 +95,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
@@ -123,7 +123,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));

View File

@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
@@ -23,6 +24,7 @@ public class ApplicationContext : DbContext
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
// 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.
@@ -133,6 +135,10 @@ public class ApplicationContext : DbContext
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
modelBuilder.Entity<EnclosurePhoto>(e =>
e.HasOne<Enclosure>().WithMany()
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
// FEAT-13: Abgabeverträge + Zuchtprofil.
modelBuilder.Entity<SaleContract>(e =>
{
@@ -197,6 +203,15 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>()
.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).
if (isNpgsql)
{

View File

@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
public sealed record ContractAnimal(
string Name,
string Geschlecht,
DateOnly? Geburtsdatum = null,
string? Farbschlag = null);
string? Farbschlag = null,
byte[]? Photo = null);
/// <summary>
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.

View File

@@ -0,0 +1,241 @@
using System.Globalization;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
/// 48 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
/// </summary>
public static class ContractPdfGenerator
{
static ContractPdfGenerator()
{
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
QuestPDF.Settings.License = LicenseType.Community;
}
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
private static string Price(decimal p) => p.ToString("N2", German) + " €";
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
private const string Accent = "#A85F2E";
private const string Ink = "#3D2E23";
private const string Line = "#D9CCB4";
public static byte[] Generate(ContractData data)
{
ArgumentNullException.ThrowIfNull(data);
return Document.Create(doc =>
{
doc.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(1.8f, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
page.Header().PaddingBottom(8).Column(h =>
{
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
});
page.Content().PaddingTop(6).Column(col =>
{
col.Spacing(12);
Parties(col, data);
Animals(col, data);
Subject(col, data);
Clauses(col);
Signatures(col, data);
});
page.Footer().AlignCenter().Text(t =>
{
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
t.Span("Seite ");
t.CurrentPageNumber();
t.Span(" / ");
t.TotalPages();
});
});
}).GeneratePdf();
}
private static void Heading(ColumnDescriptor col, string text) =>
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
private static void Kv(ColumnDescriptor col, string label, string value) =>
col.Item().Row(r =>
{
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
r.RelativeItem().Text(value).SemiBold();
});
private static void Bullet(ColumnDescriptor col, string text) =>
col.Item().Row(r =>
{
r.ConstantItem(14).Text("•").FontColor(Accent);
r.RelativeItem().Text(text);
});
private static void Parties(ColumnDescriptor col, ContractData data)
{
var s = data.Seller;
var b = data.Buyer;
Heading(col, "1. Vertragspartner");
col.Item().Text("Verkäufer / Züchter").SemiBold();
col.Item().Column(c =>
{
c.Spacing(2);
Kv(c, "Zuchtname:", Or(s.ZuchtName));
Kv(c, "Vor- und Nachname:", Or(s.Name));
Kv(c, "Adresse:", Or(s.Address));
Kv(c, "Telefon:", Or(s.Phone, ""));
Kv(c, "E-Mail:", Or(s.Email, ""));
Kv(c, "Homepage:", Or(s.Homepage, ""));
});
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
col.Item().Column(c =>
{
c.Spacing(2);
Kv(c, "Vor- und Nachname:", Or(b.Name));
Kv(c, "Adresse:", Or(b.Address));
Kv(c, "Telefon:", Or(b.Phone, ""));
Kv(c, "E-Mail:", Or(b.Email, ""));
});
}
private static void Animals(ColumnDescriptor col, ContractData data)
{
Heading(col, "2. Angaben zu den Tieren");
col.Item().Column(list =>
{
list.Spacing(10);
foreach (var a in data.Animals)
{
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
// wie im Mustervertrag (das große Rechteck links).
list.Item().Border(1).BorderColor(Line).Row(row =>
{
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
.Padding(4).Element(box =>
{
if (a.Photo is { Length: > 0 } bytes)
box.AlignMiddle().Image(bytes).FitArea();
else
box.AlignMiddle().AlignCenter().Text("Kein Foto")
.FontSize(8).FontColor("#B8AC97");
});
row.RelativeItem(3f).Column(kv =>
{
AnimalRow(kv, "Name:", Or(a.Name, ""));
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
});
});
}
});
}
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
{
var cell = col.Item();
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
{
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
r.RelativeItem().Text(value).SemiBold();
});
}
private static void Subject(ColumnDescriptor col, ContractData data)
{
Heading(col, "3. Gegenstand des Vertrages");
col.Item().Text(
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
$"von dem Züchter übergeben.");
}
private static void Clauses(ColumnDescriptor col)
{
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
col.Item().Text(
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
Heading(col, "5. Pflichten des Käufers");
col.Item().Text(
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
});
Heading(col, "6. Rechte des Käufers");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
});
Heading(col, "7. Haftung / Zuwiderhandlung");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
});
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
col.Item().Text(
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
}
private static void Signatures(ColumnDescriptor col, ContractData data)
{
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
col.Item().PaddingTop(28).Row(r =>
{
r.RelativeItem().Column(c =>
{
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
});
r.ConstantItem(40);
r.RelativeItem().Column(c =>
{
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
});
});
}
}

View File

@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? SpottingType,
string? Notes,
string? ImportSource,
string? ExternalRef,
@@ -30,20 +31,23 @@ namespace GerbilManagerWebAPI.Dtos
string? CharacterNote,
bool? IsDeaf,
bool IsResident,
string? ProfilePhotoUrl);
string? ProfilePhotoUrl,
bool IsCastrated,
string? Provenance);
public record LitterDto(
Guid Id,
string Name,
DateOnly Date,
DateOnly? Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
string? Notes);
string? Notes,
string? Provenance);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
public record EnclosureDto(Guid Id, string Name, string? Notes);
@@ -74,6 +78,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? SpottingType,
string? Notes,
string? ImportSource,
string? ExternalRef,
@@ -81,11 +86,12 @@ namespace GerbilManagerWebAPI.Dtos
List<string>? CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool? IsResident);
bool? IsResident,
bool? IsCastrated);
public record LitterInput(
string Name,
DateOnly Date,
DateOnly? Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
@@ -93,7 +99,7 @@ namespace GerbilManagerWebAPI.Dtos
DateOnly? ExpectedGoHomeDate,
string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
public record EnclosureInput(string Name, string? Notes);

View File

@@ -19,7 +19,9 @@ namespace GerbilManagerWebAPI.Dtos
List<Guid> GerbilIds,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate);
DateOnly? ContractDate,
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
Dictionary<Guid, Guid>? AnimalPhotos = null);
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
public record BreederProfileDto(
@@ -29,5 +31,6 @@ namespace GerbilManagerWebAPI.Dtos
string Phone,
string Email,
string Homepage,
string City);
string City,
string NameSuffix);
}

View File

@@ -0,0 +1,27 @@
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,
Guid? ContactId,
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,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp,
string? UserAgent,
DateTimeOffset CreatedAt);
}

View File

@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
{
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
db.Contacts.Add(c);
await db.SaveChangesAsync();
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
}
}

View File

@@ -23,6 +23,7 @@ namespace GerbilManagerWebAPI.Endpoints
{
private const string DocxContentType =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
private const string PdfContentType = "application/pdf";
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
{
@@ -50,6 +51,7 @@ namespace GerbilManagerWebAPI.Endpoints
var errors = new Dictionary<string, string[]>();
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
var photoChoice = input.AnimalPhotos ?? [];
if (gerbilIds.Count == 0)
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
if (input.Price < 0)
@@ -100,7 +102,11 @@ namespace GerbilManagerWebAPI.Endpoints
ContractDate = contractDate,
FileName = fileName,
CreatedAt = DateTimeOffset.UtcNow,
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
Animals = gerbilIds.Select(id => new SaleContractAnimal
{
GerbilId = id,
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
}).ToList(),
};
db.SaleContracts.Add(entity);
@@ -144,6 +150,49 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.PhysicalFile(path, DocxContentType, download);
});
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Contact)
.Include(x => x.Animals)
.FirstOrDefaultAsync(x => x.Id == id);
if (c is null || c.Contact is null) return TypedResults.NotFound();
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
var gerbils = await db.Gerbils.AsNoTracking()
.Include(g => g.ColorVariety)
.Where(g => gerbilIds.Contains(g.Id))
.ToListAsync();
var settings = await db.BreederSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
?? new BreederSettings();
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
// existieren — sonst bleibt das Bild leer statt zu scheitern).
var photoByGerbil = c.Animals
.Where(a => a.PhotoId is not null)
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
var data = new ContractData(
Seller: ToSeller(settings),
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
Animals: gerbils
.Select(g => new ContractAnimal(
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
photoBytes.GetValueOrDefault(g.Id)))
.ToList(),
Price: c.Price,
HandoverDate: c.HandoverDate,
ContractDate: c.ContractDate);
var pdf = ContractPdfGenerator.Generate(data);
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
return TypedResults.File(pdf, PdfContentType, download);
});
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
// (das wäre Magie — Status korrigiert man am Tier selbst).
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
@@ -166,6 +215,34 @@ namespace GerbilManagerWebAPI.Endpoints
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
{
var result = new Dictionary<Guid, byte[]>();
if (photoByGerbil.Count == 0) return result;
var photoIds = photoByGerbil.Values.ToList();
var photos = await db.GerbilPhotos.AsNoTracking()
.Where(p => photoIds.Contains(p.Id))
.ToListAsync();
foreach (var (gerbilId, photoId) in photoByGerbil)
{
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
if (photo is null) continue;
var path = Path.Combine(photoRoot, photo.FileName);
if (File.Exists(path))
result[gerbilId] = await File.ReadAllBytesAsync(path);
}
return result;
}
private static BreederProfile ToSeller(BreederSettings s) => new()
{
ZuchtName = s.ZuchtName,

View File

@@ -0,0 +1,63 @@
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,
ContactId = input.ContactId,
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.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
}
}

View File

@@ -120,6 +120,7 @@ namespace GerbilManagerWebAPI.Endpoints
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
g.Genotype = i.Genotype ?? g.Genotype;
g.SpottingType = i.SpottingType ?? g.SpottingType;
g.Notes = i.Notes ?? g.Notes;
g.ImportSource = i.ImportSource ?? g.ImportSource;
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
@@ -128,13 +129,15 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
}
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
g.Provenance);
}
}

View File

@@ -98,7 +98,7 @@ namespace GerbilManagerWebAPI.Endpoints
private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
}
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>

View File

@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.NoContent();
}).WithTags("Photos");
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
app.MapGet("/enclosures/{id:guid}/photos",
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
{
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
var photos = await db.EnclosurePhotos.AsNoTracking()
.Where(p => p.EnclosureId == id)
.OrderBy(p => p.SortOrder)
.ToListAsync();
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
}).WithTags("Photos");
app.MapPost("/enclosures/{id:guid}/photos",
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
var ext = Path.GetExtension(file.FileName);
var fileName = $"{Guid.NewGuid():N}{ext}";
var root = PhotoRoot(config, env);
Directory.CreateDirectory(root);
await using (var stream = File.Create(Path.Combine(root, fileName)))
await file.CopyToAsync(stream);
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
var photo = new EnclosurePhoto
{
Id = Guid.NewGuid(),
EnclosureId = id,
FileName = fileName,
Caption = caption,
SortOrder = nextSort,
CreatedAt = DateTimeOffset.UtcNow,
};
db.EnclosurePhotos.Add(photo);
await db.SaveChangesAsync();
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
}).WithTags("Photos").DisableAntiforgery();
app.MapDelete("/enclosure-photos/{id:guid}",
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return TypedResults.NotFound();
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
if (File.Exists(path)) File.Delete(path);
db.EnclosurePhotos.Remove(photo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).WithTags("Photos");
app.MapGet("/photos/files/{fileName}",
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
{
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
private static PhotoDto ToDto(GerbilPhoto p) =>
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
}
}

View File

@@ -33,6 +33,7 @@ namespace GerbilManagerWebAPI.Endpoints
s.Email = input.Email ?? "";
s.Homepage = input.Homepage ?? "";
s.City = input.City ?? "";
s.NameSuffix = input.NameSuffix ?? "";
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
}
internal static BreederProfileDto ToDto(BreederSettings s) =>
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
}
}

View File

@@ -20,6 +20,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="QuestPDF" Version="2026.6.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>

View File

@@ -55,10 +55,21 @@ namespace GerbilManagerWebAPI.Import
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
int docxLitCount = docxLitters.Count;
int docxAnimCount = docxAnimals.Count;
int pdfLitCount = pdfLitters.Count;
int pdfAnimCount = pdfAnimals.Count;
docxLitters.AddRange(pdfLitters);
docxAnimals.AddRange(pdfAnimals);
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
{
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
"extract_docx.py zuerst ausführen.");
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
}
@@ -70,7 +81,8 @@ namespace GerbilManagerWebAPI.Import
.Select(l => new { l.Id, l.Date })
.ToListAsync();
var littersByDayNumber = littersInDb
.GroupBy(l => l.Date.DayNumber)
.Where(l => l.Date.HasValue)
.GroupBy(l => l.Date!.Value.DayNumber)
.ToDictionary(g => g.Key, g => g.ToList());
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
@@ -274,7 +286,7 @@ namespace GerbilManagerWebAPI.Import
DateOfDeath = deathDate,
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
ColorVarietyId = cvId == default ? null : cvId,
OriginBreeder = "Zucht der Kleinen Chaoten",
OriginBreeder = "Zucht der kleinen Chaoten",
IsResident = false,
ImportSource = "docx",
ExternalRef = externalRef,
@@ -313,7 +325,7 @@ namespace GerbilManagerWebAPI.Import
});
}
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");

View File

@@ -83,7 +83,7 @@ namespace GerbilManagerWebAPI.Import
var existingLitterData = await _db.Litters
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
var existingLitterKeySet = existingLitterData
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
var existingLitterExtRefSet = existingLitterData
.Where(x => x.ExternalRef != null)
.Select(x => x.ExternalRef!).ToHashSet();
@@ -307,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
var litterByParentsDate = new Dictionary<string, Guid>();
foreach (var l in existingLitterRows)
{
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
litterParents[l.Id] = (l.FatherId, l.MotherId);
}
foreach (var sl in synthLitters.Values.ToList())
{
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
{
// remap offspring to the existing litter; don't create a duplicate.
@@ -329,8 +329,8 @@ namespace GerbilManagerWebAPI.Import
_db.Litters.Add(new Litter
{
Id = sl.Id,
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
Date = sl.Date ?? default,
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
Date = sl.Date,
FatherId = null, // deferred — applied after gerbils SaveChanges
MotherId = null, // deferred — applied after gerbils SaveChanges
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
@@ -571,7 +571,7 @@ namespace GerbilManagerWebAPI.Import
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
int herkunftBackfilled = 0;
{
var needsHerkunft = await _db.Gerbils
@@ -615,9 +615,9 @@ namespace GerbilManagerWebAPI.Import
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
if (parentFksBackfilled > 0)
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
if (herkunftBackfilled > 0)
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
if (farbschlagWouldRebackfill > 0)
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);

View File

@@ -1,6 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using GerbilManagerWebAPI.Services;
namespace GerbilManagerWebAPI.Import
{
@@ -8,13 +10,18 @@ namespace GerbilManagerWebAPI.Import
{
private readonly ApplicationContext _db;
private readonly string _resolvedJsonPath;
private readonly string _sourceDir;
private readonly string _photoRoot;
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
var sourceDir = config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output"));
_resolvedJsonPath = Path.Combine(sourceDir, "resolved_import.json");
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
_sourceDir = config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
_photoRoot = config["Photos:RootPath"]
?? Path.Combine(contentRoot, "photo-storage");
}
public async Task<string> RunAsync()
@@ -55,8 +62,10 @@ namespace GerbilManagerWebAPI.Import
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
_db.WeightRecords.RemoveRange(_db.WeightRecords);
_db.HealthRecords.RemoveRange(_db.HealthRecords);
_db.SaleContracts.RemoveRange(_db.SaleContracts);
_db.Gerbils.RemoveRange(_db.Gerbils);
_db.Litters.RemoveRange(_db.Litters);
_db.Contacts.RemoveRange(_db.Contacts);
await _db.SaveChangesAsync();
}
else
@@ -72,17 +81,33 @@ namespace GerbilManagerWebAPI.Import
await _db.GerbilPhotos.ExecuteDeleteAsync();
await _db.WeightRecords.ExecuteDeleteAsync();
await _db.HealthRecords.ExecuteDeleteAsync();
await _db.SaleContracts.ExecuteDeleteAsync();
await _db.Gerbils.ExecuteDeleteAsync();
await _db.Litters.ExecuteDeleteAsync();
await _db.Contacts.ExecuteDeleteAsync();
}
// 2. Import Contacts (Only add new ones to preserve manually entered contacts)
var existingContactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
var addedContactIds = new HashSet<Guid>();
int contactsAdded = 0;
int contactsUpdated = 0;
foreach (var c in data.Contacts)
{
if (!existingContactIds.Contains(c.Id) && addedContactIds.Add(c.Id))
if (existingContacts.TryGetValue(c.Id, out var existingContact))
{
existingContact.Name = c.Name;
existingContact.Email = c.Email;
existingContact.Phone = c.Phone;
existingContact.Address = c.Address;
existingContact.Notes = c.Notes;
existingContact.IsBreeder = c.IsBreeder;
existingContact.IsReceiver = c.IsReceiver;
existingContact.NameSuffix = c.NameSuffix;
existingContact.Provenance = c.Provenance;
contactsUpdated++;
}
else if (addedContactIds.Add(c.Id))
{
_db.Contacts.Add(c);
contactsAdded++;
@@ -107,7 +132,8 @@ namespace GerbilManagerWebAPI.Import
Notes = l.Notes,
PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter
LitterLetter = l.LitterLetter,
Provenance = l.Provenance
});
}
_db.Litters.AddRange(littersToInsert);
@@ -117,7 +143,7 @@ namespace GerbilManagerWebAPI.Import
var gerbilsToInsert = new List<Gerbil>();
foreach (var g in data.Gerbils)
{
gerbilsToInsert.Add(new Gerbil
var importedGerbil = new Gerbil
{
Id = g.Id,
Name = g.Name,
@@ -137,13 +163,16 @@ namespace GerbilManagerWebAPI.Import
ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData,
Provenance = g.Provenance,
OriginBreeder = g.OriginBreeder,
NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits,
CharacterNote = g.CharacterNote,
IsDeaf = g.IsDeaf,
IsResident = g.IsResident
});
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
gerbilsToInsert.Add(importedGerbil);
}
_db.Gerbils.AddRange(gerbilsToInsert);
await _db.SaveChangesAsync();
@@ -171,14 +200,45 @@ namespace GerbilManagerWebAPI.Import
}
await _db.SaveChangesAsync();
// 6. Insert all Photos
// 6. Insert all Photos & copy physical files
Directory.CreateDirectory(_photoRoot);
foreach (var p in data.GerbilPhotos)
{
_db.GerbilPhotos.Add(p);
if (!string.IsNullOrEmpty(p.SourcePath))
{
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var src = Path.Combine(_sourceDir, relPath);
if (File.Exists(src))
{
var dest = Path.Combine(_photoRoot, p.FileName);
try
{
File.Copy(src, dest, overwrite: true);
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
}
}
else
{
Console.WriteLine($"Warning: Source photo file not found at {src}");
}
}
_db.GerbilPhotos.Add(new GerbilPhoto
{
Id = p.Id,
GerbilId = p.GerbilId,
FileName = p.FileName,
Caption = p.Caption,
SortOrder = p.SortOrder,
CreatedAt = DateTimeOffset.UtcNow
});
}
await _db.SaveChangesAsync();
return $"Ingestion successful! Imported {contactsAdded} contacts, {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
}
}
@@ -187,6 +247,18 @@ namespace GerbilManagerWebAPI.Import
public List<Contact> Contacts { get; set; } = new();
public List<Litter> Litters { get; set; } = new();
public List<Gerbil> Gerbils { get; set; } = new();
public List<GerbilPhoto> GerbilPhotos { get; set; } = new();
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
}
public class ResolvedPhoto
{
public Guid Id { get; set; }
public Guid GerbilId { get; set; }
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
[JsonPropertyName("_source_path")]
public string? SourcePath { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddSpottingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SpottingType",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SpottingType",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContactRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBreeder",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsReceiver",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBreeder",
table: "Contacts");
migrationBuilder.DropColumn(
name: "IsReceiver",
table: "Contacts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddIsCastrated : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCastrated",
table: "Gerbils",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCastrated",
table: "Gerbils");
}
}
}

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 AddBreedingNameSuffix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NameSuffix",
table: "Contacts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NameSuffix",
table: "BreederSettings",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "BreederSettings",
keyColumn: "Id",
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
column: "NameSuffix",
value: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NameSuffix",
table: "Contacts");
migrationBuilder.DropColumn(
name: "NameSuffix",
table: "BreederSettings");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddEnclosurePhotos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EnclosurePhotos",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
EnclosureId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
Caption = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EnclosurePhotos", x => x.Id);
table.ForeignKey(
name: "FK_EnclosurePhotos_Enclosures_EnclosureId",
column: x => x.EnclosureId,
principalTable: "Enclosures",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EnclosurePhotos_EnclosureId",
table: "EnclosurePhotos",
column: "EnclosureId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EnclosurePhotos");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class MakeLitterDateNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateOnly>(
name: "Date",
table: "Litters",
type: "date",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateOnly>(
name: "Date",
table: "Litters",
type: "date",
nullable: false,
defaultValue: new DateOnly(1, 1, 1),
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContractAnimalPhoto : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "PhotoId",
table: "SaleContractAnimal",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PhotoId",
table: "SaleContractAnimal");
}
}
}

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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGerbilProvenance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Provenance",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackContactId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ContactId",
table: "Feedback",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContactId",
table: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContactLitterProvenance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Contacts",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Provenance",
table: "Litters");
migrationBuilder.DropColumn(
name: "Provenance",
table: "Contacts");
}
}
}

View File

@@ -164,6 +164,10 @@ namespace GerbilManagerWebAPI.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("NameSuffix")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
@@ -185,6 +189,7 @@ namespace GerbilManagerWebAPI.Migrations
Email = "",
Homepage = "",
Name = "",
NameSuffix = "",
Phone = "",
ZuchtName = ""
});
@@ -716,17 +721,29 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("IsBreeder")
.HasColumnType("boolean");
b.Property<bool>("IsReceiver")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("NameSuffix")
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Phone")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Contacts");
@@ -750,6 +767,80 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Enclosures");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Caption")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EnclosureId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EnclosureId");
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<Guid?>("ContactId")
.HasColumnType("uuid");
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 =>
{
b.Property<Guid>("Id")
@@ -794,6 +885,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("ImportSource")
.HasColumnType("text");
b.Property<bool>("IsCastrated")
.HasColumnType("boolean");
b.Property<bool?>("IsDeaf")
.HasColumnType("boolean");
@@ -824,12 +918,18 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<string>("RawImportData")
.HasColumnType("text");
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
b.Property<string>("SpottingType")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
@@ -921,7 +1021,7 @@ namespace GerbilManagerWebAPI.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
b.Property<DateOnly?>("Date")
.HasColumnType("date");
b.Property<int?>("DeathsWithin8Weeks")
@@ -952,6 +1052,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");
@@ -1222,6 +1325,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("PhotoId")
.HasColumnType("uuid");
b.HasKey("SaleContractId", "GerbilId");
b.HasIndex("GerbilId");
@@ -1290,6 +1396,15 @@ namespace GerbilManagerWebAPI.Migrations
.IsRequired();
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null)
.WithMany()
.HasForeignKey("EnclosureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")

View File

@@ -18,6 +18,13 @@ namespace GerbilManagerWebAPI.Models
public string ZuchtName { get; set; } = "";
/// <summary>
/// Namens-Anhängsel der eigenen Zucht (wie ein „Nachname"), z. B.
/// „von den kleinen Chaoten“. Wird an Namen eigener (selbst gezüchteter)
/// Tiere angehängt: „Danako von den kleinen Chaoten“.
/// </summary>
public string NameSuffix { get; set; } = "";
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
public string Name { get; set; } = "";

View File

@@ -15,5 +15,20 @@ namespace GerbilManagerWebAPI.Models
public string? Phone { get; set; }
public string? Address { get; set; }
public string? Notes { get; set; }
public bool IsBreeder { get; set; }
public bool IsReceiver { get; set; }
/// <summary>
/// Namens-Anhängsel dieser Zucht (wie ein „Nachname"), z. B.
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
/// </summary>
public string? NameSuffix { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this contact (sourceFiles, mergedRecordCount,
/// notes). Written by the Python merge_and_resolve step and surfaced read-only
/// ("Datenherkunft"). Null = manually-added contact / no import data.</summary>
public string? Provenance { get; set; }
}
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>A photo of an enclosure (Gehege). Mirrors <see cref="GerbilPhoto"/>:
/// the file lives under the shared photo-storage root; only the file name is in the DB.</summary>
public class EnclosurePhoto
{
[Key]
public Guid Id { get; set; }
public Guid EnclosureId { get; set; }
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
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>Loose reference (no FK) to the contact the report is about, if any.</summary>
public Guid? ContactId { 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

@@ -41,12 +41,21 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
public string? Genotype { get; set; }
public string? SpottingType { get; set; }
public string? Notes { get; set; }
// Provenance (for the FEAT-8 spreadsheet import).
public string? ImportSource { get; set; }
public string? ExternalRef { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this entry (sourceFiles, mergedRecordCount,
/// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python
/// merge_and_resolve step and surfaced read-only in the Rennmausakte
/// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data.</summary>
public string? Provenance { get; set; }
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; }
@@ -80,6 +89,7 @@ namespace GerbilManagerWebAPI.Models
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
public bool IsResident { get; set; } = true;
public bool IsCastrated { get; set; }
}
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>

View File

@@ -8,7 +8,7 @@ namespace GerbilManagerWebAPI.Models
[Key]
public Guid Id { get; set; }
public required string Name { get; set; }
public DateOnly Date { get; set; }
public DateOnly? Date { get; set; }
/// <summary>Total born count (was "Strength").</summary>
public int? TotalBorn { get; set; }
@@ -38,5 +38,12 @@ namespace GerbilManagerWebAPI.Models
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this litter (sourceFiles, mergedRecordCount,
/// fromWurfchronik, notes — e.g. "aus Wurfchronik", "aus Stammbaum-Diagramm rekonstruiert").
/// Written by the Python merge_and_resolve step and surfaced read-only ("Datenherkunft").
/// Null = manually-added litter / no import data.</summary>
public string? Provenance { get; set; }
}
}

View File

@@ -41,5 +41,9 @@ namespace GerbilManagerWebAPI.Models
public Guid SaleContractId { get; set; }
public Guid GerbilId { get; set; }
public Gerbil? Gerbil { get; set; }
/// <summary>Optional gewähltes Tierfoto (<see cref="GerbilPhoto"/>) fürs Vertragsbild.
/// Nur die Id wird gespeichert; beim PDF-Rendern wird die Datei nachgeladen.</summary>
public Guid? PhotoId { get; set; }
}
}

View File

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

View File

@@ -20,7 +20,7 @@ namespace GerbilManagerWebAPI.Services
/// </summary>
public static class GerbilStatusService
{
public const int MaxAgeYears = 7;
public const int MaxAgeYears = 6;
/// <summary>Derives and sets g.Status using the gerbil's current field values.
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)

126
README.md
View File

@@ -1,31 +1,95 @@
# GerbilManager
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
# Requirements
Install ef:
dotnet tool install --global dotnet-ef
# Used cmd´s
## Start containers
docker compose up
## Create the initial database
dotnet ef database update
## Create an migration script
dotnet ef migrations add <name>
## Docker
### force compose rebuild
docker-compose build --no-cache
## Frontend (gerbil-manager-web)
cd gerbil-manager-web
npm install
npm run dev
TODO:
- Backup docker volume database
- run "dotnet ef database update" if no database exist.
# GerbilManager
A manager application for gerbil breeding data featuring a .NET Aspire backend and a React-based frontend.
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger under `/swagger`)
- `gerbil-manager-web/` — React Frontend (Vite + TypeScript), see `gerbil-manager-web/README.md`
- `tools/import/` — Python import pipeline scripts and database schemas
## Requirements
To manage database migrations, install the EF Core CLI tool globally:
```bash
dotnet tool install --global dotnet-ef
```
## Running the Application
To run the full stack (PostgreSQL database, WebAPI, and Frontend Vite app) in one command:
```bash
dotnet run --project GerbilManager.AppHost
```
---
## 📥 Data Import & Normalization Pipeline
The application features a Python-based import pipeline that compiles gerbil breeding records, lineage, photos, and contact information from source files (markdown logs, Excel spreadsheets, Word documents) into a unified JSON format, and ingests them into the PostgreSQL database.
### 1. Structure of the Pipeline
* **Source Data**: Located at `C:\Users\gulum\dev\Wurfchronik_Bilder` (contains markdown files, `.xlsx` pedigree trees, and detail logs).
* **Compilation**: [merge_and_resolve.py](file:///C:/Users/gulum/dev/GerbilManager/tools/import/merge_and_resolve.py) processes the source files, extracts breeding details, resolves lineage links, maps colors/genotypes, and normalizes contact names.
* **Normalization Mapping**: `merge_and_resolve.py` contains a `norm_map` dictionary of contact name normalizations. This maps various spelling variations and abbreviations to a single canonical name.
### 2. How to Run the Import
#### Automatic Way (All-in-One)
The PowerShell script in `tools/import` boots the Aspire stack, waits for the API to become ready, and automatically triggers the ingest:
```powershell
powershell -ExecutionPolicy Bypass -File tools/import/run_app_and_import.ps1
```
#### Manual Way
1. **Recompile the import data**:
Run the compiler script from the root directory to parse raw sources and output a fresh `resolved_import.json`:
```bash
python tools/import/merge_and_resolve.py
```
2. **Ingest into the running database**:
Ensure the application is running, then send an HTTP `POST` request to the API:
```bash
Invoke-RestMethod -Uri "http://localhost:5179/import/ingest-resolved" -Method Post
```
---
## 👥 Contact Deduplication Workflow
When the database is wiped and re-imported, you can detect and resolve duplicate contacts (e.g. spelling mistakes, initials vs full names) using the deduplication checklist workflow.
### 1. Search for Duplicate Contacts
You can run duplicate checks using two different similarity parameter models:
* **Round 1 (Clever matching on first 4 characters)**:
```bash
python tools/import/find_duplicates_v5.py
```
Creates: `tools/import/output/potential_duplicates_decision.md`
* **Round 2 (Broader matching on initials, matching last names, phones, or emails)**:
```bash
python tools/import/find_duplicates_round2.py
```
Creates: `tools/import/output/potential_duplicates_decision_round2.md`
### 2. Fill out the Checklist
Open the generated markdown checklist file and edit the checkboxes:
* **Combine/Merge**: Put an `x` in the checkbox `[x]` of the name you want to choose as the **canonical name**. Leave the checkboxes empty `[ ]` for any names you want to **merge into the canonical name**.
* **Standalone**: Put an `o` in the checkbox `[o]` of any name that should remain a separate, standalone contact.
* **New Name**: Put an `x` next to `Custom name:` and type your new desired name inside the backticks.
* **Keep all separate**: Leave all checkboxes in a Choice empty `[ ]`.
*Example:*
```markdown
- [x] Max Mustermann <-- canonical name
- [ ] Max M. <-- merges into Max Mustermann
- [o] Moritz Mustermann <-- remains a separate standalone contact
```
### 3. Apply the Decisions
Once you have saved your decisions in the checklist file, run the parser:
```bash
python tools/import/parse_decisions.py
```
This automatically parses your selections, resolves the merges, and updates the `norm_map` inside `merge_and_resolve.py` so they are **permanently saved** in the codebase.
Run the compilation pipeline again to produce the final deduplicated import JSON:
```bash
python tools/import/merge_and_resolve.py
```

View File

@@ -9,13 +9,18 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
// DESIGN (Rennmausakte): Charakter sind anklickbare Chips (Buttons) statt Checkboxen.
// Eigenschaft 'neugierig' aktivieren und speichern.
await page.getByRole('checkbox', { name: 'neugierig' }).check()
await page.getByRole('button', { name: 'neugierig', exact: true }).click()
await page.getByRole('button', { name: de.character.save }).click()
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
await expect(page.getByRole('button', { name: 'neugierig', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
)
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
await page.goto('/abgabe')
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.

View File

@@ -7,9 +7,6 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
@@ -18,7 +15,8 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
await expect(genotypeInput).toHaveValue('')
// 'Agouti' wählen → Gencode wird automatisch befüllt.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await page.locator('.color-picker-trigger').click()
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
})
@@ -26,54 +24,47 @@ test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) =>
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('cv-schwarz')
await expect(page.locator('.color-picker-trigger')).toHaveText(/Schwarz/)
})
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await expect(farbschlagSelect).not.toHaveValue('')
await page.locator('.color-picker-trigger').click()
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
await expect(page.locator('.color-picker-trigger')).toHaveText(/Agouti/)
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('')
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
})
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
await farbschlagSelect.selectOption({ label: 'Blau' })
await expect(farbschlagSelect).not.toHaveValue('')
await page.locator('.color-picker-trigger').click()
await page.locator('.color-picker-option', { hasText: 'Blau' }).click()
await expect(page.locator('.color-picker-trigger')).toHaveText(/Blau/)
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
await expect(farbschlagSelect).toHaveValue('')
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
})

View File

@@ -0,0 +1,132 @@
/** 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('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
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['contact-detail'])
await dialog.getByLabel(f.label).fill('Die Adresse stimmt nicht.')
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: 'contact-detail', contactId: 'con-meier' })
})
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' })
})
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
skipUnlessMock()
// Der Long-Press-Code-Pfad (onTouchStart → Timer → Menü) ist projekt-unabhängig
// identisch; im Touch-emulierten phone-Projekt ist das Auslösen eines
// synthetischen TouchEvents unzuverlässig, daher hier auf das desktop-Projekt
// beschränkt (validiert dieselbe Logik).
testInfo.skip(testInfo.project.name === 'phone', 'synthetischer Long-Press im phone-Projekt instabil')
await page.goto('/rennmaeuse/kruemel/stammbaum')
const card = page.locator('.pedigree-card--root')
await expect(card).toBeVisible()
// Long-Press simulieren: echtes touchstart (mit Touch-Objekt) auslösen; der
// Timer im PedigreeCard öffnet nach ~500 ms das Menü.
const dispatched = await card.evaluate((el) => {
if (typeof Touch === 'undefined' || typeof TouchEvent === 'undefined') return false
const r = el.getBoundingClientRect()
const t = new Touch({ identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10 })
el.dispatchEvent(
new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }),
)
return true
})
test.skip(!dispatched, 'TouchEvent in diesem Projekt nicht verfügbar')
await expect(page.locator('.stammbaum-context-menu')).toBeVisible({ timeout: 2000 })
await expect(
page.locator('.stammbaum-context-menu').getByRole('menuitem', { name: st.contextMenu.copyId }),
).toBeVisible()
})

View File

@@ -45,12 +45,20 @@ function matchesFilter(row: Row, filter: string | null): boolean {
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
if (!orderBy) return rows
const [field, dir] = orderBy.split(/\s+/)
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1
// Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
// is "<field> [desc]"; later keys act as tiebreakers.
const keys = orderBy.split(',').map((part) => {
const [field, dir] = part.trim().split(/\s+/)
return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 }
})
return [...rows].sort((a, b) => {
const av = String(a[field] ?? '')
const bv = String(b[field] ?? '')
return av < bv ? -sign : av > bv ? sign : 0
for (const { field, sign } of keys) {
const av = String(a[field] ?? '')
const bv = String(b[field] ?? '')
if (av < bv) return -sign
if (av > bv) return sign
}
return 0
})
}
@@ -131,6 +139,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
name: 'Frau Erika Muster',
address: 'Musterweg 1, 12345 Musterstadt',
phone: '', email: '', homepage: '', city: 'Musterstadt',
nameSuffix: '',
})
}
if (method === 'PUT') return json(route, 204)
@@ -245,6 +254,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
// Gehege-Bilder (analog zu Tier-Fotos).
m = path.match(/^\/enclosures\/([^/]+)\/photos$/)
if (m) {
if (method === 'GET') return json(route, 200, [])
return json(route, 405)
}
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
if (m) {
const isKruemel = m[1] === 'kruemel'
@@ -381,6 +397,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
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>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -76,6 +76,8 @@ export interface MockDb {
saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
}
function gerbil(
@@ -120,6 +122,23 @@ export function seedDb(): MockDb {
enclosureId: 'enc-gross',
originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten',
// NACHVERFOLGUNG: Datenherkunft (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Stammbaum von Krümel.xlsx', 'Wurfchronik-Detail.docx'],
mergedRecordCount: 2,
fromWurfchronik: true,
parentMethod: 'chart-position',
parentConfidence: 'medium',
notes: ['aus 2 Datensätzen zusammengeführt'],
history: [
'In „Stammbaum von Krümel.xlsx“ gefunden.',
'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
'Genotyp aus „Stammbaum von Krümel.xlsx“.',
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
'Angaben aus der Wurfchronik übernommen.',
],
}),
},
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
@@ -136,8 +155,10 @@ export function seedDb(): MockDb {
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
{
@@ -169,10 +190,34 @@ export function seedDb(): MockDb {
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
// SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide
// Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das
// Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen.
gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'),
gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'),
{ ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false },
{ ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false },
gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'),
]
const litters: Litter[] = [
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
{
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
history: [
'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
'Geschwister-Würfe zusammengeführt.',
],
}),
},
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
@@ -189,9 +234,24 @@ export function seedDb(): MockDb {
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
const contacts: Contact[] = [
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null },
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' },
{
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
history: [
'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
'Sowohl als Züchter als auch als Abnehmer geführt.',
],
}),
},
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
]
const colorVarieties = [
@@ -311,5 +371,6 @@ export function seedDb(): MockDb {
contracts: [],
saleAdConfigured: true,
namesConfigured: true,
feedback: [],
}
}

View File

@@ -0,0 +1,101 @@
/** NACHVERFOLGUNG: "Datenherkunft"-Button in der Tierakte öffnet den
* Nachverfolgungs-Dialog mit Quelldateien, Datensatzanzahl und Hinweisen. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const p = de.provenance
test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
await expect(dialog).toContainText(
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
)
// Quelldateien werden angezeigt.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
await expect(dialog).toContainText('Wurfchronik-Detail.docx')
// Zusammenführungs-Info + Wurfchronik-Hinweis.
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
// Eltern-Herkunft (übersetzte Methode/Konfidenz).
await expect(dialog).toContainText(p.methods['chart-position'])
await expect(dialog).toContainText(p.confidences.medium)
// Hinweis aus dem Import.
await expect(dialog).toContainText('aus 2 Datensätzen zusammengeführt')
// Schließen (Footer-Button — Header-Button trägt dasselbe aria-label).
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ page }) => {
skipUnlessMock()
// Luna hat keine provenance im Mock → Leer-Zustand.
await page.goto('/rennmaeuse/luna')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(p.empty)
})
test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Kontakts.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('als Züchter erkannt')
await expect(dialog).toContainText('als Abnehmer erkannt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Wurfs.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('aus Wurfchronik')
await expect(dialog).toContainText('Geschwister-Würfe zusammengeführt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})

View File

@@ -111,6 +111,33 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
})
test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus
// demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister.
await page.goto('/rennmaeuse/inzucht-kind/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fit = page.getByRole('button', { name: t.zoomFit })
if (await fit.isVisible()) await fit.click()
await page.waitForTimeout(600)
// Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den
// Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig).
await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible()
// Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater).
const ref = page.locator('.pedigree-card--sibling')
await expect(ref).toBeVisible()
await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock'))
await expect(ref).toContainText(t.siblingPairing.refHint)
// Mini-Legende ergänzt den Geschwister-Hinweis.
await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings)
})
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.

View File

@@ -46,7 +46,7 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
const name = uniqueName('Tier')
await page.goto('/rennmaeuse/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.male })
await page.getByRole('button', { name: t.genderLabels.male, exact: false }).click()
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
@@ -54,3 +54,32 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
})
test('Gehege-Feld Sichtbarkeit bei verstorbenen/abgegebenen Tieren', async ({ page }) => {
skipUnlessMock()
// 1. Krümel ist Breeding, hat Gehege 'Großbecken'
await page.goto('/rennmaeuse/kruemel')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
await expect(page.locator('dd', { hasText: 'Großbecken' })).toBeVisible()
// 2. Verstorbene UND abgegebene Tiere sind nicht mehr im eigenen Bestand
// -> Gehege ausgeblendet (unabhängig von der Abnehmer-Rolle).
for (const slug of ['willi', 'pauli', 'pauli-breeder', 'pauli-neither']) {
await page.goto(`/rennmaeuse/${slug}`)
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
}
})
test('Verstorbene Tiere: Abnehmer und Inserat-Button ausgeblendet', async ({ page }) => {
skipUnlessMock()
// Willi ist verstorben -> "Abnehmer"-Zeile ausgeblendet und "Zur Abgabe stellen"-Button ausgeblendet
await page.goto('/rennmaeuse/willi')
await expect(page.locator('dt', { hasText: t.fields.receiver })).toBeHidden()
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
// Krümel lebt -> "Zur Abgabe stellen"-Button vorhanden
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeVisible()
})

View File

@@ -52,7 +52,7 @@ test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
const name = uniqueName('Testmaus')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.female })
await page.getByRole('button', { name: t.genderLabels.female, exact: false }).click()
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Erfolg: Detailseite des neuen Tieres
@@ -104,6 +104,20 @@ test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', asy
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite zeigt Geschwisterverpaarungs-Hinweis nur wenn die Eltern Vollgeschwister sind (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock + Zwilling Maus aus demselben Wurf
// 'w-zwillinge' → Vollgeschwister → Hinweis sichtbar.
await page.goto('/rennmaeuse/inzucht-kind')
await expect(page.getByRole('heading', { name: 'Inzucht Kind' })).toBeVisible()
await expect(page.locator('.ak-fact--sibling')).toContainText(t.detail.siblingPairing)
// 'Krümel': Eltern Fridolin × Luna aus verschiedenen Würfen → kein Hinweis.
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.locator('.ak-fact--sibling')).toHaveCount(0)
})
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
@@ -174,12 +188,13 @@ test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)'
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
// Wurf K als Link sichtbar
const litterLink = page.getByRole('link', { name: /Wurf K/ })
await expect(litterLink).toBeVisible()
// Vater-Badge sichtbar
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
// Vater-Rollen-Badge im Wurf-Eintrag sichtbar (die Akte zeigt „Vater" jetzt auch
// als Stammdaten-Zeile, daher gezielt auf das Badge eingrenzen).
await expect(page.locator('.badge', { hasText: t.detail.parentLittersRoleVater })).toBeVisible()
// Navigation zum Wurf funktioniert
await litterLink.first().click()
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
@@ -189,6 +204,6 @@ test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTER
skipUnlessMock()
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
})

View File

@@ -47,7 +47,7 @@ test('Jungtier erfassen — Formular ist mit Wurf + Geburtsdatum vorbelegt', asy
const name = uniqueName('Jungtier')
await page.getByLabel(`${tg.fields.name} *`).fill(name)
await page.getByLabel(`${tg.fields.gender} *`).selectOption({ label: tg.genderLabels.male })
await page.getByRole('button', { name: tg.genderLabels.male, exact: false }).click()
await page.getByRole('button', { name: tg.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()

View File

@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
import StammbaumPage from './pages/StammbaumPage'
import StatistikPage from './pages/StatistikPage'
import HilfePage from './pages/HilfePage'
import FarbkatalogPage from './pages/FarbkatalogPage'
import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage'
@@ -70,6 +71,7 @@ export default function App() {
<Route path="abgabe" element={<AbgabePage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe" element={<HilfePage />} />
<Route path="farbkatalog" element={<FarbkatalogPage />} />
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
<Route path="vertraege">
<Route index element={<VertraegeListPage />} />

View File

@@ -10,6 +10,9 @@ export interface CreateContact {
phone?: string | null
address?: string | null
notes?: string | null
isBreeder?: boolean
isReceiver?: boolean
nameSuffix?: string | null
}
/** Payload for PUT /contacts/{id}. */

View File

@@ -22,6 +22,8 @@ export interface CreateSaleContract {
price: number
handoverDate: DateOnlyString
contractDate?: DateOnlyString | null
/** Optionales Vertragsfoto je Tier: GerbilId → GerbilPhoto-Id. */
animalPhotos?: Record<string, string>
}
export function listContracts(query: GridifyQuery): Promise<Paged<SaleContract>> {
@@ -40,3 +42,8 @@ export function deleteContract(id: string): Promise<void> {
export function contractFileUrl(contract: Pick<SaleContract, 'url'>): string {
return `${API_BASE_URL}${contract.url}`
}
/** Absolute Download-URL des druckfertigen PDF. */
export function contractPdfUrl(contract: Pick<SaleContract, 'id'>): string {
return `${API_BASE_URL}/contracts/${contract.id}/pdf`
}

View File

@@ -0,0 +1,47 @@
/** Fotos für Gehege (Enclosure) — analog zu api/photos, gleiche Dateiablage. */
import { API_BASE_URL, ApiError, api } from './client'
import { de } from '../strings/de'
import { photoSrc, type GerbilPhoto } from './photos'
/** Identical shape to a gerbil photo (PhotoDto). */
export type EnclosurePhoto = GerbilPhoto
export function listEnclosurePhotos(enclosureId: string): Promise<EnclosurePhoto[]> {
return api.get<EnclosurePhoto[]>(`/enclosures/${enclosureId}/photos`)
}
export async function uploadEnclosurePhoto(
enclosureId: string,
file: File,
caption: string | null,
): Promise<EnclosurePhoto> {
const form = new FormData()
form.append('file', file)
if (caption) form.append('caption', caption)
let response: Response
try {
response = await fetch(`${API_BASE_URL}/enclosures/${enclosureId}/photos`, {
method: 'POST',
body: form,
})
} catch {
throw new ApiError(de.api.errors.network, null)
}
if (!response.ok) {
const message =
response.status === 404
? de.api.errors.notFound
: response.status >= 500
? de.api.errors.server
: de.api.errors.unknown
throw new ApiError(message, response.status)
}
return (await response.json()) as EnclosurePhoto
}
export function deleteEnclosurePhoto(photoId: string): Promise<void> {
return api.delete(`/enclosure-photos/${photoId}`)
}
export { photoSrc }

View File

@@ -0,0 +1,37 @@
/** 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' | 'contact-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
contactId?: 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
contactId: 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

@@ -22,13 +22,15 @@ export async function listEnclosures(): Promise<Enclosure[]> {
}
export async function listContacts(): Promise<Contact[]> {
const paged = await api.get<Paged<Contact>>(`${resources.contacts}${ALL}`)
const paged = await api.get<Paged<Contact>>(
`${resources.contacts}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'name' })}`
)
return paged.items
}
export async function listLitters(): Promise<Litter[]> {
const paged = await api.get<Paged<Litter>>(
`${resources.litters}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'date desc' })}`,
`${resources.litters}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'date desc' })}`,
)
return paged.items
}

View File

@@ -10,6 +10,8 @@ export interface BreederProfile {
homepage: string
/** Ort der Unterschriftszeile („{Ort}, den {Datum}“). */
city: string
/** Namens-Anhängsel („Nachname") der eigenen Zucht, z. B. „von den kleinen Chaoten“. */
nameSuffix: string
}
export const EMPTY_BREEDER_PROFILE: BreederProfile = {
@@ -20,6 +22,7 @@ export const EMPTY_BREEDER_PROFILE: BreederProfile = {
email: '',
homepage: '',
city: '',
nameSuffix: '',
}
/** Pflichtangaben für einen brauchbaren Vertrag (Hinweis-Logik der UI). */

View File

@@ -60,8 +60,50 @@ export interface Gerbil {
isDeaf?: boolean | null
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
profilePhotoUrl?: string | null
spottingType?: string | null
isCastrated?: boolean
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link GerbilProvenance} für die geparste Form).
* null = manuell angelegtes Tier / keine Importdaten.
*/
provenance?: string | null
}
/**
* NACHVERFOLGUNG: geparste Datenherkunft eines importierten Tiers (JSON aus
* {@link Gerbil.provenance}). Beschreibt, WELCHE Quellinformationen den
* Datenbankeintrag erzeugt haben.
*/
export interface GerbilProvenance {
/** Alle Quelldateien (Stammbäume / Wurfchronik), die beigetragen haben. */
sourceFiles: string[]
/** Anzahl der Rohdatensätze, die zu diesem Tier zusammengeführt wurden. */
mergedRecordCount: number
/** Hat ein Wurfchronik-Treffer beigetragen? */
fromWurfchronik: boolean
/** Wie die Eltern abgeleitet wurden (z. B. "chart-position", "decision"). */
parentMethod?: string
/** Konfidenz der Eltern-Ableitung (z. B. "low" | "medium" | "high"). */
parentConfidence?: string
/** Menschlich lesbare Herkunftshinweise (deutsch). */
notes: string[]
/**
* Chronologischer, dateibezogener Verlauf (deutsch): liest sich wie ein
* Protokoll, WELCHE Quelldatei WELCHE Angabe beigetragen hat (z. B.
* „Geburtsdatum (27.03.2022) aus Stammbaum von X.xlsx'."). Primärinhalt des
* Datenherkunft-Dialogs.
*/
history: string[]
}
/**
* NACHVERFOLGUNG: geparste Datenherkunft eines beliebigen importierten Eintrags
* (Tier, Kontakt oder Wurf). {@link GerbilProvenance} ist die Tier-Spezialisierung
* mit zusätzlichen Eltern-Feldern; Kontakte/Würfe nutzen dieselbe Grundform.
*/
export type EntityProvenance = GerbilProvenance
/** Payload for POST /gerbils. */
export interface CreateGerbil {
name: string
@@ -83,6 +125,8 @@ export interface CreateGerbil {
characterNote?: string | null
isResident?: boolean
isDeaf?: boolean | null
spottingType?: string | null
isCastrated?: boolean
}
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
@@ -108,6 +152,15 @@ export interface Contact {
phone: string | null
address: string | null
notes: string | null
isBreeder: boolean
isReceiver: boolean
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
nameSuffix: string | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
}
export interface Litter {
@@ -128,6 +181,11 @@ export interface Litter {
motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
}
/** Payload for POST /litters. */

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { de } from '../strings/de'
import GerbilIcon from './GerbilIcon'
import { BreederSuffixProvider } from './BreederSuffixProvider'
import './appShell.css'
interface NavItem {
@@ -31,6 +32,7 @@ const SECONDARY: NavItem[] = [
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
{ to: '/farbkatalog', label: de.nav.colorCatalog, icon: '🎨' },
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
]
@@ -82,7 +84,9 @@ export default function AppShell() {
</nav>
<main className="app-main">
<Outlet />
<BreederSuffixProvider>
<Outlet />
</BreederSuffixProvider>
</main>
</div>
)

View File

@@ -0,0 +1,17 @@
/**
* ZUCHT-SUFFIX: provides the own-Zucht name suffix (from the breeder profile) once
* to the whole app. Consumers use `useGerbilName()` from ./breederSuffix.
*/
import type { ReactNode } from 'react'
import { getBreederProfile } from '../api/settings'
import { useApi } from '../hooks/useApi'
import { OwnSuffixContext } from './breederSuffix'
export function BreederSuffixProvider({ children }: { children: ReactNode }) {
const profile = useApi(() => getBreederProfile(), [])
return (
<OwnSuffixContext.Provider value={profile.data?.nameSuffix ?? ''}>
{children}
</OwnSuffixContext.Provider>
)
}

View File

@@ -0,0 +1,156 @@
.color-picker-container {
position: relative;
width: 100%;
}
.color-picker-trigger {
font: inherit;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
min-height: 44px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.color-picker-trigger:hover {
border-color: var(--color-accent);
}
.color-picker-trigger:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-soft);
}
.color-picker-trigger-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-picker-trigger-name {
font-weight: 500;
}
.color-picker-placeholder {
color: var(--color-text-muted);
}
.color-picker-caret {
color: var(--color-text-muted);
font-size: 0.8rem;
transition: transform 0.15s ease;
}
.color-picker-container[aria-expanded="true"] .color-picker-caret {
transform: rotate(180deg);
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 0.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(59, 48, 38, 0.12);
display: flex;
flex-direction: column;
max-height: 320px;
overflow: hidden;
}
.color-picker-search-wrapper {
padding: 0.5rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.color-picker-search {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 0.9rem;
min-height: 36px;
}
.color-picker-search:focus {
outline: none;
border-color: var(--color-accent);
}
.color-picker-options {
overflow-y: auto;
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 1px;
}
.color-picker-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.4rem 0.6rem;
border: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 0.95rem;
cursor: pointer;
border-radius: 0.375rem;
text-align: left;
min-height: 40px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.color-picker-option:hover {
background: var(--color-accent-soft);
}
.color-picker-option--selected {
background: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 600;
}
.color-picker-option-image-wrapper {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.color-picker-option-image-placeholder {
width: 24px;
height: 24px;
border-radius: 0.25rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
flex-shrink: 0;
}
.color-picker-no-results {
padding: 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}

View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useRef } from 'react'
import { de } from '../strings/de'
import { type ColorVariety } from '../api/types'
import FarbschlagImage from './FarbschlagImage'
import './ColorVarietyPicker.css'
interface ColorVarietyPickerProps {
value: string
onChange: (id: string) => void
colorVarieties: ColorVariety[]
}
export default function ColorVarietyPicker({ value, onChange, colorVarieties }: ColorVarietyPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside of it
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Find currently selected variety
const selectedVariety = colorVarieties.find((cv) => cv.id === value)
// Filter varieties by search query
const filtered = colorVarieties.filter((cv) =>
cv.name.toLowerCase().includes(search.toLowerCase())
)
const handleSelect = (id: string) => {
onChange(id)
setIsOpen(false)
setSearch('')
}
return (
<div className="color-picker-container" ref={containerRef}>
<button
type="button"
className="color-picker-trigger"
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className="color-picker-trigger-content">
{selectedVariety ? (
<>
<FarbschlagImage name={selectedVariety.name} size={24} />
<span className="color-picker-trigger-name">{selectedVariety.name}</span>
</>
) : (
<span className="color-picker-placeholder">{de.pages.gerbils.form.none}</span>
)}
</span>
<span className="color-picker-caret"></span>
</button>
{isOpen && (
<div className="color-picker-dropdown">
<div className="color-picker-search-wrapper">
<input
type="text"
className="color-picker-search"
placeholder="Farbschlag suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="color-picker-options" role="listbox">
<button
type="button"
className={`color-picker-option ${!value ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect('')}
role="option"
aria-selected={!value}
>
<div className="color-picker-option-image-placeholder" />
<span className="color-picker-option-name">{de.pages.gerbils.form.none}</span>
</button>
{filtered.map((cv) => {
const isSelected = cv.id === value
return (
<button
key={cv.id}
type="button"
className={`color-picker-option ${isSelected ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect(cv.id)}
role="option"
aria-selected={isSelected}
>
<div className="color-picker-option-image-wrapper">
<FarbschlagImage name={cv.name} size={24} />
</div>
<span className="color-picker-option-name">{cv.name}</span>
</button>
)
})}
{filtered.length === 0 && (
<div className="color-picker-no-results">
Keine Farbschläge gefunden
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
/** Bilder eines Gehges — Galerie-Raster + Upload + Löschen (wie der Tier-Fotos-Tab). */
import { useRef, useState, type FormEvent } from 'react'
import { de } from '../strings/de'
import {
deleteEnclosurePhoto,
listEnclosurePhotos,
photoSrc,
uploadEnclosurePhoto,
} from '../api/enclosurePhotos'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from './toast'
export default function EnclosurePhotosSection({ enclosureId }: { enclosureId: string }) {
const tp = de.pages.tierTabs.photos
const toast = useToast()
const photos = useApi(() => listEnclosurePhotos(enclosureId), [enclosureId])
const fileInput = useRef<HTMLInputElement>(null)
const [caption, setCaption] = useState('')
const [fileError, setFileError] = useState<string | null>(null)
const upload = useMutation((file: File) =>
uploadEnclosurePhoto(enclosureId, file, caption.trim() === '' ? null : caption.trim()),
)
const removal = useMutation((photoId: string) => deleteEnclosurePhoto(photoId))
async function onUpload(e: FormEvent) {
e.preventDefault()
const file = fileInput.current?.files?.[0]
if (!file) {
setFileError(tp.validation.fileRequired)
return
}
setFileError(null)
const r = await upload.run(file)
if (r.ok) {
toast.success(de.common.saved)
setCaption('')
if (fileInput.current) fileInput.current.value = ''
photos.reload()
} else {
toast.error(r.error)
}
}
async function onDelete(photoId: string) {
if (!window.confirm(tp.deleteConfirm)) return
const r = await removal.run(photoId)
if (r.ok) {
toast.success(de.common.deleted)
photos.reload()
} else {
toast.error(r.error)
}
}
const items = photos.data ?? []
return (
<div>
<form className="form" onSubmit={onUpload} noValidate>
<label className="field">
<span>{tp.chooseFile}</span>
<input ref={fileInput} type="file" accept="image/*" className="input" />
{fileError && <small className="error-text">{fileError}</small>}
</label>
<label className="field">
<span>{tp.captionLabel}</span>
<input className="input" value={caption} onChange={(e) => setCaption(e.target.value)} />
</label>
{upload.error && <div className="alert alert--error">{upload.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={upload.pending}>
{upload.pending ? tp.uploading : tp.uploadButton}
</button>
</div>
</form>
{photos.loading && <p className="muted">{de.common.loading}</p>}
{photos.error && (
<div className="alert alert--error">
<span>{photos.error}</span>
<button type="button" className="btn" onClick={photos.reload}>
{de.common.retry}
</button>
</div>
)}
{removal.error && <div className="alert alert--error">{removal.error}</div>}
{!photos.loading && !photos.error && items.length === 0 && <p className="muted">{tp.empty}</p>}
{items.length > 0 && (
<ul className="photo-grid">
{[...items]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((p) => (
<li key={p.id} className="photo-card">
<img src={photoSrc(p)} alt={p.caption ?? p.fileName} loading="lazy" />
<div className="photo-card__bar">
<span className="photo-card__caption">{p.caption ?? ''}</span>
<button
type="button"
className="btn btn--danger"
onClick={() => onDelete(p.id)}
disabled={removal.pending}
>
{de.common.delete}
</button>
</div>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -1,20 +1,26 @@
/**
* FEAT-6: Profilfoto im Detail-Kopf — erstes Foto nach sortOrder.
* Selbstständig ladend; rendert nichts, solange es keine Fotos gibt
* (oder die Foto-Endpunkte noch nicht live sind).
* FEAT-6: Profilfoto — erstes Foto nach sortOrder. Selbstständig ladend.
* Ohne Foto rendert es `fallback` (oder nichts).
*/
import type { ReactNode } from 'react'
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
import { useApi } from '../hooks/useApi'
export default function GerbilProfilePhoto({ gerbilId }: { gerbilId: string }) {
interface GerbilProfilePhotoProps {
gerbilId: string
/** CSS class for the <img> (default: the small detail-header avatar). */
className?: string
/** Rendered when the gerbil has no photo yet (e.g. a hero placeholder). */
fallback?: ReactNode
}
export default function GerbilProfilePhoto({
gerbilId,
className = 'profile-photo',
fallback = null,
}: GerbilProfilePhotoProps) {
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
const profile = profilePhoto(photos.data ?? [])
if (!profile) return null
return (
<img
className="profile-photo"
src={photoSrc(profile)}
alt={profile.caption ?? profile.fileName}
/>
)
if (!profile) return <>{fallback}</>
return <img className={className} src={photoSrc(profile)} alt={profile.caption ?? profile.fileName} />
}

View File

@@ -10,6 +10,7 @@ import { formatDate, genderLabel } from '../format/labels'
import { traitLabels } from '../format/traits'
import FarbschlagImage from './FarbschlagImage'
import Charakterbogen from './Charakterbogen'
import { useGerbilName } from './breederSuffix'
type SaleStatus = 'free' | 'loose' | 'reserved'
@@ -32,6 +33,7 @@ function groupGender(animals: Gerbil[]): string {
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
const t = de.pages.abgabe
const gerbilName = useGerbilName()
const navigate = useNavigate()
const [status, setStatus] = useState<SaleStatus>('free')
const [reservedName, setReservedName] = useState('')
@@ -203,7 +205,7 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
<div key={a.id} className="group-composer__animal">
<div className="group-composer__animal-head">
<FarbschlagImage name={farbschlagOf(a)} size={36} />
<strong>{a.name}</strong>
<strong>{gerbilName(a) || a.name}</strong>
<span className="muted">
{farbschlagOf(a)} · {genderLabel(a.gender)}
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}

View File

@@ -0,0 +1,173 @@
/**
* NACHVERFOLGUNG: "Nachverfolgungsinformationen" — read-only modal showing the
* data provenance of an imported gerbil (which source files contributed, how many
* raw records were merged, how the parents were derived, and human-readable notes).
*
* The provenance arrives as a JSON string on the Gerbil DTO (produced by the Python
* merge step). We parse it here defensively; anything unparseable / absent is shown
* as "Keine Herkunftsdaten vorhanden." Mirrors the ReportErrorDialog modal pattern.
*/
import { useEffect } from 'react'
import { de } from '../strings/de'
import type { EntityProvenance } from '../api/types'
import './provenanceDialog.css'
interface ProvenanceDialogProps {
open: boolean
onClose: () => void
/** Raw provenance JSON string from the entity (Gerbil/Contact/Litter; null = no import data). */
provenance?: string | null
/** Optional entity label (e.g. "dieses Tiers", "dieses Kontakts") for the intro line. */
entityLabel?: string
}
/** Parse the JSON provenance string defensively; null on absence / parse error. */
function parseProvenance(raw?: string | null): EntityProvenance | null {
if (!raw || !raw.trim()) return null
try {
const p = JSON.parse(raw) as Partial<EntityProvenance>
return {
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
fromWurfchronik: Boolean(p.fromWurfchronik),
parentMethod: p.parentMethod,
parentConfidence: p.parentConfidence,
notes: Array.isArray(p.notes) ? p.notes : [],
history: Array.isArray(p.history) ? p.history : [],
}
} catch {
return null
}
}
export default function ProvenanceDialog({ open, onClose, provenance, entityLabel }: ProvenanceDialogProps) {
// Close on Escape (only while mounted).
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
const t = de.provenance
const p = parseProvenance(provenance)
const method = p?.parentMethod ? (t.methods[p.parentMethod] ?? p.parentMethod) : null
const confidence = p?.parentConfidence
? (t.confidences[p.parentConfidence] ?? p.parentConfidence)
: null
return (
<div className="provenance__backdrop" onClick={onClose} role="presentation">
<div
className="provenance__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="provenance-title"
onClick={(e) => e.stopPropagation()}
>
<div className="provenance__header">
<h2 id="provenance-title" className="provenance__title">
{t.dialogTitle}
</h2>
<button
type="button"
className="provenance__close"
onClick={onClose}
aria-label={t.close}
>
</button>
</div>
{!p ? (
<p className="provenance__empty">{t.empty}</p>
) : (
<>
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
{p.history.length > 0 && (
<section className="provenance__section">
<div className="provenance__section-title">{t.historyTitle}</div>
<ol className="provenance__history">
{p.history.map((step, i) => (
<li key={`${i}-${step}`} className="provenance__history-step">
{step}
</li>
))}
</ol>
</section>
)}
<section className="provenance__section">
<div className="provenance__section-title">
{t.mergedTitle}
</div>
<p className="provenance__merged">{t.mergedCount(p.mergedRecordCount)}</p>
{p.fromWurfchronik && (
<p className="provenance__wurfchronik">📖 {t.fromWurfchronik}</p>
)}
</section>
<section className="provenance__section">
<div className="provenance__section-title">
{t.sourceFilesTitle} {t.sourceFilesCount(p.sourceFiles.length)}
</div>
{p.sourceFiles.length === 0 ? (
<p className="provenance__empty">{t.empty}</p>
) : (
<ul className="provenance__files">
{p.sourceFiles.map((f) => (
<li key={f} className="provenance__file">
{f}
</li>
))}
</ul>
)}
</section>
{(method || confidence) && (
<section className="provenance__section">
<div className="provenance__section-title">{t.parentsTitle}</div>
<dl className="provenance__kvlist">
{method && (
<div className="provenance__kv">
<dt>{t.parentMethodLabel}</dt>
<dd>{method}</dd>
</div>
)}
{confidence && (
<div className="provenance__kv">
<dt>{t.parentConfidenceLabel}</dt>
<dd>{confidence}</dd>
</div>
)}
</dl>
</section>
)}
{p.notes.length > 0 && (
<section className="provenance__section">
<div className="provenance__section-title">{t.notesTitle}</div>
<ul className="provenance__notes">
{p.notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
</section>
)}
</>
)}
<div className="provenance__actions">
<button type="button" className="btn btn--primary" onClick={onClose}>
{t.close}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
/**
* 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
contactId?: 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,
contactId: context.contactId ?? 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,43 @@
/** Toast notifications: provider + auto-dismissing toast stack. */
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
import { ToastContext, type ToastApi, type ToastVariant } from './toast'
import './toast.css'
interface ToastItem {
id: number
message: string
variant: ToastVariant
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const idRef = useRef(0)
const remove = useCallback((id: number) => setToasts((ts) => ts.filter((t) => t.id !== id)), [])
const api = useMemo<ToastApi>(() => {
const show = (message: string, variant: ToastVariant = 'info') => {
const id = (idRef.current += 1)
setToasts((ts) => [...ts, { id, message, variant }])
window.setTimeout(() => remove(id), 3500)
}
return { show, success: (m) => show(m, 'success'), error: (m) => show(m, 'error') }
}, [remove])
return (
<ToastContext.Provider value={api}>
{children}
<div className="toast-stack" role="status" aria-live="polite">
{toasts.map((t) => (
<button
key={t.id}
type="button"
className={`toast toast--${t.variant}`}
onClick={() => remove(t.id)}
>
{t.message}
</button>
))}
</div>
</ToastContext.Provider>
)
}

View File

@@ -0,0 +1,17 @@
/**
* ZUCHT-SUFFIX: the own-Zucht name-suffix context + the `useGerbilName()` helper.
* Kept separate from the provider component so each module has a single concern
* (and satisfies react-refresh's "components-only" rule for .tsx files).
*/
import { createContext, useContext } from 'react'
import { gerbilDisplayName } from '../format/gerbilName'
import type { Gerbil } from '../api/types'
export const OwnSuffixContext = createContext<string>('')
/** Returns a function that renders a gerbil's display name incl. the Zucht suffix. */
export function useGerbilName() {
const ownSuffix = useContext(OwnSuffixContext)
return (g: Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>): string =>
gerbilDisplayName(g, ownSuffix)
}

View File

@@ -65,3 +65,54 @@
color: #d97706;
white-space: nowrap;
}
/* Collapsible details styling for the Charakter & Eigenschaften section */
.charakter-details {
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
background: var(--color-surface, #fff);
margin-top: 1.5rem;
margin-bottom: 1.5rem;
overflow: hidden;
}
.charakter-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
background: var(--color-accent-soft, #f9f9f9);
transition: background 0.2s ease;
}
.charakter-summary::-webkit-details-marker {
display: none;
}
.charakter-summary:hover {
background: var(--color-hover, #f5f5f5);
}
.charakter-summary h3 {
margin: 0;
font-size: 1.25rem;
color: var(--color-text, #333);
}
.charakter-chevron {
font-size: 1.1rem;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.charakter-details[open] > .charakter-summary .charakter-chevron {
transform: rotate(180deg);
}
.charakter-body {
padding: 1.25rem;
border-top: 1px solid var(--color-border, #ddd);
}

View File

@@ -0,0 +1,171 @@
/* NACHVERFOLGUNG: "Nachverfolgungsinformationen" modal dialog. Mirrors the
reportErrorDialog modal pattern. */
.provenance__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);
}
.provenance__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: provenanceIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes provenanceIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.provenance__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.provenance__title {
margin: 0;
font-size: 1.15rem;
}
.provenance__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;
}
.provenance__close:hover {
background: var(--color-border, #eee);
}
.provenance__intro {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.provenance__empty {
margin: 0.4rem 0;
font-size: 0.9rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
.provenance__section {
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));
}
.provenance__section-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;
}
.provenance__merged {
margin: 0;
font-size: 0.9rem;
}
.provenance__wurfchronik {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: var(--color-text-muted, #666);
}
.provenance__files {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
}
.provenance__file {
font-size: 0.85rem;
word-break: break-word;
}
.provenance__kvlist {
margin: 0;
display: grid;
gap: 0.25rem;
}
.provenance__kv {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.provenance__kv dt {
flex: 0 0 6rem;
font-weight: 600;
color: var(--color-text-muted, #777);
}
.provenance__kv dd {
margin: 0;
min-width: 0;
word-break: break-word;
}
.provenance__notes {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.85rem;
}
/* Chronologischer Verlauf: liest sich wie ein Protokoll der Datenherkunft. */
.provenance__history {
margin: 0;
padding-left: 1.25rem;
display: grid;
gap: 0.35rem;
}
.provenance__history-step {
font-size: 0.88rem;
line-height: 1.35;
word-break: break-word;
}
.provenance__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

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

@@ -0,0 +1,55 @@
/* Toast stack: centered above the mobile action bar (normal bottom inset on desktop). */
.toast-stack {
position: fixed;
left: 50%;
bottom: calc(5rem + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
width: max-content;
max-width: calc(100vw - 2rem);
pointer-events: none;
}
.toast {
pointer-events: auto;
cursor: pointer;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: var(--color-text);
border: none;
border-radius: 12px;
padding: 0.7rem 1.1rem;
box-shadow: 0 6px 24px rgba(43, 33, 25, 0.28);
text-align: center;
animation: toastIn 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.toast--success {
background: #5e8c5a;
}
.toast--error {
background: #a6433a;
}
.toast--info {
background: var(--color-accent);
}
@media (min-width: 768px) {
.toast-stack {
bottom: 1.5rem;
}
}

View File

@@ -0,0 +1,19 @@
/** Toast notifications: the context + `useToast()` hook (kept separate from the
* provider component so each module has a single concern / fast-refresh stays happy). */
import { createContext, useContext } from 'react'
export type ToastVariant = 'success' | 'error' | 'info'
export interface ToastApi {
show: (message: string, variant?: ToastVariant) => void
success: (message: string) => void
error: (message: string) => void
}
const NOOP: ToastApi = { show: () => {}, success: () => {}, error: () => {} }
export const ToastContext = createContext<ToastApi>(NOOP)
export function useToast(): ToastApi {
return useContext(ToastContext)
}

View File

@@ -0,0 +1,24 @@
/**
* ZUCHT-SUFFIX: a gerbil's display name with the own-Zucht "suffix" (like a last
* name) appended — "Danako" + "von den kleinen Chaoten" → "Danako von den kleinen
* Chaoten". Per the chosen rule, only OWN-bred animals get the suffix; animals from
* other breeders keep their plain name.
*/
import type { Gerbil } from '../api/types'
type NameParts = Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>
/** Own-bred = no external origin recorded (no origin contact, no freetext breeder). */
export function isOwnBred(g: NameParts): boolean {
return !g.originContactId && !(g.originBreeder && g.originBreeder.trim())
}
/**
* Name + own-Zucht suffix for own-bred animals; plain name otherwise.
* Returns '' for a nameless animal (callers fall back to the "(ohne Namen)" label).
*/
export function gerbilDisplayName(g: NameParts, ownSuffix: string): string {
if (!g.name) return ''
const suffix = ownSuffix.trim()
return suffix && isOwnBred(g) ? `${g.name} ${suffix}` : g.name
}

View File

@@ -73,10 +73,15 @@ export function useInfiniteList<T>(
error: null,
})
// Tracks an in-flight request synchronously so loadMore() can't advance (and
// re-fetch/append) the same page twice if the scroll sentinel fires repeatedly.
const inFlightRef = useRef(false)
useEffect(() => {
let cancelled = false
const page = req.page
const id = `${req.nonce}:${req.key}#${page}`
inFlightRef.current = true
loaderRef
.current(page)
.then((res) => {
@@ -92,6 +97,9 @@ export function useInfiniteList<T>(
.catch((err) => {
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
})
.finally(() => {
if (!cancelled) inFlightRef.current = false
})
return () => {
cancelled = true
}
@@ -109,8 +117,35 @@ export function useInfiniteList<T>(
const error = inFlight ? null : sameQuery ? resolved.error : null
const hasMore = sameQuery && !inFlight && items.length < total
const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), [])
const loadMore = useCallback(() => {
if (inFlightRef.current) return
setReq((r) => ({ ...r, page: r.page + 1 }))
}, [])
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
}
/**
* Wires an IntersectionObserver to a sentinel element that triggers loadMore() as
* it scrolls into view. Render the returned ref on a small element shown only while
* `list.hasMore`, e.g. `{list.hasMore && <div ref={sentinelRef} />}`. Re-subscribes
* when the load state changes so it keeps paging while the sentinel stays in view.
*/
export function useInfiniteSentinel<T>(list: InfiniteListState<T>) {
const ref = useRef<HTMLDivElement | null>(null)
const { hasMore, loading, loadingMore, loadMore } = list
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) loadMore()
},
{ rootMargin: '300px 0px' },
)
io.observe(el)
return () => io.disconnect()
}, [hasMore, loading, loadingMore, loadMore])
return ref
}

View File

@@ -348,13 +348,65 @@ textarea {
border: 1px dashed #bbb;
}
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
/* Checkbox-style filter (label text + toggle on one row), vs. the stacked .field. */
.field--check {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
/* Toggle-switch styling for boolean checkboxes + the multi-select pickers
(mobile-friendlier than a checkbox). The element stays a real <input
type="checkbox"> — role, keyboard and form semantics are unchanged. */
.field--check input[type='checkbox'],
.wizard-pick input[type='checkbox'],
.namegen-usages-grid input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
flex: 0 0 auto;
position: relative;
width: 2.5rem;
height: 1.45rem;
min-height: 0;
margin: 0;
border: none;
border-radius: 999px;
background: var(--color-border);
cursor: pointer;
transition: background 0.18s ease;
}
.field--check input[type='checkbox']::after,
.wizard-pick input[type='checkbox']::after,
.namegen-usages-grid input[type='checkbox']::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: calc(1.45rem - 4px);
height: calc(1.45rem - 4px);
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(61, 46, 35, 0.35);
transition: transform 0.18s ease;
}
.field--check input[type='checkbox']:checked,
.wizard-pick input[type='checkbox']:checked,
.namegen-usages-grid input[type='checkbox']:checked {
background: var(--color-accent);
}
.field--check input[type='checkbox']:checked::after,
.wizard-pick input[type='checkbox']:checked::after,
.namegen-usages-grid input[type='checkbox']:checked::after {
transform: translateX(calc(2.5rem - 1.45rem));
}
.field--check input[type='checkbox']:focus-visible,
.wizard-pick input[type='checkbox']:focus-visible,
.namegen-usages-grid input[type='checkbox']:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
@media (min-width: 768px) {
.gerbil-card {
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
@@ -796,10 +848,149 @@ textarea {
}
/* Profilfoto im Detail-Kopf */
.profile-photo {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--color-border);
.profile-header-summary {
display: flex;
align-items: center;
gap: 1.5rem;
}
.profile-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.profile-header-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.profile-photo {
width: 200px;
height: 200px;
border-radius: 1.25rem;
object-fit: cover;
border: 4px solid var(--color-border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-photo:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
}
.form-row-2col {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 0.5rem;
}
.form-row-2col > :first-child {
flex: 2;
min-width: 0;
}
.form-row-2col > :last-child {
flex: 1;
min-width: 0;
min-height: 44px;
display: flex;
align-items: center;
}
.form-group-box {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 1.25rem;
background: var(--color-surface);
box-shadow: inset 0 1px 3px rgba(59, 48, 38, 0.02);
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0.5rem 0 1.25rem 0;
max-width: 32rem; /* match form max-width */
}
.form-group-box__title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
color: var(--color-accent);
margin-bottom: 0.25rem;
border-bottom: 1px dashed var(--color-border);
padding-bottom: 0.5rem;
}
/* Gender Segmented Control / Radio Buttons */
.gender-selector-group {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
max-width: 32rem;
}
.gender-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.gender-btn:hover {
background: var(--color-bg);
border-color: var(--color-text-muted);
}
.gender-btn__icon {
font-size: 1.15rem;
line-height: 1;
}
/* Active styles */
.gender-btn--male.is-active {
border-color: #3b82f6;
background: #eff6ff;
color: #1d4ed8;
box-shadow: 0 0 0 1px #3b82f6;
}
.gender-btn--male.is-active .gender-btn__icon {
color: #2563eb;
}
.gender-btn--female.is-active {
border-color: #ec4899;
background: #fdf2f8;
color: #be185d;
box-shadow: 0 0 0 1px #ec4899;
}
.gender-btn--female.is-active .gender-btn__icon {
color: #db2777;
}
.gender-btn--unknown.is-active {
border-color: #6b7280;
background: #f3f4f6;
color: #374151;
box-shadow: 0 0 0 1px #6b7280;
}
.gender-btn--unknown.is-active .gender-btn__icon {
color: #4b5563;
}

View File

@@ -2,12 +2,15 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ToastProvider } from './components/ToastProvider'
import { installUnhandledRejectionGuard } from './dev/unhandledRejectionGuard'
installUnhandledRejectionGuard()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ToastProvider>
<App />
</ToastProvider>
</StrictMode>,
)

View File

@@ -7,12 +7,14 @@ import type { Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
import GroupComposer from '../components/GroupComposer'
import { useGerbilName } from '../components/breederSuffix'
import './abgabe.css'
const UNGROUPED = 'ungrouped'
export default function AbgabePage() {
const t = de.pages.abgabe
const gerbilName = useGerbilName()
const forSale = useApi(
() =>
@@ -82,7 +84,7 @@ export default function AbgabePage() {
<ul className="abgabe-roster__list">
{animals.map((a) => (
<li key={a.id}>
<span>{a.name}</span>
<span>{gerbilName(a) || a.name}</span>
<select
value={assignment(a)}
onChange={(e) => moveTo(a.id, e.target.value)}

View File

@@ -13,7 +13,8 @@ import {
syncRequests,
type RequestStatus,
} from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi'
import { useMutation } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDateTime } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './anfragen.css'
@@ -31,19 +32,20 @@ export function StatusBadge({ status }: { status: RequestStatus }) {
export default function AnfragenPage() {
const t = de.pages.anfragen
const [status, setStatus] = useState<RequestStatus | ''>('')
const [page, setPage] = useState(1)
const [notice, setNotice] = useState<string | null>(null)
const requests = useApi(
() =>
const requests = useInfiniteList(
(page) =>
listRequests({
page,
pageSize: PAGE_SIZE,
orderBy: 'receivedAt desc',
orderBy: 'receivedAt desc,id',
filter: status === '' ? undefined : `status==${status}`,
}),
[page, status],
`${status}`,
)
const sentinelRef = useInfiniteSentinel(requests)
const { items, total, loading } = requests
const sync = useMutation(() => syncRequests())
async function onSync() {
@@ -63,7 +65,7 @@ export default function AnfragenPage() {
<header className="page-head">
<div>
<h2>{t.title}</h2>
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
{!loading && <p className="muted">{t.countText(total)}</p>}
</div>
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
@@ -78,16 +80,13 @@ export default function AnfragenPage() {
{/* Status-Filter */}
<FilterPanel
activeCount={status !== '' ? 1 : 0}
onReset={() => { setStatus(''); setPage(1) }}
onReset={() => setStatus('')}
>
<label className="field">
<span>{t.detail.statusLabel}</span>
<select
value={status}
onChange={(e) => {
setStatus(e.target.value as RequestStatus | '')
setPage(1)
}}
onChange={(e) => setStatus(e.target.value as RequestStatus | '')}
>
<option value="">{t.filterAll}</option>
{REQUEST_STATUSES.map((s) => (
@@ -99,7 +98,7 @@ export default function AnfragenPage() {
</label>
</FilterPanel>
{requests.loading && <p className="muted">{de.common.loading}</p>}
{loading && <p className="muted">{de.common.loading}</p>}
{requests.error && (
<div className="alert alert--error">
<span>{requests.error}</span>
@@ -109,13 +108,13 @@ export default function AnfragenPage() {
</div>
)}
{requests.data && requests.data.items.length === 0 && (
{!loading && items.length === 0 && (
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
)}
{requests.data && requests.data.items.length > 0 && (
{items.length > 0 && (
<ul className="card-list">
{requests.data.items.map((r) => (
{items.map((r) => (
<li key={r.id}>
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
@@ -130,24 +129,12 @@ export default function AnfragenPage() {
</ul>
)}
{requests.data && requests.data.totalCount > PAGE_SIZE && (
<nav className="pager" aria-label="Seitennavigation">
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
{de.common.previous}
</button>
<span>
{de.common.page} {page} {de.common.of}{' '}
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
</span>
<button
type="button"
className="btn"
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
onClick={() => setPage(page + 1)}
>
{de.common.next}
</button>
</nav>
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{requests.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
{requests.loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</section>
)

View File

@@ -12,6 +12,7 @@ import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi'
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
export default function BeckenDetailPage() {
const t = de.pages.becken
@@ -109,6 +110,9 @@ export default function BeckenDetailPage() {
</dl>
)}
<h3>{t.photosTitle}</h3>
<EnclosurePhotosSection enclosureId={e.id} />
<h3>{t.occupancy.title}</h3>
{occupants.loading && <p className="muted">{de.common.loading}</p>}
{occupants.error && (

View File

@@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createEnclosure, getEnclosure, updateEnclosure, type CreateEnclosure } from '../api/enclosures'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from '../components/toast'
interface FormState {
name: string
@@ -18,6 +19,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
export default function BeckenFormPage() {
const t = de.pages.becken
const navigate = useNavigate()
const toast = useToast()
const { id } = useParams()
const isEdit = Boolean(id)
@@ -45,8 +47,13 @@ export default function BeckenFormPage() {
}
setErrors({})
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
if (result.ok) navigate(`/gehege/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
if (result.ok) {
toast.success(de.common.saved)
navigate(`/gehege/${result.value.id}`)
} else {
toast.error(result.error)
}
// On failure mutation.error also drives the inline alert; run() never throws.
}
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>

View File

@@ -1,35 +1,34 @@
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listEnclosuresPaged } from '../api/enclosures'
import { condition, type GridifyQuery } from '../api/gridify'
import { useApi } from '../hooks/useApi'
import { condition } from '../api/gridify'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
const PAGE_SIZE = 20
export default function BeckenPage() {
const t = de.pages.becken
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const filter = search.trim()
? condition({ field: 'name', op: 'contains', value: search.trim() })
: undefined
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
const enclosures = useApi(() => listEnclosuresPaged(query), [filter, page])
const total = enclosures.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = enclosures.data?.items ?? []
// Infinite scroll; trailing `id` keeps OFFSET pagination stable (no repeats/omits).
const list = useInfiniteList(
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`,
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, loading } = list
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
{enclosures.data && (
{!loading && (
<p className="muted">
{total} {t.countLabel}
</p>
@@ -46,27 +45,22 @@ export default function BeckenPage() {
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
onChange={(e) => setSearch(e.target.value)}
aria-label={t.fields.name}
/>
</div>
{enclosures.loading && <p className="muted">{de.common.loading}</p>}
{enclosures.error && (
{loading && <p className="muted">{de.common.loading}</p>}
{list.error && (
<div className="alert alert--error">
<span>{enclosures.error}</span>
<button type="button" className="btn" onClick={enclosures.reload}>
<span>{list.error}</span>
<button type="button" className="btn" onClick={list.reload}>
{de.common.retry}
</button>
</div>
)}
{!enclosures.loading && !enclosures.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
{items.length > 0 && (
<ul className="card-list">
@@ -81,28 +75,12 @@ export default function BeckenPage() {
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label="Seitennavigation">
<button
type="button"
className="btn"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{list.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
{list.loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</section>
)

View File

@@ -13,12 +13,14 @@ import {
type BreederProfile,
} from '../api/settings'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from '../components/toast'
// EXPORT-1 (Oscar): Datenexport-Karte
import DatenexportCard from '../components/DatenexportCard'
export default function EinstellungenPage() {
const t = de.pages.einstellungen
const tz = t.zuchtprofil
const toast = useToast()
const [form, setForm] = useState<BreederProfile>(EMPTY_BREEDER_PROFILE)
const [initialized, setInitialized] = useState(false)
@@ -40,7 +42,12 @@ export default function EinstellungenPage() {
async function onSubmit(e: FormEvent) {
e.preventDefault()
const result = await mutation.run(form)
if (result.ok) setSaved(true)
if (result.ok) {
setSaved(true)
toast.success(de.common.saved)
} else {
toast.error(result.error)
}
}
if (existing.loading) return <p className="muted">{de.common.loading}</p>
@@ -81,6 +88,7 @@ export default function EinstellungenPage() {
<form className="form" onSubmit={onSubmit} noValidate>
{field('zuchtName', tz.fields.zuchtName)}
{field('nameSuffix', tz.fields.nameSuffix, tz.fields.nameSuffixHint)}
{field('name', tz.fields.name, tz.fields.nameHint)}
{field('address', tz.fields.address, tz.fields.addressHint)}
{field('city', tz.fields.city)}

View File

@@ -0,0 +1,81 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { CATALOG, farbschlagImageUrl } from '../genetics'
import './farbkatalog.css'
export default function FarbkatalogPage() {
const [search, setSearch] = useState('')
// Filter color catalog by query
const filtered = CATALOG.filter((variety) => {
const q = search.toLowerCase()
return (
variety.name.toLowerCase().includes(q) ||
(variety.english && variety.english.toLowerCase().includes(q)) ||
variety.canonicalGenotype.toLowerCase().includes(q)
);
})
return (
<section className="page farbkatalog-page">
<header className="page-head">
<div>
<h2>{de.pages.farbkatalog.title}</h2>
<p className="muted">
{de.pages.farbkatalog.subtitle.replace('{count}', String(CATALOG.length))}
</p>
</div>
</header>
<div className="farbkatalog-search-bar">
<input
type="search"
className="input"
placeholder={de.pages.farbkatalog.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="farbkatalog-grid">
{filtered.map((variety) => {
const imgUrl = farbschlagImageUrl(variety.image)
return (
<div key={variety.name} className="farbkatalog-card">
<div className="farbkatalog-card-media">
{imgUrl ? (
<img
src={imgUrl}
alt={variety.name}
className="farbkatalog-card-img"
loading="lazy"
/>
) : (
<div className="farbkatalog-card-media-placeholder">
<span>{variety.name.substring(0, 2).toUpperCase()}</span>
</div>
)}
</div>
<div className="farbkatalog-card-body">
<h4 className="farbkatalog-card-title">{variety.name}</h4>
{variety.english && (
<span className="farbkatalog-card-subtitle">{variety.english}</span>
)}
<div className="farbkatalog-card-genotype">
<code>{variety.canonicalGenotype}</code>
</div>
</div>
</div>
)
})}
{filtered.length === 0 && (
<div className="farbkatalog-empty">
<span className="farbkatalog-empty-icon">🔍</span>
<p>{de.pages.farbkatalog.empty}</p>
</div>
)}
</div>
</section>
)
}

Some files were not shown because too many files have changed in this diff Show More