Compare commits

...

3 Commits

Author SHA1 Message Date
6eb82da90c UX-MOBILE-1: FilterPanel — einklappbare Filter auf Mobil (390px)
Neues FilterPanel-Muster (src/components/FilterPanel.tsx + filterPanel.css):
- Mobil (<768px): Suchfeld immer sichtbar; alle weiteren Filter hinter einem
  'Filter (N)'-Button eingeklappt (Badge = Anzahl aktiver Nicht-Default-Filter).
  Tippen öffnet/schließt den Drawer. 'Filter zurücksetzen' im Drawer.
- Desktop (>=768px): Toggle versteckt, Drawer als display:contents → alle Controls
  inline in der bestehenden .filters-Flexzeile, wie bisher.
Auf GerbilsPage, WuerfeListPage und AnfragenPage ausgerollt.
BeckenPage/KontaktePage nur ein Suchfeld → kein Panel nötig.

e2e: filter-panel.spec.ts (8 Tests, phone+desktop); bestand/tiere/anfragen-Specs
erhalten openFilterPanel-Aufruf vor Drawer-Controls; fixtures.ts +openFilterPanel().
Vitest 78 / e2e 102 grün.

Mobil-Pass (390px, Durchsicht): Hauptproblem behoben. Beobachtete Restpunkte
für god (nicht selbst gefixed):
- .genotype-table in Genetik/Probeverpaarung: Genotyp-Strings können eng werden
- Stammbaum (react-d3-tree SVG): kein explizites mobile Viewport-Handling
- BreedingResultView Genotyp-Detail-Tabelle: ggf. horizontal scrollbar-los

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:28:13 +02:00
0a1e63bec6 Merge feature/import-counter: 31 undatierte Wurfchronik-Eintraege ehrlich als WithoutDate gezaehlt statt als Phantom-created (+latente litterIdMap-FK-Falle entschaerft) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:25:51 +02:00
5b30407257 IMPORT-COUNTER-BUG: undatierte Wuerfe als WithoutDate zaehlen, nicht als Created
31 undatierte Wurfchronik-Eintraege (leeres Datumsfeld) wurden bei jedem Lauf
als littersCreated++ gezaehlt, weil ihr Idempotenz-Key (kein Datum) nie in
existingLitterKeySet stand -- aber kein DB-Insert folgte, da date is DateOnly d
false war. Das verfaelschte Arithmetik und Julians Bericht (752 vs 723).

Fix: undatierte Eintraege werden am Schleifenanfang uebersprungen
(littersWithoutDate++, continue) bevor sie in litterIdMap oder den
Created-Zaehler einfliessen. Execute-Block ohne redundante date-Pruefung.
Neues Feld LitterSummary.WithoutDate; Report-Notiz wenn WithoutDate > 0.

Gate: 126/126 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:24:55 +02:00
14 changed files with 306 additions and 27 deletions

View File

@@ -507,6 +507,44 @@ namespace GerbilManager.Tests
} }
} }
[Fact]
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
{
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
// One dated litter, one undated litter (blank date field)
File.WriteAllText(Path.Combine(dir, "litters.json"), """
[
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
]
""");
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
using var db = NewDb();
// First run
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, r1.Litters.InSource);
Assert.Equal(1, r1.Litters.Created); // only the dated one
Assert.Equal(0, r1.Litters.AlreadyImported);
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
// Second run (re-import): dated litter is now existing, undated still WithoutDate
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
Assert.Equal(1, r2.Litters.AlreadyImported);
Assert.Equal(1, r2.Litters.WithoutDate);
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Theory] [Theory]
[InlineData("01.02.2020", 2020, 2, 1)] [InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)] [InlineData("5.3.21", 2021, 3, 5)]

View File

@@ -87,7 +87,7 @@ namespace GerbilManagerWebAPI.Import
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0, int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
int ParentFksBackfilled = 0); int ParentFksBackfilled = 0, int WithoutDate = 0);
public sealed record AnimalSummary( public sealed record AnimalSummary(
int InSource, int InSource,

View File

@@ -94,11 +94,17 @@ namespace GerbilManagerWebAPI.Import
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet(); var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ---- // ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
int littersCreated = 0, littersExisting = 0; // COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
// so their existingLitterKeySet key was always "" → they were always counted as
// "created" even though the execute block skipped them (date is DateOnly d = false).
// Fix: skip undated litters early — they can never be created or linked to animals.
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters) foreach (var sl in litters)
{ {
var date = ParseDate(sl.Date); var date = ParseDate(sl.Date);
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
var name = $"Wurf {sl.LitterId}".Trim(); var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}"; var key = $"{name}|{date:yyyy-MM-dd}";
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; } if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
@@ -106,19 +112,19 @@ namespace GerbilManagerWebAPI.Import
var id = Guid.NewGuid(); var id = Guid.NewGuid();
litterIdMap[sl.Id] = id; litterIdMap[sl.Id] = id;
littersCreated++; littersCreated++;
if (execute && date is DateOnly d) if (execute)
{ {
_db.Litters.Add(new Litter _db.Litters.Add(new Litter
{ {
Id = id, Id = id,
Name = name, Name = name,
Date = d, Date = date.Value,
TotalBorn = sl.TotalBorn, TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note, Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer, PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
}); });
} }
if (samples.Count < 8 && date is not null) if (samples.Count < 8)
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}"); samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
} }
if (execute) await _db.SaveChangesAsync(); if (execute) await _db.SaveChangesAsync();
@@ -454,6 +460,8 @@ namespace GerbilManagerWebAPI.Import
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync(); if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
} }
if (littersWithoutDate > 0)
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
if (parentLinksAdded > 0) if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
@@ -468,7 +476,7 @@ namespace GerbilManagerWebAPI.Import
return new ImportReport( return new ImportReport(
Executed: execute, Executed: execute,
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled), Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
Animals: new AnimalSummary( Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),

View File

@@ -3,7 +3,7 @@
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis). * AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock. * Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
*/ */
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures' import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
const ta = de.pages.anfragen const ta = de.pages.anfragen
const td = ta.detail const td = ta.detail
@@ -23,6 +23,8 @@ test.describe('Anfragen', () => {
// Status-Badge auf der Karte // Status-Badge auf der Karte
await expect(cards.nth(0)).toContainText(ta.statusLabels.New) await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Filter: nur Beantwortet // Filter: nur Beantwortet
await page.getByLabel(td.statusLabel).selectOption('Answered') await page.getByLabel(td.statusLabel).selectOption('Answered')
await expect(cards).toHaveCount(1) await expect(cards).toHaveCount(1)

View File

@@ -1,5 +1,5 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */ /** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures' import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils const t = de.pages.gerbils
@@ -14,11 +14,13 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) =
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0) await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
}) })
test('Toggle „Externe Ahnen einblenden zeigt externe Tiere mit Extern-Markierung', async ({ page }) => { test('Toggle „Externe Ahnen einblenden zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await gotoSection(page, de.nav.gerbils) await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible() 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() await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' }) const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })

View File

@@ -0,0 +1,85 @@
/**
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
*
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
*/
import { de, expect, gotoSection, 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) => {
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()
})
test('Phone: Toggle öffnet und schließt den Filter-Drawer', 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()
// Schließen → wieder verborgen.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Phone: Badge zählt aktive Filter korrekt', async ({
page,
}, testInfo) => {
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()
})
})

View File

@@ -63,6 +63,18 @@ export function acceptNextDialog(page: Page) {
page.once('dialog', (d) => void d.accept()) page.once('dialog', (d) => void d.accept())
} }
/**
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
* kein Klick nötig — Controls sind direkt sichtbar.
*/
export async function openFilterPanel(page: Page) {
const toggle = page.locator('.filter-panel__toggle')
if (await toggle.isVisible()) {
await toggle.click()
}
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */ /** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) => export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` `${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -1,5 +1,5 @@
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */ /** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures' import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils const t = de.pages.gerbils
const tabs = de.pages.tierTabs const tabs = de.pages.tierTabs
@@ -23,6 +23,8 @@ 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: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).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). // Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })

View File

@@ -0,0 +1,52 @@
import { useState, type ReactNode } from 'react'
import { de } from '../strings/de'
import './filterPanel.css'
interface FilterPanelProps {
/** Always visible on mobile (typically the search text input). Optional. */
searchField?: ReactNode
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
children: ReactNode
/** Number of currently active (non-default) filter values. Shown as badge. */
activeCount: number
/** Called when the reset button is clicked. */
onReset: () => void
}
/**
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
* Render inside the existing `.filters` div (or replace it entirely).
*
* Desktop (>=768px): renders all children inline, identical to today.
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
* the rest of the controls in a column drawer + a reset button.
*/
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
const [open, setOpen] = useState(false)
const t = de.filterPanel
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
return (
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
{searchField}
<button
type="button"
className="btn btn--ghost filter-panel__toggle"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={label}
>
{label}
</button>
<div className="filter-panel__drawer">
{children}
{activeCount > 0 && (
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
{t.resetButton}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
/*
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
*
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
* children participate directly in the parent .filters flex row.
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
* width drawer (flex column) with the remaining filters + reset button.
*/
/* ── Mobile default ── */
.filter-panel__toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.filter-panel__drawer {
display: none;
flex-direction: column;
gap: 0.75rem;
width: 100%;
padding-top: 0.25rem;
}
.filter-panel--open .filter-panel__drawer {
display: flex;
}
/* ── Desktop ── */
@media (min-width: 768px) {
.filter-panel__toggle {
display: none;
}
.filter-panel__drawer {
/* Let children participate directly in the parent flex row. */
display: contents;
}
/* Reset button sits in the flex row on desktop when active filters exist. */
.filter-panel__reset-btn {
align-self: flex-end;
}
}
/* ── Mobile reset button ── */
@media (max-width: 767px) {
.filter-panel__reset-btn {
align-self: flex-start;
margin-top: 0.25rem;
}
}

View File

@@ -15,6 +15,7 @@ import {
} from '../api/requests' } from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDateTime } from '../format/labels' import { formatDateTime } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './anfragen.css' import './anfragen.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -75,7 +76,10 @@ export default function AnfragenPage() {
{sync.error && <div className="alert alert--error">{sync.error}</div>} {sync.error && <div className="alert alert--error">{sync.error}</div>}
{/* Status-Filter */} {/* Status-Filter */}
<div className="filters"> <FilterPanel
activeCount={status !== '' ? 1 : 0}
onReset={() => { setStatus(''); setPage(1) }}
>
<label className="field"> <label className="field">
<span>{t.detail.statusLabel}</span> <span>{t.detail.statusLabel}</span>
<select <select
@@ -93,7 +97,7 @@ export default function AnfragenPage() {
))} ))}
</select> </select>
</label> </label>
</div> </FilterPanel>
{requests.loading && <p className="muted">{de.common.loading}</p>} {requests.loading && <p className="muted">{de.common.loading}</p>}
{requests.error && ( {requests.error && (

View File

@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types' import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './gerbils.css' import './gerbils.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -82,6 +83,14 @@ export default function GerbilsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = gerbils.data?.items ?? [] const items = gerbils.data?.items ?? []
// UX-MOBILE-1: count non-default filter values for the badge.
const activeFilterCount =
(status !== 'Active' ? 1 : 0) +
(gender !== '' ? 1 : 0) +
(colorVarietyId !== '' ? 1 : 0) +
(originBreeder !== '' ? 1 : 0) +
(showExternal ? 1 : 0)
// Multi-select bulk "Zur Abgabe stellen". // Multi-select bulk "Zur Abgabe stellen".
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const toggleSelect = (id: string) => const toggleSelect = (id: string) =>
@@ -118,15 +127,20 @@ export default function GerbilsPage() {
</Link> </Link>
</header> </header>
<div className="filters"> <FilterPanel
<input searchField={
type="search" <input
className="input" type="search"
placeholder={t.searchPlaceholder} className="input"
value={search} placeholder={t.searchPlaceholder}
onChange={(e) => onFilterChange(setSearch)(e.target.value)} value={search}
aria-label={t.fields.name} onChange={(e) => onFilterChange(setSearch)(e.target.value)}
/> aria-label={t.fields.name}
/>
}
activeCount={activeFilterCount}
onReset={resetFilters}
>
<label className="field"> <label className="field">
<span>{t.filters.status}</span> <span>{t.filters.status}</span>
<select <select
@@ -200,10 +214,7 @@ export default function GerbilsPage() {
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)} onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
/> />
</label> </label>
<button type="button" className="btn" onClick={resetFilters}> </FilterPanel>
{t.filters.reset}
</button>
</div>
{gerbils.loading && <p className="muted">{de.common.loading}</p>} {gerbils.loading && <p className="muted">{de.common.loading}</p>}
{gerbils.error && ( {gerbils.error && (

View File

@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import type { Litter } from '../api/types' import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels' import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -111,7 +112,10 @@ export default function WuerfeListPage() {
{tab === 'litters' && ( {tab === 'litters' && (
<> <>
<div className="filters"> <FilterPanel
activeCount={year !== '' ? 1 : 0}
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
>
<label className="field"> <label className="field">
<span>{t.filterYear}</span> <span>{t.filterYear}</span>
<select <select
@@ -136,7 +140,7 @@ export default function WuerfeListPage() {
<option value="dateAsc">{t.sort.dateAsc}</option> <option value="dateAsc">{t.sort.dateAsc}</option>
</select> </select>
</label> </label>
</div> </FilterPanel>
{litters.loading && <p className="muted">{de.common.loading}</p>} {litters.loading && <p className="muted">{de.common.loading}</p>}
{litters.error && ( {litters.error && (

View File

@@ -807,6 +807,12 @@ export const de = {
{ key: 'schreckhaft', label: 'schreckhaft' }, { key: 'schreckhaft', label: 'schreckhaft' },
], ],
}, },
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
filterPanel: {
toggleButton: 'Filter',
resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen',
},
} as const } as const
export type Strings = typeof de export type Strings = typeof de