BESTAND-FILTER: Tiere-Liste zeigt standardmäßig nur den eigenen Bestand
Die Tiere-Liste (/rennmaeuse) filtert serverseitig auf isResident==true (eigener Clan = Bestand) und blendet externe Ahnen (nur für den Stammbaum erfasste Fremdtiere) standardmäßig aus — passend zu Julians Modell (Kleine Chaoten = Bestand, extern = stammbaum-only). - Neuer Schalter „Externe Ahnen einblenden“ (Default aus). An -> isResident- Filter entfällt, externe Tiere erscheinen mit. Serverseitig (Gridify), damit Paging und Trefferzahl korrekt bleiben. „Filter zurücksetzen“ setzt ihn zurück. - Subtile „Extern“-Markierung (gestricheltes Badge) auf externen Tieren in Liste UND Detailseite, mit Tooltip-Erläuterung. - Gerbil-Typ um isResident (optional bool) ergänzt; CreateGerbil ebenso. Deutsch via de.ts (LF), mobil + Laptop. e2e-Mock: gerbil()-Factory setzt isResident=true; Hilde & Max als externe Ahnen (isResident=false). Neue e2e bestand.spec (Default blendet aus; Toggle zeigt + Extern-Markierung). Gate grün: build, eslint, vitest 78, e2e 84. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
29
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
29
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
|
test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Bestand ist sichtbar …
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
// … aber die externe Ahne „Max“ (isResident=false) nicht.
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||||
|
|
||||||
|
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||||
|
await expect(maxRow).toBeVisible()
|
||||||
|
await expect(maxRow.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||||
|
// Der Bestand bleibt weiterhin sichtbar.
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -88,6 +88,8 @@ function gerbil(
|
|||||||
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
|
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
|
||||||
genotype,
|
genotype,
|
||||||
notes: null,
|
notes: null,
|
||||||
|
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||||
|
isResident: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +112,9 @@ export function seedDb(): MockDb {
|
|||||||
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
||||||
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
|
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
|
||||||
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
|
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
|
||||||
gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'),
|
// BESTAND-FILTER: externe Ahnen (Gründertiere fremder Zuchten, nur für den Stammbaum).
|
||||||
gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'),
|
{ ...gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||||
|
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
{ ...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('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export interface Gerbil {
|
|||||||
*/
|
*/
|
||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
|
/**
|
||||||
|
* Residency (Bestand): true = part of the own clan (Kleine Chaoten), false =
|
||||||
|
* external/pedigree-only ancestor imported for the Stammbaum. Gridify-filterable
|
||||||
|
* (`isResident==true`); the Tiere list defaults to resident-only. Absent = treat
|
||||||
|
* as resident (backend default true).
|
||||||
|
*/
|
||||||
|
isResident?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -68,6 +75,7 @@ export interface CreateGerbil {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
|
isResident?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
@@ -330,6 +330,24 @@ textarea {
|
|||||||
color: #3a6ea5;
|
color: #3a6ea5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* BESTAND-FILTER: subtle marker for external (non-Bestand) animals. Inline next
|
||||||
|
to the name, so it should not be pushed to the grid edge like the status badge. */
|
||||||
|
.badge--external {
|
||||||
|
justify-self: start;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #777;
|
||||||
|
border: 1px dashed #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
|
||||||
|
.field--check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.gerbil-card {
|
.gerbil-card {
|
||||||
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ export default function GerbilDetailPage() {
|
|||||||
<GerbilProfilePhoto gerbilId={g.id} />
|
<GerbilProfilePhoto gerbilId={g.id} />
|
||||||
<h2>{g.name}</h2>
|
<h2>{g.name}</h2>
|
||||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||||
|
{g.isResident === false && (
|
||||||
|
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||||
|
{de.pages.gerbils.externalBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export default function GerbilsPage() {
|
|||||||
const [gender, setGender] = useState<Gender | ''>('')
|
const [gender, setGender] = useState<Gender | ''>('')
|
||||||
const [colorVarietyId, setColorVarietyId] = useState('')
|
const [colorVarietyId, setColorVarietyId] = useState('')
|
||||||
const [originBreeder, setOriginBreeder] = useState('')
|
const [originBreeder, setOriginBreeder] = useState('')
|
||||||
|
// BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors.
|
||||||
|
const [showExternal, setShowExternal] = useState(false)
|
||||||
const [sort, setSort] = useState<SortKey>('nameAsc')
|
const [sort, setSort] = useState<SortKey>('nameAsc')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
@@ -50,6 +52,9 @@ export default function GerbilsPage() {
|
|||||||
gender && condition({ field: 'gender', op: '==', value: gender }),
|
gender && condition({ field: 'gender', op: '==', value: gender }),
|
||||||
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
|
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
|
||||||
originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
|
originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
|
||||||
|
// Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on,
|
||||||
|
// restrict to resident animals (server-side, so paging/counts stay correct).
|
||||||
|
!showExternal && condition({ field: 'isResident', op: '==', value: true }),
|
||||||
)
|
)
|
||||||
const orderBy = SORT_ORDER_BY[sort]
|
const orderBy = SORT_ORDER_BY[sort]
|
||||||
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
||||||
@@ -62,6 +67,7 @@ export default function GerbilsPage() {
|
|||||||
setGender('')
|
setGender('')
|
||||||
setColorVarietyId('')
|
setColorVarietyId('')
|
||||||
setOriginBreeder('')
|
setOriginBreeder('')
|
||||||
|
setShowExternal(false)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setSort('nameAsc')
|
setSort('nameAsc')
|
||||||
}
|
}
|
||||||
@@ -186,6 +192,14 @@ export default function GerbilsPage() {
|
|||||||
<option value="birthAsc">{t.sort.birthAsc}</option>
|
<option value="birthAsc">{t.sort.birthAsc}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field field--check" title={t.filters.showExternalHint}>
|
||||||
|
<span>{t.filters.showExternal}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showExternal}
|
||||||
|
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button type="button" className="btn" onClick={resetFilters}>
|
<button type="button" className="btn" onClick={resetFilters}>
|
||||||
{t.filters.reset}
|
{t.filters.reset}
|
||||||
</button>
|
</button>
|
||||||
@@ -234,7 +248,14 @@ export default function GerbilsPage() {
|
|||||||
aria-label={g.name}
|
aria-label={g.name}
|
||||||
/>
|
/>
|
||||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">{g.name}</span>
|
<span className="gerbil-card__name">
|
||||||
|
{g.name}
|
||||||
|
{g.isResident === false && (
|
||||||
|
<span className="badge badge--external" title={t.externalTitle}>
|
||||||
|
{t.externalBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={`badge badge--${g.status.toLowerCase()}`}>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>
|
||||||
{statusLabel(g.status)}
|
{statusLabel(g.status)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const de = {
|
|||||||
empty: 'Keine Rennmäuse gefunden.',
|
empty: 'Keine Rennmäuse gefunden.',
|
||||||
countLabel: 'Tiere',
|
countLabel: 'Tiere',
|
||||||
searchPlaceholder: 'Name suchen …',
|
searchPlaceholder: 'Name suchen …',
|
||||||
|
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
|
||||||
|
externalBadge: 'Extern',
|
||||||
|
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
|
||||||
// Filter-Beschriftungen
|
// Filter-Beschriftungen
|
||||||
filters: {
|
filters: {
|
||||||
title: 'Filter',
|
title: 'Filter',
|
||||||
@@ -58,6 +61,9 @@ export const de = {
|
|||||||
all: 'Alle',
|
all: 'Alle',
|
||||||
reset: 'Filter zurücksetzen',
|
reset: 'Filter zurücksetzen',
|
||||||
sortBy: 'Sortieren nach',
|
sortBy: 'Sortieren nach',
|
||||||
|
// BESTAND-FILTER: eigener Bestand (Clan) vs. externe Ahnen (nur Stammbaum)
|
||||||
|
showExternal: 'Externe Ahnen einblenden',
|
||||||
|
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
|
||||||
},
|
},
|
||||||
// Sortier-Optionen
|
// Sortier-Optionen
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
Reference in New Issue
Block a user