Compare commits

..

46 Commits

Author SHA1 Message Date
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
5732398e60 feat(rennmaeuse): Tiere-Liste neu im Rennmaus-Filter-Design
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m54s
CI / Docker Build & Push (push) Has been skipped
Filter-UX aus dem Design:
- Quick-Chip-Kategorien (Status/Geschlecht/Farbschlag/Herkunft) öffnen ein
  Bottom-Sheet mit Mehrfachauswahl (suchbar bei Farbschlag/Herkunft); aktive
  Filter als entfernbare Chips; Sortierung + "Externe Ahnen" als gleich große
  Chips (Sortierung öffnet dasselbe Drawer).
- Neue zweizeilige Tierkarte (Name + Geschlechts-Kreis + Status-Badge, Meta-Zeile),
  cardIn/Chip/Sheet-Animationen, Titel blendet beim Scrollen aus.

App-weit:
- Design-Palette (Creme/Braun/Terrakotta), Hanken-Grotesk-Schrift und gemeinsamer
  --shadow-Token; obere "Rennmaus-Manager"-Titelleiste entfernt (mobil bleibt die
  untere Aktionsleiste); fixe Shell-Höhe, nur der Hauptbereich scrollt.

Funktion:
- Infinite Scroll via useInfiniteList (seitenweise) + IntersectionObserver-Sentinel.
- Namensgenerator: Option männlich/weiblich/egal.
- Mehrfach-OR-Filter über gridify orGroup; e2e an die neue UI angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:05:44 +02:00
0185446bfe feat(migration): implement offline-resolved database-ready ingestion, fix circular FKs, duplicate contacts and parser scanning bugs 2026-06-08 21:57:01 +02:00
04d189bd2f FEAT: genotype parsing resilience and displayGenotypeSafe format helper 2026-06-08 21:19:11 +02:00
7d8166c555 Merge feature/gerbil-icon-redesign: GerbilIcon SVG neu gezeichnet
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Julian fand die alte Silhouette bescheiden. Neue Rennmaus-Silhouette
(sitzende Pose, layered ears, Pfoten, geschwungener Schwanz + Tuft).
Komponenten-API unveraendert (Props/viewBox/role/aria-label/style) ->
Consumer + e2e Selektoren intakt. QA: tsc+vite build green, vitest 122/122.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:35:18 +02:00
d5c7b77e7e FEAT: redesign GerbilIcon with a cute alert sitting silhouette and dynamic tufted tail 2026-06-08 00:33:40 +02:00
bff25b4683 FEAT-8d: add wipe-import-data.sql utility
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Utility script to clean up import data from the database after testing
/import/dry-run. Useful for re-running the import process during development
and QA. Deletes all animals and litters created by the import (identified
by ImportSource='docx-export' and ImportSource='derived-litter'), resets
sequences, and leaves the database ready for fresh import execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:11 +02:00
aa1d22cec7 FEAT-8d: add node_modules/ to .gitignore
Never commit node_modules — always installed locally via npm install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:10 +02:00
f7e97ad7a2 FEAT-8d: fix StatusModel migration table name SaleContractAnimals->SaleContractAnimal
During import execution, the migration 20260607012436_StatusModel failed because it
referenced a non-existent table "SaleContractAnimals" (plural). The actual table name
is "SaleContractAnimal" (singular). Fixed the reference in the SQL raw string to use
the correct table name so the migration can apply cleanly on fresh deployments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:10 +02:00
7d552d33cd D7-PREP: Batch-Merge feature/d7-conflicts -> main (all 7 D7 conflict animals resolved)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
Merged commits:
- 5ce35af (Skarlett): dateOfDeath 01.01.2017 (year-only, born 14.07.2013)
- 62b0759 (Max): dateOfDeath 01.01.2014 (year-only, born 01.02.2013) + genotype
- 4d7333b (Dakota + Banjo): Dakota genotype + Banjo E-locus correction
- Base file (Isa/Jack II/Sunny): Already correct, verified

All D7 conflict decisions now on main. Feature branch can be retained or deleted per god preference.
Status: Ready for FEAT-8d re-import (currently on HOLD pending Julian GO).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:32:00 +02:00
23b6dad303 chore: retire HUMANQUESTION.md -> HUMANQUESTION.md.migrated
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Human-feedback workflow moved into the hive task system (humanQA arrays on blocked cards in tasks.json). File is no longer the source of truth and must not be written to again. Open asks migrated to HQ-A3-GMAIL / HQ-A4-HOSTNAME / HQ-A5B2-REGISTRY / HQ-C7-RENNER-WISHES, plus D7-PREP (5 open conflict animals) and FEAT-8d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:17:37 +02:00
e9eef5cce6 Merge JUVENILE-AVATARS (feature/juvenile-avatars @299c038) — Kelly
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
CI / Backend Tests (.NET) (push) Successful in 1m3s
Foto-Avatare pro Jungtier im Wurf-Detail (36px Kreis-Avatar + GerbilIcon-Fallback),
reuse profilePhotoUrl + failedUrl-Fehler-Tracking. StammbaumPage auf gleiches
failedUrl-Muster umgestellt (set-state-in-effect Lint-Fix). FE-only, keine Migration.
god-QA: vitest 122/122, build+tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:24:49 +02:00
299c038449 feat(JUVENILE-AVATARS): Foto-Avatare pro Jungtier im Wurf-Detail
- JuvenileCard-Komponente in WurfDetailPage mit 36px-Kreis-Avatar links
- Fallback auf GerbilIcon (SVG-Silhouette) wenn kein profilePhotoUrl
- URL-basiertes Fehler-Tracking (failedUrl) statt useEffect+imgError
- wuerfe.css: Flex-Layout fuer Card+Avatar, overflow:hidden/object-fit:cover
- StammbaumPage: gleiche Lint-Verletzung (react-hooks/set-state-in-effect)
  behoben (failedUrl-Pattern, kein useEffect mehr fuer Bild-Reset)
- E2E: profilePhotoUrl auf Pippa (pup-abgabe), neuer Avatar-Test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:20:19 +02:00
ecd7ad2658 Merge feature/status-model-fe (STATUS-MODEL-FE): Dropdown Zucht/Liebhaber/Abzugeben; Verstorben/Abgegeben read-only abgeleitet; Labels+Filter
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Failing after 9s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
2026-06-07 03:37:48 +02:00
6de56ab112 Merge feature/litter-mortality-fe (LITTER-MORTALITY-FE): deathsWithin8Weeks Feld im Wurf-Formular+Detail mit >totalBorn-Warnung 2026-06-07 03:37:48 +02:00
ddc343a96b Merge feature/d7-conflicts (D7-PREP): 5 Tiere (Milon/Max/Isa/Jack II/Sunny) in conflict-decisions.json + Jahr-only-Tod deathYear-Vorschlag; dry-run Konflikte 7->2 (KEIN execute)
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:37:20 +02:00
a26bc4d75e Merge feature/litter-mortality (LITTER-MORTALITY-BE): Litter.DeathsWithin8Weeks int? + additive Migration + DTO + Validierung <=TotalBorn 2026-06-07 03:36:57 +02:00
d4dfe6c3ac Merge feature/status-model (STATUS-MODEL-BE P0): enum Active->Breeding(default)+Pet/ForSale; Deceased/GivenAway serverseitig abgeleitet (GerbilStatusService.Derive + Praezedenz + >7J presumed); Migration+Backfill+Startup-Sweep 2026-06-07 03:36:32 +02:00
40f3ce250d STATUS-MODEL-FE: Zucht/Liebhaber/Abzugeben wählbar; Verstorben+Abgegeben abgeleitet (gated auf Pam BE) 2026-06-07 03:36:10 +02:00
ffbbc0dfad Merge feature/stammbaum-avatar (STAMMBAUM-AVATAR): Stammbaum-Knoten konsumieren profilePhotoUrl (Foto-Avatar mit imgError-Fallback auf GerbilIcon)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:33:54 +02:00
05cb1af39e Merge feature/gerbil-profilephoto (PROFILEPHOTO-BE): berechnetes profilePhotoUrl aufs Gerbil-DTO (Shared-Dep fuer Avatare, keine Migration, kein N+1)
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
2026-06-07 03:33:01 +02:00
78f87f0d44 LITTER-MORTALITY: DeathsWithin8Weeks field (Frühsterblichkeit)
Litter.DeathsWithin8Weeks (int?, nullable). LitterDto + LitterInput extended.
Validation: negative → 400, > TotalBorn → 400 (skipped when TotalBorn null).
Migration AddLitterMortality (additive, nullable). 5 new tests. 194/194 green.
Contract: camelCase field deathsWithin8Weeks in LitterDto/LitterInput.
2026-06-07 03:31:51 +02:00
29e9c4cd64 LITTER-MORTALITY-FE: deathsWithin8Weeks Feld im Wurf-Formular und -Detail (gated auf Pam BE) 2026-06-07 03:28:11 +02:00
8eddb83c99 STAMMBAUM-AVATAR: Tier-Foto als Avatar in Pedigree-Karte, GerbilIcon-Fallback (profilePhotoUrl-Contract) 2026-06-07 03:27:24 +02:00
1a5f3218fb STATUS-MODEL: Active→Breeding + Pet enum + derived status logic (P0)
GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED.
GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1),
ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4).
All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply().
Startup sweep flips >7y gerbils to Deceased at next app restart.
Migration StatusModel: Active→Breeding rename + backfill derived statuses.
10 new tests; 200/200 green; has-pending=No.

Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale.
FE contract: status field values updated (see Done-Report).
2026-06-07 03:27:11 +02:00
2d54cb901f Merge feature/litter-juvenile-details (LITTER-JUVENILE-DETAILS): pro Jungtier Farbschlag/Abgabedatum/Abnehmer/Todestag/Ursache im Wurf-Detail
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m16s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:25:53 +02:00
03f8e4de90 LITTER-JUVENILE-DETAILS: Farbschlag/Abgabe/Abnehmer/Todesdaten pro Jungtier im Wurf-Detail 2026-06-07 03:23:22 +02:00
70803efa1f PROFILEPHOTO: profilePhotoUrl computed field on GerbilDto
GET /gerbils/{id}: loads first photo by sortOrder (2 queries, no N+1).
GET /gerbils list: batch-loads first photos for the page in one IN query.
profilePhotoUrl = /photos/files/{fileName} or null. No migration.
3 new tests: no-photo=null, sortOrder priority, list batch.
2026-06-07 03:19:00 +02:00
25af10a736 Merge feature/farbschlag-genotype-sync (FARBSCHLAG-GENOTYPE-SYNC): Farbschlag↔Gencode bidirektionale Sync im Tier-Formular
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:16:55 +02:00
ade40eef5e FARBSCHLAG-GENOTYPE-SYNC: Farbschlag↔Gencode bidirektionale Sync im Tier-Formular 2026-06-07 03:16:02 +02:00
144 changed files with 22432 additions and 877 deletions

3
.gitignore vendored
View File

@@ -137,3 +137,6 @@ GerbilManagerWebAPI/photo-storage/
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
GerbilManagerWebAPI/.data-protection-keys/
# node_modules — never commit, installed locally per npm install
node_modules/

View File

@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
// a non-ForSale gerbil must NOT appear
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()

View File

@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
Id = Guid.NewGuid(),
Name = "Krümel",
Gender = Gender.female,
Status = GerbilStatus.Active,
Status = GerbilStatus.Breeding,
DateOfBirth = new DateOnly(2025, 3, 12),
LitterId = litter.Id,
EnclosureId = enclosure.Id,
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
var row = lines[1];
Assert.Contains("Krümel", row);
Assert.Contains("Weiblich", row);
Assert.Contains("Aktiv", row);
Assert.Contains("Zucht", row);
Assert.Contains("12.03.2025", row);
Assert.Contains("Großbecken", row);
Assert.Contains("Wurf K", row);

View File

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using Microsoft.Extensions.DependencyInjection;
namespace GerbilManager.Tests;
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
private readonly ApiFactory _factory;
public GerbilProfilePhotoTests(ApiFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
private static Guid ExtractId(string json) =>
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
private static string? GetStr(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
private async Task<Guid> CreateGerbil(string name)
{
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync());
}
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
foreach (var (fn, so) in photos)
db.GerbilPhotos.Add(new GerbilPhoto
{
Id = Guid.NewGuid(), GerbilId = gerbilId,
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
});
db.SaveChanges();
}
[Fact]
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
{
var id = await CreateGerbil("NoPhoto");
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
{
var id = await CreateGerbil("PhotoGerbil");
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
}
[Fact]
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
{
var id = await CreateGerbil("ListPhoto");
InsertPhotos(id, ("list-photo.jpg", 0));
var json = await _client.GetStringAsync("/gerbils");
var root = JsonDocument.Parse(json).RootElement;
var items = root.GetProperty("items").EnumerateArray().ToList();
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
Assert.NotEqual(default, gerbil);
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
}
}

View File

@@ -0,0 +1,122 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
namespace GerbilManager.Tests;
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
public class GerbilStatusTests
{
private static readonly DateOnly Today = new(2026, 6, 7);
// 1) DateOfDeath → Deceased, always overrides user input
[Fact]
public void DateOfDeath_set_returns_Deceased()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// DateOfDeath wins even when also Abgegeben
[Fact]
public void DateOfDeath_wins_over_Abgegeben()
{
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// 2) Abgegeben without death date → GivenAway
[Fact]
public void ReceiverContact_set_returns_GivenAway()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
[Fact]
public void OlderThan6y_without_death_returns_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// 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 6y today
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
// (threshold = "older than" so >= means Deceased)
Assert.Equal(GerbilStatus.Deceased, status);
}
// One day before threshold is alive
[Fact]
public void OneDayBeforeMaxAge_is_alive()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
[Fact]
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
{
var dob = Today.AddYears(-7);
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 4) Default → Breeding
[Fact]
public void No_special_condition_returns_Breeding()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 4a) User sets Pet → kept
[Fact]
public void User_Pet_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Pet, status);
}
// 4b) User sets ForSale → kept
[Fact]
public void User_ForSale_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.ForSale, status);
}
// DateOfBirth null → age rule does not fire
[Fact]
public void Null_DateOfBirth_does_not_trigger_age_rule()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, 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

@@ -0,0 +1,181 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
namespace GerbilManager.Tests
{
public class IngestResolvedServiceTests : IDisposable
{
private readonly string _dir;
private readonly string _resolvedJsonPath;
public IngestResolvedServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var photoId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new
{
Id = contactId,
Name = "Test Breeder",
ZuchtName = "Test Zucht",
City = "Test City",
Email = "test@example.com",
Homepage = "",
Phone = "",
Address = ""
}
},
Litters = new[]
{
new
{
Id = litterId,
Name = "Wurf A",
Date = "2026-01-01",
TotalBorn = 5,
DeathsWithin8Weeks = 0,
FatherId = fatherId,
MotherId = motherId,
ExpectedGoHomeDate = (string?)null,
Notes = "Test litter notes",
PairingCode = "PC01",
ExternalRef = "ext-litter-1",
LitterLetter = "A"
}
},
Gerbils = new[]
{
new
{
Id = fatherId,
Name = "Papa",
Gender = "male",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
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-papa",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "papa",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
},
new
{
Id = motherId,
Name = "Mama",
Gender = "female",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
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-mama",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "mama",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
}
},
GerbilPhotos = new[]
{
new
{
Id = photoId,
GerbilId = fatherId,
FileName = "photo1.jpg",
SortOrder = 0
}
}
};
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
return db;
}
[Fact]
public async Task IngestResolved_loads_data_and_resolves_all_relations()
{
using var db = NewDb();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Import:SourcePath", _dir }
})
.Build();
var service = new IngestResolvedService(db, config, null!);
var result = await service.RunAsync();
Assert.Contains("Ingestion successful!", result);
// Verify counts
Assert.Equal(1, await db.Contacts.CountAsync());
Assert.Equal(1, await db.Litters.CountAsync());
Assert.Equal(2, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
// Verify relations
var litter = await db.Litters.SingleAsync();
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
var photo = await db.GerbilPhotos.SingleAsync();
Assert.Equal(father.Id, litter.FatherId);
Assert.Equal(mother.Id, litter.MotherId);
Assert.Equal(father.Id, photo.GerbilId);
Assert.Equal(father.OriginContactId, mother.OriginContactId);
}
}
}

View File

@@ -0,0 +1,88 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
public class LitterMortalityTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
private static int? GetInt(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32() : null;
private async Task<JsonElement> CreateAndGet(object body)
{
var post = await _client.PostAsJsonAsync("/litters", body);
post.EnsureSuccessStatusCode();
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
.GetProperty("id").GetString()!;
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
}
[Fact]
public async Task DeathsWithin8Weeks_round_trips()
{
var el = await CreateAndGet(new
{
name = "Wurf Mortalitätstest",
date = "2026-05-01",
totalBorn = 6,
deathsWithin8Weeks = 2,
});
Assert.Equal(6, GetInt(el, "totalBorn"));
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
}
[Fact]
public async Task DeathsWithin8Weeks_defaults_null()
{
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Ungültig",
date = "2026-05-03",
totalBorn = 4,
deathsWithin8Weeks = 5,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("TotalBorn", body);
}
[Fact]
public async Task Negative_DeathsWithin8Weeks_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Negativ",
date = "2026-05-04",
deathsWithin8Weeks = -1,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("negativ", body);
}
[Fact]
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
{
var el = await CreateAndGet(new
{
name = "Kein TotalBorn",
date = "2026-05-05",
deathsWithin8Weeks = 3,
});
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
}
}

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") ?? "");
@@ -91,11 +91,11 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
// Assert: name / genotype / originBreeder / status / externalRef all intact
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("Active", GetStr(json, "status"));
Assert.Equal("Breeding", GetStr(json, "status"));
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"));
@@ -138,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Minimal", GetStr(json, "name"));
Assert.Equal("Active", GetStr(json, "status"));
Assert.Equal("Breeding", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
}

View File

@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
private readonly HttpClient _client;
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
private async Task Create(string name, string? originBreeder = null, string status = "Active")
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
{
var resp = await _client.PostAsJsonAsync("/gerbils",
new { name, gender = "female", status, originBreeder });
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
public async Task Combined_status_and_name_filter()
{
await Create("KombiVerkauf", status: "ForSale");
await Create("KombiAktiv", status: "Active");
await Create("KombiAktiv", status: "Breeding");
var hits = await Names("status==ForSale,nameSearch=*kombi");
Assert.Contains("KombiVerkauf", hits);
Assert.DoesNotContain("KombiAktiv", hits);

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>();
@@ -133,6 +134,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 =>
{

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,
@@ -29,19 +30,22 @@ namespace GerbilManagerWebAPI.Dtos
List<string> CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool IsResident);
bool IsResident,
string? ProfilePhotoUrl,
bool IsCastrated);
public record LitterDto(
Guid Id,
string Name,
DateOnly Date,
DateOnly? Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
string? Notes);
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);
public record EnclosureDto(Guid Id, string Name, string? Notes);
@@ -72,6 +76,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? SpottingType,
string? Notes,
string? ImportSource,
string? ExternalRef,
@@ -79,18 +84,20 @@ 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,
Guid? MotherId,
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

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

View File

@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
@@ -22,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)
{
@@ -49,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)
@@ -99,16 +102,21 @@ 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);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in gerbils)
{
g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate;
g.Status = GerbilStatus.GivenAway;
GerbilStatusService.Apply(g, today);
}
try
@@ -142,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>> (
@@ -164,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

@@ -1,7 +1,9 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Gridify;
using Gridify.EntityFramework;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
{
var q = query.ToQuery();
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
var ids = paging.Data.Select(g => g.Id).ToList();
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
await db.GerbilPhotos.AsNoTracking()
.Where(p => ids.Contains(p.GerbilId))
.GroupBy(p => p.GerbilId)
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
var items = paging.Data
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
.ToList();
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
});
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) =>
@@ -30,7 +45,14 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
if (g is null) return TypedResults.NotFound();
var photoFileName = await db.GerbilPhotos.AsNoTracking()
.Where(p => p.GerbilId == id)
.OrderBy(p => p.SortOrder)
.Select(p => p.FileName)
.FirstOrDefaultAsync();
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
});
// POST /gerbils
@@ -86,7 +108,8 @@ namespace GerbilManagerWebAPI.Endpoints
{
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
// Status is applied as a user preference and then overridden by GerbilStatusService.
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
g.LitterId = i.LitterId ?? g.LitterId;
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
@@ -97,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;
@@ -105,12 +129,14 @@ 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) => new(
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);
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);
}
}

View File

@@ -29,6 +29,13 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(report);
});
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var result = await new IngestResolvedService(db, config, env).RunAsync();
return TypedResults.Ok(result);
});
return app;
}
}

View File

@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
});
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
{
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
Apply(l, input);
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
{
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound();
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
l.Name = input.Name;
l.Date = input.Date;
Apply(l, input);
@@ -76,14 +80,25 @@ namespace GerbilManagerWebAPI.Endpoints
private static void Apply(Litter l, LitterInput i)
{
l.TotalBorn = i.TotalBorn;
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
l.FatherId = i.FatherId;
l.MotherId = i.MotherId;
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
l.Notes = i.Notes;
}
private static string? ValidateMortality(int? deaths, int? totalBorn)
{
if (deaths is null) return null;
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
if (totalBorn is not null && deaths > totalBorn)
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
return null;
}
private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
}
/// <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

@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Breeding] = "Zucht",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
[GerbilStatus.ForSale] = "Abzugeben",
[GerbilStatus.Pet] = "Liebhaber",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()

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

@@ -1,5 +1,6 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -54,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);
}
@@ -69,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)
@@ -266,14 +279,14 @@ namespace GerbilManagerWebAPI.Import
Name = da.Name.Trim(),
DateOfBirth = animalDob,
Gender = ParseGender(da.Gender),
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
LitterId = litterId,
ReceiverContactId = receiverId,
GoHomeDate = goHomeDate,
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,
@@ -312,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

@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -82,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();
@@ -306,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.
@@ -328,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})",
@@ -390,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
if (execute)
{
_db.Gerbils.Add(new Gerbil
var importedGerbil = new Gerbil
{
Id = p.Gid,
Name = p.A.Name,
Gender = p.Gender,
Status = GerbilStatus.Active,
Status = GerbilStatus.Breeding,
DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId,
@@ -419,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag,
}),
});
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
_db.Gerbils.Add(importedGerbil);
}
// photos
@@ -568,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
@@ -612,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

@@ -0,0 +1,260 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using GerbilManagerWebAPI.Services;
namespace GerbilManagerWebAPI.Import
{
public sealed class IngestResolvedService
{
private readonly ApplicationContext _db;
private readonly string _resolvedJsonPath;
private readonly string _sourceDir;
private readonly string _photoRoot;
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
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()
{
if (!File.Exists(_resolvedJsonPath))
{
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
}
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
var jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
if (data == null)
{
return "Error: Failed to deserialize resolved_import.json.";
}
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
// Break circular references first
foreach (var l in _db.Litters)
{
l.FatherId = null;
l.MotherId = null;
}
foreach (var g in _db.Gerbils)
{
g.LitterId = null;
}
await _db.SaveChangesAsync();
_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
{
// Break circular references first using ExecuteUpdateAsync
await _db.Litters.ExecuteUpdateAsync(s => s
.SetProperty(l => l.FatherId, (Guid?)null)
.SetProperty(l => l.MotherId, (Guid?)null));
await _db.Gerbils.ExecuteUpdateAsync(s => s
.SetProperty(g => g.LitterId, (Guid?)null));
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 (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 (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;
contactsUpdated++;
}
else if (addedContactIds.Add(c.Id))
{
_db.Contacts.Add(c);
contactsAdded++;
}
}
await _db.SaveChangesAsync();
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
var littersToInsert = new List<Litter>();
foreach (var l in data.Litters)
{
littersToInsert.Add(new Litter
{
Id = l.Id,
Name = l.Name,
Date = l.Date,
TotalBorn = l.TotalBorn,
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
FatherId = null, // Set in Pass 2
MotherId = null, // Set in Pass 2
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
Notes = l.Notes,
PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter
});
}
_db.Litters.AddRange(littersToInsert);
await _db.SaveChangesAsync();
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
var gerbilsToInsert = new List<Gerbil>();
foreach (var g in data.Gerbils)
{
var importedGerbil = new Gerbil
{
Id = g.Id,
Name = g.Name,
Gender = g.Gender,
Status = g.Status,
LitterId = null, // Set in Pass 2
OriginContactId = g.OriginContactId,
ReceiverContactId = g.ReceiverContactId,
EnclosureId = g.EnclosureId,
ColorVarietyId = g.ColorVarietyId,
DateOfBirth = g.DateOfBirth,
DateOfDeath = g.DateOfDeath,
CauseOfDeath = g.CauseOfDeath,
GoHomeDate = g.GoHomeDate,
Genotype = g.Genotype,
Notes = g.Notes,
ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData,
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();
// 5. Pass 2: Set the actual foreign key relationships
// Update Litters with their parent animal IDs
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
foreach (var l in data.Litters)
{
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
{
dbLitter.FatherId = l.FatherId;
dbLitter.MotherId = l.MotherId;
}
}
// Update Gerbils with their actual LitterId
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
foreach (var g in data.Gerbils)
{
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
{
dbGerbil.LitterId = g.LitterId;
}
}
await _db.SaveChangesAsync();
// 6. Insert all Photos & copy physical files
Directory.CreateDirectory(_photoRoot);
foreach (var p in data.GerbilPhotos)
{
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 ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
}
}
public class ResolvedImportData
{
public List<Contact> Contacts { get; set; } = new();
public List<Litter> Litters { get; set; } = new();
public List<Gerbil> Gerbils { 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,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class StatusModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Rename string value 'Active' → 'Breeding'.
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
// 2) DateOfDeath set → Deceased (always takes precedence).
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
WHERE ""Status"" != 'Deceased'
AND (""ReceiverContactId"" IS NOT NULL
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
AND ""DateOfBirth"" IS NOT NULL
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
}
}
}

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 AddLitterMortality : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DeathsWithin8Weeks",
table: "Litters",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeathsWithin8Weeks",
table: "Litters");
}
}
}

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

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,11 +721,20 @@ 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");
@@ -750,6 +764,35 @@ 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.Gerbil", b =>
{
b.Property<Guid>("Id")
@@ -794,6 +837,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("ImportSource")
.HasColumnType("text");
b.Property<bool>("IsCastrated")
.HasColumnType("boolean");
b.Property<bool?>("IsDeaf")
.HasColumnType("boolean");
@@ -830,6 +876,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
b.Property<string>("SpottingType")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
@@ -921,9 +970,12 @@ namespace GerbilManagerWebAPI.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
b.Property<DateOnly?>("Date")
.HasColumnType("date");
b.Property<int?>("DeathsWithin8Weeks")
.HasColumnType("integer");
b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date");
@@ -1219,6 +1271,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("PhotoId")
.HasColumnType("uuid");
b.HasKey("SaleContractId", "GerbilId");
b.HasIndex("GerbilId");
@@ -1287,6 +1342,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,14 @@ 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; }
}
}

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

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
public Guid Id { get; set; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
// Birth litter (the litter this gerbil was born in).
public Guid? LitterId { get; set; }
@@ -41,6 +41,8 @@ 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).
@@ -80,6 +82,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

@@ -1,12 +1,17 @@
namespace GerbilManagerWebAPI.Models
{
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
public enum GerbilStatus
{
Active = 0,
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
Breeding = 0,
Deceased = 1,
GivenAway = 2,
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
ForSale = 3
ForSale = 3,
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
Pet = 4,
}
}

View File

@@ -8,11 +8,15 @@ 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; }
/// <summary>Frühsterblichkeit: Anzahl der in den ersten 8 Wochen verstorbenen Tiere.
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
public int? DeathsWithin8Weeks { get; set; }
public Guid? FatherId { get; set; }
public Gerbil? Father { get; set; }
public Guid? MotherId { 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

@@ -1,5 +1,7 @@
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
@@ -84,7 +86,23 @@ app.MapScalarApiReference();
if (!app.Environment.IsEnvironment("Testing"))
{
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
db.Database.Migrate();
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
// since the last write. No-op if all statuses are already current.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var candidates = await db.Gerbils
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
&& g.DateOfDeath == null && g.ReceiverContactId == null
&& g.DateOfBirth != null
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
.ToListAsync();
if (candidates.Count > 0)
{
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
await db.SaveChangesAsync();
}
}
app.UseCors(LanCorsPolicy);

View File

@@ -0,0 +1,53 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Services
{
/// <summary>
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
/// derived statuses (Deceased, GivenAway) are always consistent.
///
/// Precedence (highest wins):
/// 1) DateOfDeath set → Deceased (explicit, always)
/// 2) Abgabe (ReceiverContactId set) → GivenAway
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
///
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
/// a) Apply() on every write (catches the animal at write time)
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
/// </summary>
public static class GerbilStatusService
{
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)
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
public static void Apply(Gerbil g, DateOnly today)
{
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
isAbgegeben: g.ReceiverContactId is not null, today);
}
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
public static GerbilStatus Derive(
GerbilStatus requested,
DateOnly? dateOfBirth,
DateOnly? dateOfDeath,
bool isAbgegeben,
DateOnly today)
{
if (dateOfDeath is not null) return GerbilStatus.Deceased;
if (isAbgegeben) return GerbilStatus.GivenAway;
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
return GerbilStatus.Deceased;
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
? requested : GerbilStatus.Breeding;
}
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
today >= dob.AddYears(years);
}
}

View File

@@ -64,7 +64,7 @@ _Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|---|---|---|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |

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

@@ -1,5 +1,5 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
@@ -19,9 +19,8 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
// DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox.
await page.getByRole('button', { name: t.filters.showExternalShort }).click()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
await expect(maxRow).toBeVisible()

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

@@ -0,0 +1,70 @@
/** FARBSCHLAG-GENOTYPE-SYNC: Bidirektionale Sync im Tier-Formular. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Vor der Auswahl ist das Gencode-Feld leer.
await expect(genotypeInput).toHaveValue('')
// 'Agouti' wählen → Gencode wird automatisch befüllt.
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')
})
test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
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(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 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 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(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 genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
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(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
})

View File

@@ -1,85 +1,54 @@
/**
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
* DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste.
*
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
* Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein
* Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare
* Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test.describe('FilterPanel Rennmäuse-Liste', () => {
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
page,
}, testInfo) => {
test.describe('Quick-Chips-Filter Rennmäuse-Liste', () => {
test('Kategorie-Pillen sind sichtbar', async ({ page }) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Toggle-Button sichtbar.
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeVisible()
// Status-Beschriftung im Drawer ist noch verborgen.
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
const quickrow = page.locator('.rmf-quickrow')
await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible()
})
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({
page,
}, testInfo) => {
}) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Öffnen → Status-Feld wird sichtbar.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
// Default: Zucht-Chip ist aktiv.
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
// Schließen → wieder verborgen.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
// Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint.
await selectListFilter(page, t.filters.gender, t.genderLabels.male)
await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible()
// „Alle löschen" entfernt sämtliche Chips.
await page.getByRole('button', { name: de.filterBar.clearAll }).click()
await expect(page.locator('.rmf-chip')).toHaveCount(0)
})
test('Phone: Badge zählt aktive Filter korrekt', async ({
page,
}, testInfo) => {
test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
await expect(toggle).toHaveText('Filter')
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
await toggle.click()
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
await expect(toggle).toHaveText('Filter (1)')
// Zurücksetzen → Badge weg.
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
await expect(toggle).toHaveText('Filter')
})
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kein Toggle-Button auf Desktop (display:none via Media Query).
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeHidden()
// Status-Beschriftung direkt sichtbar.
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
const breedingChip = page.locator('.rmf-chip', {
has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }),
})
await expect(breedingChip).toBeVisible()
await breedingChip.click()
await expect(breedingChip).toHaveCount(0)
})
})

View File

@@ -86,6 +86,18 @@ export async function openFilterPanel(page: Page) {
}
}
/**
* DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste.
* Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig".
*/
export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) {
// Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen).
await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click()
const sheet = page.getByRole('dialog', { name: categoryLabel })
await sheet.getByRole('button', { name: optionLabel, exact: true }).click()
await sheet.getByRole('button', { name: de.filterBar.done }).click()
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -0,0 +1,45 @@
/** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.litters
test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Todesursache', async ({
page,
}) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Krümel: Active, colorVarietyId cv-agouti → Farbschlag 'Agouti'
const kruemelCard = page.getByRole('link', { name: /Krümel/ })
await expect(kruemelCard).toBeVisible()
await expect(kruemelCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Agouti`)
// Pippa: GivenAway, Gold, goHomeDate 2025-05-01, Abnehmer Familie Huber
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard).toBeVisible()
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Gold`)
await expect(pippaCard).toContainText(t.detail.juvenileFields.goHomeDate)
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.receiver}: Familie Huber`)
// Benny: Deceased, no colorVarietyId, dateOfDeath + causeOfDeath
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard).toBeVisible()
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
})
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
await expect(bennyCard.locator('svg')).toBeVisible()
})

View File

@@ -0,0 +1,46 @@
/** LITTER-MORTALITY: deathsWithin8Weeks im Wurf-Formular und -Detail. */
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.litters
test('Wurf-Detail zeigt Frühverluste wenn gesetzt', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Frühverluste-Zeile im def-list: Label + Wert '1'
const mortalityRow = page.locator('.def-row', {
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
})
await expect(mortalityRow).toBeVisible()
await expect(mortalityRow.locator('dd')).toHaveText('1')
})
test('Wurf-Formular speichert Frühverluste und zeigt sie im Detail', async ({ page }) => {
skipUnlessMock()
const name = uniqueName('WurfM')
await page.goto('/wuerfe/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-10')
// Frühverluste eingeben
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('2')
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).not.toBeVisible()
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
// Frühverluste-Zeile im Detail
const mortalityRow = page.locator('.def-row', {
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
})
await expect(mortalityRow).toBeVisible()
await expect(mortalityRow.locator('dd')).toHaveText('2')
})
test('Frühverluste > Wurfstärke zeigt Warnmeldung', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/neu')
await page.getByLabel(t.fields.totalBorn).fill('3')
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('5')
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).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'

View File

@@ -91,7 +91,7 @@ function gerbil(
id,
name,
gender,
status: 'Active',
status: 'Breeding',
dateOfBirth,
dateOfDeath: null,
causeOfDeath: null,
@@ -108,6 +108,7 @@ function gerbil(
notes: null,
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
isResident: true,
profilePhotoUrl: null,
}
}
@@ -120,7 +121,7 @@ export function seedDb(): MockDb {
originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten',
},
{ ...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' },
{ ...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'),
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
@@ -135,8 +136,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).
{
@@ -151,12 +154,27 @@ export function seedDb(): MockDb {
enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker',
},
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
{
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
status: 'GivenAway' as const,
goHomeDate: '2025-05-01',
receiverContactId: 'con-huber',
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
},
{
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
status: 'Deceased' as const,
dateOfDeath: '2025-04-15',
causeOfDeath: 'Altersschwäche',
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
]
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' },
{ 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-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' },
@@ -173,9 +191,10 @@ 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 },
{ 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 = [

View File

@@ -3,7 +3,7 @@ import { de, expect, gotoSection, test } from './fixtures'
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
// DESIGN: the top "Rennmaus-Manager" title bar was removed — the page heading remains.
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await gotoSection(page, de.nav.gerbils)

View File

@@ -154,3 +154,20 @@ test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ p
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
})
test('Tier mit Foto zeigt Avatar-Bild, Tier ohne Foto zeigt Rennmaus-Icon (STAMMBAUM-AVATAR)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat profilePhotoUrl (data URI) → <img.pedigree-card__avatar> sichtbar
// Krümel hat profilePhotoUrl=null → kein img, SVG-Icon sichtbar
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Fridolin ist Vater von Krümel → seine Karte enthält ein <img>
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('img.pedigree-card__avatar')).toBeVisible()
// Krümel (root) hat kein Foto → SVG-Icon im Foto-Slot
const rootCard = page.locator('.pedigree-card.pedigree-card--root')
await expect(rootCard.locator('img.pedigree-card__avatar')).not.toBeAttached()
await expect(rootCard.locator('svg[role="img"]')).toBeVisible()
})

View File

@@ -0,0 +1,85 @@
/** STATUS-MODEL: Zucht/Liebhaber wählbar; Verstorben/Abgegeben abgeleitet (read-only). */
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
test('Tier-Formular zeigt nur wählbare Statuswerte im Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
// Zucht (Breeding) ist vorausgewählt
await expect(statusField.locator('select')).toHaveValue('Breeding')
// Optionen: nur Zucht, Liebhaber, Abzugeben (NICHT Verstorben/Abgegeben)
const opts = statusField.locator('select option')
await expect(opts.filter({ hasText: t.statusLabels.Breeding })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Pet })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.ForSale })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Deceased })).toHaveCount(0)
await expect(opts.filter({ hasText: t.statusLabels.GivenAway })).toHaveCount(0)
})
test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', async ({ page }) => {
skipUnlessMock()
// Willi ist Deceased
await page.goto('/rennmaeuse/willi/bearbeiten')
// Status-Select ist NICHT vorhanden (abgeleitet)
const statusField = page.locator('.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
await expect(statusField).toBeVisible()
await expect(statusField.locator('select')).toHaveCount(0)
await expect(statusField).toContainText(t.statusLabels.Deceased)
await expect(statusField).toContainText(t.form.statusDerivedHint.Deceased)
})
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse')
// DESIGN (Rennmaus Filter): der Default-Status erscheint als aktiver, entfernbarer Chip.
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
})
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
skipUnlessMock()
const name = uniqueName('Tier')
await page.goto('/rennmaeuse/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
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}")`),
})
await statusField.locator('select').selectOption({ label: t.statusLabels.Pet })
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

@@ -1,5 +1,5 @@
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
const tabs = de.pages.tierTabs
@@ -23,13 +23,9 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
.locator('select')
.selectOption({ label: 'Clan-Kleine-Chaoten' })
// DESIGN (Rennmaus Filter): Herkunft (originBreeder) über das Quick-Chips-Sheet
// auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel) filtern.
await selectListFilter(page, t.fields.origin, 'Clan-Kleine-Chaoten')
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
@@ -56,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
@@ -178,12 +174,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()
@@ -193,6 +190,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

@@ -5,6 +5,13 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
<!-- DESIGN (Rennmaus Filter): Hanken Grotesk als App-Schrift. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>Rennmaus-Manager</title>
</head>
<body>

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

@@ -61,6 +61,23 @@ export function andFilter(...conditions: Array<string | null | undefined | false
return conditions.filter((c): c is string => Boolean(c)).join(',')
}
/**
* Build an OR-group of conditions for one field, e.g. `(status=Breeding|status=ForSale)`.
* Used for multi-select filters: several allowed values within one category are OR'd,
* while categories are AND'd together by {@link andFilter}.
* Returns '' for an empty value list (so andFilter drops it); a single value needs no parens.
*/
export function orGroup(
field: string,
values: ReadonlyArray<string | number | boolean>,
op: GridifyOp = '==',
): string {
const parts = values.map((value) => condition({ field, op, value }))
if (parts.length === 0) return ''
if (parts.length === 1) return parts[0]
return `(${parts.join('|')})`
}
/** Assemble the URL query string ("?...") from a GridifyQuery. */
export function toQueryString(q: GridifyQuery): string {
const params = new URLSearchParams()

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

@@ -10,9 +10,11 @@
export type Gender = 'unknown' | 'male' | 'female'
export const GENDERS: Gender[] = ['unknown', 'male', 'female']
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale'
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway']
/** Gerbil.Status — C# enum { Breeding, Pet, Deceased, GivenAway, ForSale }. STATUS-MODEL: Active→Breeding (gated on Pam feature/status-model). */
export type GerbilStatus = 'Breeding' | 'Pet' | 'Deceased' | 'GivenAway' | 'ForSale'
export const GERBIL_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale', 'Deceased', 'GivenAway']
/** Statuses the user can pick freely in the form; Deceased+GivenAway are derived by the backend. */
export const SELECTABLE_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale']
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
export type DateOnlyString = string
@@ -56,6 +58,10 @@ export interface Gerbil {
isResident?: boolean
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
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
}
/** Payload for POST /gerbils. */
@@ -79,6 +85,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). */
@@ -104,6 +112,10 @@ 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
}
export interface Litter {
@@ -122,6 +134,8 @@ export interface Litter {
notes?: string | null
fatherId: string | null
motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null
}
/** Payload for POST /litters. */
@@ -133,6 +147,7 @@ export interface CreateLitter {
notes?: string | null
fatherId?: string | null
motherId?: string | null
deathsWithin8Weeks?: number | null
}
export type UpdateLitter = Partial<CreateLitter>

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: '❓' },
]
@@ -47,11 +49,6 @@ export default function AppShell() {
return (
<div className="app-shell">
<header className="app-header">
<span className="app-logo" aria-hidden="true"><GerbilIcon size="1.5rem" /></span>
<h1 className="app-title">{de.app.title}</h1>
</header>
<nav className="app-nav" aria-label={de.nav.mainNavigation}>
<div className="nav-primary">
{PRIMARY.map((item) => (
@@ -87,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

@@ -0,0 +1,271 @@
/**
* DESIGN (Rennmaus Filter.html): mobile-first filter UX —
* 1. a horizontal "Quick-Chips" pill row (one pill per category, with a count badge),
* 2. tapping a pill opens a bottom-sheet of multi-select options (✓ checkboxes,
* searchable for long lists like Farbschlag / Herkunft),
* 3. active selections appear ABOVE the list as removable chips (so they scroll
* away with the list), with "Alle löschen".
*
* The user landed on the Quick-Chips concept, dezent style, brown accent. Colors
* come from the app's existing tokens. `GerbilFilterBar` is the pill row + sheet;
* `ActiveFilterChips` renders the removable chips and is placed above the list.
*/
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { de } from '../strings/de'
import './gerbilFilterBar.css'
export interface FilterOption {
value: string
label: string
/** Optional swatch dot (used for status colors). */
color?: string
/** Optional leading symbol (used for gender ♂ ♀ ?). */
symbol?: string
}
export interface FilterCategory {
/** Matches the Gridify field / the key into the selected map. */
key: string
label: string
/** Renders a search box above the option list (for long lists). */
searchable?: boolean
options: FilterOption[]
}
function countFor(selected: Record<string, string[]>, key: string): number {
return (selected[key] ?? []).length
}
interface GerbilFilterBarProps {
categories: FilterCategory[]
selected: Record<string, string[]>
onToggle: (key: string, value: string) => void
}
/** The Quick-Chips pill row + the category bottom-sheet. */
export function GerbilFilterBar({ categories, selected, onToggle }: GerbilFilterBarProps) {
const t = de.filterBar
const [activeKey, setActiveKey] = useState<string | null>(null)
const activeCat = categories.find((c) => c.key === activeKey) ?? null
return (
<div className="rmf">
<div className="rmf-quickrow" role="group" aria-label={t.title}>
{categories.map((cat) => {
const n = countFor(selected, cat.key)
return (
<button
key={cat.key}
type="button"
className={'rmf-pill' + (n > 0 ? ' is-on' : '')}
onClick={() => setActiveKey(cat.key)}
aria-haspopup="dialog"
>
{cat.label}
{n > 0 && <span className="rmf-pill-count">{n}</span>}
<span className="rmf-pill-chev" aria-hidden="true"></span>
</button>
)
})}
</div>
{activeCat && (
<FilterSheet
cat={activeCat}
selected={selected[activeCat.key] ?? []}
onToggle={onToggle}
onClose={() => setActiveKey(null)}
/>
)}
</div>
)
}
interface ActiveFilterChipsProps {
categories: FilterCategory[]
selected: Record<string, string[]>
onRemove: (key: string, value: string) => void
onClearAll: () => void
/** Optional per-chip accent colour (e.g. status colours); falls back to the app accent. */
chipColor?: (key: string, value: string) => string | undefined
}
/**
* Removable chips for the active selections, placed above the list so they scroll
* away as you browse. Wraps onto multiple lines — nothing is hidden.
*/
export function ActiveFilterChips({
categories,
selected,
onRemove,
onClearAll,
chipColor,
}: ActiveFilterChipsProps) {
const t = de.filterBar
const chips = categories.flatMap((cat) => {
const labelByValue = new Map(cat.options.map((o) => [o.value, o.label]))
return (selected[cat.key] ?? []).map((value) => ({
key: cat.key,
value,
catLabel: cat.label,
label: labelByValue.get(value) ?? value,
}))
})
if (chips.length === 0) return null
return (
<div className="rmf-chips">
{chips.map((c) => {
const accent = chipColor?.(c.key, c.value)
return (
<button
key={c.key + c.value}
type="button"
className="rmf-chip"
style={accent ? ({ '--chip': accent } as CSSProperties) : undefined}
onClick={() => onRemove(c.key, c.value)}
aria-label={`${c.catLabel}: ${c.label}${t.remove}`}
>
<span className="rmf-chip-cat">{c.catLabel}</span>
<span className="rmf-chip-val">{c.label}</span>
<span className="rmf-chip-x" aria-hidden="true">×</span>
</button>
)
})}
{chips.length > 1 && (
<button type="button" className="rmf-chip rmf-chip-clear" onClick={onClearAll}>
{t.clearAll}
</button>
)}
</div>
)
}
interface BottomSheetProps {
title: string
onClose: () => void
children: ReactNode
}
/**
* Reusable bottom-sheet: dimmed scrim + slide-up panel with grip, title and a
* "Fertig" button. Locks body scroll and closes on Escape / scrim tap. Used for
* the category filters AND the sort selector (same drawer mechanic).
*/
export function BottomSheet({ title, onClose, children }: BottomSheetProps) {
const t = de.filterBar
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [onClose])
// Portal to <body> so the sheet escapes any sticky/stacking-context ancestor
// (e.g. the sticky header) and renders above the bottom action bar.
return createPortal(
<div className="rmf-scrim" onClick={onClose} role="presentation">
<div
className="rmf-sheet"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<div className="rmf-grip" aria-hidden="true" />
<div className="rmf-sheet-head">
<span className="rmf-sheet-title">{title}</span>
<button type="button" className="rmf-sheet-done" onClick={onClose}>
{t.done}
</button>
</div>
<div className="rmf-sheet-body">{children}</div>
</div>
</div>,
document.body,
)
}
interface FilterSheetProps {
cat: FilterCategory
selected: string[]
onToggle: (key: string, value: string) => void
onClose: () => void
}
function FilterSheet({ cat, selected, onToggle, onClose }: FilterSheetProps) {
return (
<BottomSheet title={cat.label} onClose={onClose}>
<OptionList cat={cat} selected={selected} onToggle={onToggle} />
</BottomSheet>
)
}
interface OptionListProps {
cat: FilterCategory
selected: string[]
onToggle: (key: string, value: string) => void
}
function OptionList({ cat, selected, onToggle }: OptionListProps) {
const t = de.filterBar
const [q, setQ] = useState('')
const options = useMemo(() => {
if (!cat.searchable) return cat.options
const term = q.trim().toLowerCase()
if (!term) return cat.options
return cat.options.filter((o) => o.label.toLowerCase().includes(term))
}, [cat, q])
return (
<div className="rmf-picker">
{cat.searchable && (
<input
className="rmf-search"
placeholder={t.searchPlaceholder}
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
aria-label={t.searchPlaceholder}
/>
)}
<div className={'rmf-optlist' + (cat.searchable ? ' rmf-optlist--scroll' : '')}>
{options.length === 0 && <div className="rmf-empty">{t.noMatch}</div>}
{options.map((o) => {
const on = selected.includes(o.value)
return (
<button
key={o.value}
type="button"
className={'rmf-opt' + (on ? ' is-on' : '')}
onClick={() => onToggle(cat.key, o.value)}
aria-pressed={on}
>
<span className="rmf-opt-box" aria-hidden="true">
{on ? '✓' : ''}
</span>
{o.color && <span className="rmf-opt-dot" style={{ background: o.color }} />}
{o.symbol && (
<span className="rmf-opt-sym" aria-hidden="true">
{o.symbol}
</span>
)}
<span className="rmf-opt-label">{o.label}</span>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -21,15 +21,17 @@ export default function GerbilIcon({ size = '1em', style, className }: Props) {
aria-label="Rennmaus"
>
{/* body — sitting upright */}
<path d="M40 14c6 0 11 5 11 12 0 4-2 7-2 11 0 10-6 16-15 16-8 0-14-6-14-15 0-13 9-24 20-24z" />
{/* ear */}
<circle cx="46" cy="15" r="5" />
{/* hind paw */}
<ellipse cx="24" cy="52" rx="9" ry="3.5" />
{/* long curved tail with tuft */}
<path d="M16 51c-7 1-13-2-15-9-1-4 0-8 3-10 1 3 1 6 3 8 3 3 7 4 11 4z" />
<path d="M 34 23 C 38 20, 44 21, 46 25 C 47 27, 45 30, 41 31 C 38 32, 40 38, 40 42 C 40 48, 36 51, 31 52 C 25 52, 21 47, 21 40 C 21 31, 27 25, 34 23 Z" />
{/* ears */}
<path d="M 36 22 Q 33 13 39 11 Q 42 13 39 21 Z" />
<path d="M 32 23 Q 30 15 35 13 Q 37 15 35 22 Z" opacity="0.6" />
{/* front paw */}
<path d="M30 45c3 0 5 1 5 4-3 1-6 1-7-2 0-1 1-2 2-2z" />
<path d="M 41 33 Q 46 33 46 36 Q 44 38 41 35 Z" />
{/* hind paw */}
<path d="M 21 49 C 19 53, 23 55, 30 55 C 33 55, 34 52, 30 50 Z" />
{/* tail and tuft */}
<path d="M 21 47 C 14 51, 9 46, 8 41 C 7 35, 11 27, 16 22 C 17 21, 18 22, 17 23 C 13 28, 10 35, 11 40 C 12 44, 15 47, 21 44 Z" />
<path d="M 16 22 C 16 16, 13 11, 16 7 C 20 3, 22 10, 19 16 C 18 19, 17 21, 16 22 Z" />
</svg>
)
}

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

@@ -43,6 +43,39 @@
width: 5rem;
}
.namegen-panel__gender {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-gender-group {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 0.4rem;
overflow: hidden;
}
.namegen-gender-btn {
background: none;
border: none;
border-left: 1px solid var(--color-border);
padding: 0.35rem 0.7rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
color: var(--color-text);
}
.namegen-gender-btn:first-child {
border-left: none;
}
.namegen-gender-btn.is-active {
background: var(--color-accent, #2563eb);
color: #fff;
}
.namegen-panel__usages {
display: flex;
flex-direction: column;

View File

@@ -12,9 +12,17 @@ interface NameSuggestPanelProps {
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
type GenderSelection = 'any' | 'female' | 'male'
/** Map the gerbil form's gender onto the generator's three-way choice. */
function seedGender(gender: string): GenderSelection {
return gender === 'female' || gender === 'male' ? gender : 'any'
}
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
const t = de.namegen
const [letter, setLetter] = useState('')
const [genderSel, setGenderSel] = useState<GenderSelection>(() => seedGender(gender))
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
const [loading, setLoading] = useState(false)
const [keyMissing, setKeyMissing] = useState(false)
@@ -39,7 +47,7 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
try {
const result = await suggestNames({
letter: letter.trim() || undefined,
gender: gender || undefined,
gender: genderSel === 'any' ? undefined : genderSel,
usages: [...usages] as NamegenUsageCode[],
count: 6,
})
@@ -78,6 +86,28 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
/>
</div>
<div className="namegen-panel__gender">
<span>{t.genderLabel}</span>
<div className="namegen-gender-group" role="radiogroup" aria-label={t.genderLabel}>
{([
['any', t.genderAny],
['female', t.genderFemale],
['male', t.genderMale],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={genderSel === value}
className={`namegen-gender-btn ${genderSel === value ? 'is-active' : ''}`}
onClick={() => setGenderSel(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="namegen-panel__usages">
<span>{t.usagesLabel}</span>
<div className="namegen-usages-grid">

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,427 @@
/* DESIGN (Rennmaus Filter.html): Quick-Chips + Bottom-Sheet + entfernbare Chips.
Mobile-first. Palette + font sizes are taken 1:1 from the design (the app tokens
are now the design's creme/brown/terracotta), so this matches the prototype. */
.rmf {
--rmf-ease: cubic-bezier(0.22, 1, 0.36, 1);
margin: 0.5rem 0 0.25rem;
}
/* ---------- Quick-Chips pill row ---------- */
.rmf-quickrow {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 2px 0 4px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.rmf-quickrow::-webkit-scrollbar {
display: none;
}
.rmf-pill {
flex-shrink: 0;
cursor: pointer;
font: inherit;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 9px 13px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.375rem;
box-shadow: var(--shadow);
transition: transform 0.12s var(--rmf-ease), border-color 0.15s, color 0.15s, background 0.15s;
}
.rmf-pill:active {
transform: scale(0.94);
}
.rmf-pill.is-on {
border-color: var(--color-accent);
color: var(--color-accent);
background: var(--color-accent-soft);
}
.rmf-pill-count {
background: var(--color-accent);
color: #fff;
font-size: 11px;
font-weight: 800;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.rmf-pill-chev {
font-size: 11px;
color: var(--color-text-faint);
}
/* ---------- Active filter chips ---------- */
.rmf-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.625rem;
}
.rmf-chip {
--chip: var(--color-accent);
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: color-mix(in srgb, var(--chip) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--chip) 32%, transparent);
color: color-mix(in srgb, var(--chip) 78%, #2b2723);
padding: 6px 8px 6px 11px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
animation: rmfChipIn 0.22s var(--rmf-ease);
transition: transform 0.1s;
}
.rmf-chip:active {
transform: scale(0.94);
}
.rmf-chip-cat {
font-size: 11px;
font-weight: 700;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.rmf-chip-val {
font-weight: 700;
}
.rmf-chip-x {
font-size: 15px;
line-height: 1;
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--chip) 22%, transparent);
border-radius: 50%;
}
.rmf-chip-clear {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.rmf-chip-clear .rmf-chip-x {
display: none;
}
@keyframes rmfChipIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ---------- Bottom-Sheet ---------- */
.rmf-scrim {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(43, 33, 25, 0.42);
display: flex;
align-items: flex-end;
justify-content: center;
animation: rmfScrim 0.2s ease;
}
@keyframes rmfScrim {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.rmf-sheet {
width: 100%;
max-width: 460px;
background: var(--color-bg);
border-radius: 22px 22px 0 0;
max-height: 82vh;
display: flex;
flex-direction: column;
box-shadow: 0 -8px 40px rgba(43, 33, 25, 0.25);
animation: rmfSheetUp 0.32s var(--rmf-ease);
}
@keyframes rmfSheetUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.rmf-grip {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--color-text-faint);
opacity: 0.5;
margin: 9px auto 4px;
}
.rmf-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.rmf-sheet-title {
font-size: 18px;
font-weight: 800;
color: var(--color-text);
}
.rmf-sheet-done {
background: var(--color-accent);
color: #fff;
border: none;
font: inherit;
font-weight: 700;
font-size: 14px;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.rmf-sheet-body {
overflow-y: auto;
padding: 0.5rem 1rem 1.5rem;
-webkit-overflow-scrolling: touch;
}
/* ---------- Option list (multi-select checkboxes) ---------- */
.rmf-picker {
display: flex;
flex-direction: column;
}
.rmf-search {
width: 100%;
border: 1px solid var(--color-border);
background: #fff;
border-radius: 14px;
padding: 0.75rem 0.875rem;
font: inherit;
font-size: 1rem;
color: var(--color-text);
outline: none;
margin: 0.25rem 0 0.375rem;
}
.rmf-search:focus {
border-color: var(--color-accent);
}
.rmf-optlist {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 4px;
}
.rmf-optlist--scroll {
max-height: 46vh;
overflow-y: auto;
}
.rmf-opt {
display: flex;
align-items: center;
gap: 0.7rem;
background: none;
border: none;
font: inherit;
padding: 0.75rem 0.375rem;
cursor: pointer;
color: var(--color-text);
font-size: 1rem;
font-weight: 500;
text-align: left;
width: 100%;
border-radius: 10px;
transition: background 0.12s;
}
.rmf-opt:hover {
background: var(--color-accent-soft);
}
.rmf-opt.is-on {
font-weight: 700;
}
.rmf-opt-box {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 7px;
border: 1.5px solid var(--color-border);
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #fff;
font-weight: 800;
transition: background 0.14s, border-color 0.14s;
}
.rmf-opt.is-on .rmf-opt-box {
background: var(--color-accent);
border-color: var(--color-accent);
}
.rmf-opt-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.rmf-opt-sym {
width: 22px;
text-align: center;
font-size: 16px;
color: var(--color-text-muted);
}
.rmf-opt-label {
flex: 1;
}
.rmf-empty {
padding: 1.1rem 0.375rem;
color: var(--color-text-muted);
font-size: 15px;
}
/* ---------- Search bar ---------- */
.rmf-searchbar {
margin-top: 0.75rem;
position: relative;
display: flex;
align-items: center;
background: #fff;
border: 1px solid var(--color-border);
border-radius: 14px;
box-shadow: var(--shadow);
}
.rmf-search-ico {
padding-left: 0.875rem;
font-size: 19px;
color: var(--color-text-faint);
}
.rmf-searchfield {
flex: 1;
border: none;
background: transparent;
outline: none;
font: inherit;
font-size: 16px;
color: var(--color-text);
padding: 0.875rem 0.75rem;
min-width: 0;
}
.rmf-searchfield::placeholder {
color: var(--color-text-faint);
}
.rmf-search-clear {
border: none;
background: var(--color-accent-soft);
color: var(--color-text-muted);
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 0.625rem;
font-size: 1rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
/* ---------- Bar: Externe-Ahnen toggle + Sortieren — two equal-width chips ---------- */
.rm-bar {
display: flex;
align-items: stretch;
gap: 0.5rem;
margin-top: 0.625rem;
}
/* Shared pill: both chips are the SAME size (flex: 1 → equal width). */
.rmf-bar-chip {
flex: 1 1 0;
min-width: 0;
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
padding: 8px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
box-shadow: var(--shadow);
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
}
.rmf-bar-chip:active {
transform: scale(0.97);
}
/* Externe-Ahnen active state. */
.rmf-bar-chip.is-on {
background: var(--color-accent-soft);
border-color: color-mix(in srgb, var(--color-accent) 32%, transparent);
color: color-mix(in srgb, var(--color-accent) 78%, #2b2723);
}
.rmf-bar-chip-label {
overflow: hidden;
text-overflow: ellipsis;
}
.rmf-bar-chip-ico {
color: var(--color-accent);
font-weight: 800;
}
.rmf-bar-chip-chev {
font-size: 11px;
color: var(--color-text-faint);
}
/* Toggle icon circle on the Externe-Ahnen chip (+ → ✓). */
.rmf-chip-toggle-ico {
font-size: 14px;
line-height: 1;
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 800;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
transition: background 0.15s, color 0.15s;
}
.rmf-bar-chip.is-on .rmf-chip-toggle-ico {
background: var(--color-accent);
color: #fff;
}
/* Desktop: the sheet becomes a centered modal rather than a bottom drawer. */
@media (min-width: 768px) {
.rmf-scrim {
align-items: center;
}
.rmf-sheet {
border-radius: 18px;
max-width: 420px;
max-height: 70vh;
animation: rmfChipIn 0.2s var(--rmf-ease);
}
}

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

@@ -16,6 +16,7 @@ import {
fromJSON,
wildType,
extractGenotypeFlags,
displayGenotypeSafe,
} from '../genotype'
import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci'
@@ -686,4 +687,48 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
})
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
it('handles blank c locus and c[-] resiliently without crashing', () => {
// Vandana's genotype
const rawVandana = 'Aa Cc Dd eef Gg P? spsp ??'
const g = fromDisplayString(rawVandana)
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe('Aa C- Dd ee[f] Gg P- spsp')
// cchmc allele
const g2 = fromDisplayString('Aa cchmc Dd ee Gg Pp spsp')
expect(g2.C).toEqual(['cchm', '?'])
expect(toDisplayString(g2)).toBe('Aa c[chm]- Dd ee Gg Pp spsp')
// c[-] and blank c standalone
const g3 = fromDisplayString('Aa c[-] Dd ee Gg Pp spsp')
expect(g3.C).toEqual(['C', 'C']) // default to wild-type since c[-] was skipped
expect(toDisplayString(g3)).toBe('Aa CC Dd ee Gg Pp spsp')
const g4 = fromDisplayString('Aa c Dd ee Gg Pp spsp')
expect(g4.C).toEqual(['C', 'C']) // default to wild-type since c was skipped
expect(toDisplayString(g4)).toBe('Aa CC Dd ee Gg Pp spsp')
})
it('displayGenotypeSafe formats parsed genotypes and handles fallback safely', () => {
// 1. Parseable with unknown alleles, replacing ? with -
expect(displayGenotypeSafe('Aa C? Dd')).toBe('Aa C- Dd EE GG PP spsp')
// 2. Parseable with trailing ?? (stripped completely)
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp ??')).toBe('Aa CC Dd EE GG Pp spsp')
expect(displayGenotypeSafe('Aa Cc Dd eef Gg P? spsp ??')).toBe('Aa C- Dd ee[f] Gg P- spsp')
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp Slsl ??')).toBe('Aa CC Dd EE GG Pp spsp Slsl')
// 3. Unparseable fallback: removes ?? / -- / [-], replaces remaining ? with -
expect(displayGenotypeSafe('Aa Cc XX ??')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX --')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX [-]')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc? XX')).toBe('Aa Cc- XX')
// 4. Handles null/undefined/empty
expect(displayGenotypeSafe(null)).toBe('')
expect(displayGenotypeSafe(undefined)).toBe('')
expect(displayGenotypeSafe('')).toBe('')
})
})

View File

@@ -192,6 +192,11 @@ function normalizeToken(tok: string): string | null {
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
// leaving the leading allele intact for splitToken).
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
t = t.replace(/^c\[-\]$/g, '??')
t = t.replace(/^c\?$/g, '??')
t = t.replace(/^c$/g, '??')
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
t = t.replace(/-/g, '?')
@@ -257,6 +262,25 @@ export function fromDisplayString(input: string): Genotype {
return makeGenotype({ ...base, ...acc })
}
export function displayGenotypeSafe(raw: string | null | undefined): string {
if (!raw) return ''
try {
const parsed = fromDisplayString(raw)
return toDisplayString(parsed)
} catch {
// Fallback path:
return raw
.trim()
.split(/\s+/)
.filter((tok) => {
const norm = normalizeToken(tok)
return norm !== null && norm !== '?' && norm !== '??'
})
.map((tok) => tok.replace(/\?/g, '-'))
.join(' ')
}
}
export function hasUnknown(g: Genotype): boolean {
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
}

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