Compare commits

...

3 Commits

Author SHA1 Message Date
69ffe58d6b UI-POLISH-2: '(ohne Namen)' Platzhalter in Stammbaum, Vertrag-Wizard, Wurf-Formular
- StammbaumPage: PedigreeCard-Name + PrintCell-Name + beide titleFor()-Aufrufe → g.name || nameless
- VertragWizardPage: wizard-pick__name + join(', ')-Tierliste → g.name || nameless
- WurfFormPage: fatherName/motherName in Prefill (nameById.get || nameless) + Picker-Auswahl (g.name || nameless)

e2e: neuer Test stammbaum.spec — namenloser Ahne zeigt Platzhalter in Karte + Titel

Gate: vitest 82/82, e2e 148/148, tsc clean
2026-06-06 17:53:24 +02:00
d7c0698c44 Merge feature/form-fields: Herkunft (originBreeder) + 'Gehoerlos' (isDeaf) im Tier-Formular editierbar
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 54s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilFormPage: originBreeder-Freitextfeld + isDeaf-Tristate-Select (Ja/Nein/Unbekannt),
round-trip laden/speichern. types.ts isDeaf, de.ts Labels. +4 e2e round-trip. Komponiert
mit CR-2 PATCH-Semantik (ausgelassene Felder bleiben). vitest 82, e2e 146, build clean. Kein EF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:47:57 +02:00
db49150206 FORM-FIELDS-1: originBreeder + isDeaf in GerbilFormPage
- types.ts: isDeaf?: boolean|null in Gerbil + CreateGerbil (UpdateGerbil erbt via Partial)
- de.ts: fields.originBreeder, fields.isDeaf; form.isDeafUnknown/Yes/No
- GerbilFormPage: originBreeder Freitext-Input (neben originContactId-Dropdown); isDeaf Tristate-Select (Unbekannt/Ja/Nein); round-trip load→display→save

e2e: 2 neue Tests (originBreeder Freitext + isDeaf Tristate round-trip)

Gate: vitest 82/82, e2e 146/146, tsc clean
Rebase auf Pams CR-2 folgt nach deren Merge.
2026-06-06 17:47:25 +02:00
8 changed files with 85 additions and 10 deletions

View File

@@ -53,3 +53,12 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum') await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible() await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
}) })
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
// Titel zeigt '(ohne Namen)'
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
// Karte selbst zeigt '(ohne Namen)' statt leer
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
})

View File

@@ -149,3 +149,26 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText('Sehr zutraulich.')).toBeVisible() await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
}) })
test('Tier bearbeiten — originBreeder Freitext round-trip (FORM-FIELDS-1)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat originBreeder='Zoohandlung Meier', kein originContactId → Freitext-Feld vorbelegt
await page.goto('/rennmaeuse/fridolin/bearbeiten')
await expect(page.getByLabel(t.fields.originBreeder)).toHaveValue('Zoohandlung Meier')
await page.getByLabel(t.fields.originBreeder).fill('Neue Musterzucht')
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Detailseite: Herkunft zeigt den neuen Wert (kein Contact → originBreeder-Text)
await expect(page.getByText('Neue Musterzucht')).toBeVisible()
})
test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/bearbeiten')
// Standard: Unbekannt (null → value='')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('')
await page.getByLabel(t.fields.isDeaf).selectOption({ label: t.form.isDeafYes })
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Zurück zur Bearbeiten-Seite: Wert muss erhalten sein
await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
})

View File

@@ -54,6 +54,8 @@ export interface Gerbil {
* as resident (backend default true). * as resident (backend default true).
*/ */
isResident?: boolean isResident?: boolean
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
isDeaf?: boolean | null
} }
/** Payload for POST /gerbils. */ /** Payload for POST /gerbils. */
@@ -76,6 +78,7 @@ export interface CreateGerbil {
characterTraits?: string[] | null characterTraits?: string[] | null
characterNote?: string | null characterNote?: string | null
isResident?: boolean isResident?: boolean
isDeaf?: boolean | null
} }
/** Payload for PUT /gerbils/{id} (all optional / partial update). */ /** Payload for PUT /gerbils/{id} (all optional / partial update). */

View File

@@ -21,10 +21,12 @@ interface FormState {
enclosureId: string enclosureId: string
litterId: string litterId: string
originContactId: string originContactId: string
originBreeder: string
receiverContactId: string receiverContactId: string
genotype: string genotype: string
notes: string notes: string
isResident: boolean isResident: boolean
isDeaf: boolean | null
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -39,10 +41,12 @@ const EMPTY: FormState = {
enclosureId: '', enclosureId: '',
litterId: '', litterId: '',
originContactId: '', originContactId: '',
originBreeder: '',
receiverContactId: '', receiverContactId: '',
genotype: '', genotype: '',
notes: '', notes: '',
isResident: true, isResident: true,
isDeaf: null,
} }
function formFromGerbil(g: { function formFromGerbil(g: {
@@ -57,10 +61,12 @@ function formFromGerbil(g: {
enclosureId: string | null enclosureId: string | null
litterId: string | null litterId: string | null
originContactId: string | null originContactId: string | null
originBreeder?: string | null
receiverContactId: string | null receiverContactId: string | null
genotype: string | null genotype: string | null
notes: string | null notes: string | null
isResident?: boolean | null isResident?: boolean | null
isDeaf?: boolean | null
}): FormState { }): FormState {
return { return {
name: g.name, name: g.name,
@@ -74,10 +80,12 @@ function formFromGerbil(g: {
enclosureId: g.enclosureId ?? '', enclosureId: g.enclosureId ?? '',
litterId: g.litterId ?? '', litterId: g.litterId ?? '',
originContactId: g.originContactId ?? '', originContactId: g.originContactId ?? '',
originBreeder: g.originBreeder ?? '',
receiverContactId: g.receiverContactId ?? '', receiverContactId: g.receiverContactId ?? '',
genotype: g.genotype ?? '', genotype: g.genotype ?? '',
notes: g.notes ?? '', notes: g.notes ?? '',
isResident: g.isResident ?? true, isResident: g.isResident ?? true,
isDeaf: g.isDeaf ?? null,
} }
} }
@@ -167,10 +175,12 @@ export default function GerbilFormPage() {
enclosureId: nn(form.enclosureId), enclosureId: nn(form.enclosureId),
litterId: nn(form.litterId), litterId: nn(form.litterId),
originContactId: nn(form.originContactId), originContactId: nn(form.originContactId),
originBreeder: nn(form.originBreeder),
receiverContactId: nn(form.receiverContactId), receiverContactId: nn(form.receiverContactId),
genotype: nn(form.genotype), genotype: nn(form.genotype),
notes: nn(form.notes), notes: nn(form.notes),
isResident: form.isResident, isResident: form.isResident,
isDeaf: form.isDeaf,
} }
const result = await mutation.run(body) const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`) if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -326,6 +336,16 @@ export default function GerbilFormPage() {
</select> </select>
</label> </label>
<label className="field">
<span>{t.fields.originBreeder}</span>
<input
className="input"
value={form.originBreeder}
placeholder="z. B. Zucht der Kleinen Chaoten"
onChange={(e) => set('originBreeder', e.target.value)}
/>
</label>
<label className="field"> <label className="field">
<span>{t.fields.receiver}</span> <span>{t.fields.receiver}</span>
<select <select
@@ -360,6 +380,21 @@ export default function GerbilFormPage() {
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} /> <textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label> </label>
<label className="field">
<span>{t.fields.isDeaf}</span>
<select
value={form.isDeaf === null ? '' : String(form.isDeaf)}
onChange={(e) => {
const v = e.target.value
set('isDeaf', v === '' ? null : v === 'true')
}}
>
<option value="">{t.form.isDeafUnknown}</option>
<option value="true">{t.form.isDeafYes}</option>
<option value="false">{t.form.isDeafNo}</option>
</select>
</label>
<label className="field field--check" title={t.form.isResidentHint}> <label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span> <span>{t.form.isResidentLabel}</span>
<input <input

View File

@@ -276,7 +276,7 @@ export default function StammbaumPage() {
<> <>
<section className="page stammbaum-screen"> <section className="page stammbaum-screen">
<header className="page-head"> <header className="page-head">
<h2>{t.titleFor(root.gerbil.name)}</h2> <h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h2>
<div className="head-actions"> <div className="head-actions">
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn"> <Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
{t.backToAnimal} {t.backToAnimal}
@@ -377,7 +377,7 @@ function PedigreeCard({
<div className="pedigree-card__body"> <div className="pedigree-card__body">
<div className="pedigree-card__name"> <div className="pedigree-card__name">
<SexIcon gender={g.gender} /> <SexIcon gender={g.gender} />
<span className="pedigree-card__nametext">{g.name}</span> <span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
</div> </div>
{farbschlag && ( {farbschlag && (
<span <span
@@ -439,7 +439,7 @@ function PrintPedigree({
return ( return (
<div className="stammbaum-print"> <div className="stammbaum-print">
<header className="stammbaum-print__head"> <header className="stammbaum-print__head">
<h1>{t.titleFor(root.gerbil.name)}</h1> <h1>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h1>
<p className="stammbaum-print__meta"> <p className="stammbaum-print__meta">
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today} {t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
</p> </p>
@@ -494,7 +494,7 @@ function PrintCell({
<div className={base} style={style}> <div className={base} style={style}>
<div className="stammbaum-print__name"> <div className="stammbaum-print__name">
{sexSymbol} {sexSymbol}
{g.name} {g.name || de.pages.gerbils.nameless}
</div> </div>
{g.dateOfBirth && ( {g.dateOfBirth && (
<div className="stammbaum-print__sub"> <div className="stammbaum-print__sub">

View File

@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
checked={selectedIds.has(g.id)} checked={selectedIds.has(g.id)}
onChange={() => toggleAnimal(g.id)} onChange={() => toggleAnimal(g.id)}
/> />
<span className="wizard-pick__name">{g.name}</span> <span className="wizard-pick__name">{g.name || de.pages.gerbils.nameless}</span>
<span className="wizard-pick__meta"> <span className="wizard-pick__meta">
{[ {[
genderLabel(g.gender), genderLabel(g.gender),
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
</div> </div>
<div className="def-row"> <div className="def-row">
<dt>{de.pages.vertraege.fields.animals}</dt> <dt>{de.pages.vertraege.fields.animals}</dt>
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd> <dd>{selectedAnimals.map((g) => g.name || de.pages.gerbils.nameless).join(', ')}</dd>
</div> </div>
<div className="def-row"> <div className="def-row">
<dt>{de.pages.vertraege.fields.price}</dt> <dt>{de.pages.vertraege.fields.price}</dt>

View File

@@ -71,9 +71,9 @@ export default function WurfFormPage() {
name: l.name, name: l.name,
date: l.date ?? '', date: l.date ?? '',
fatherId: l.fatherId ?? '', fatherId: l.fatherId ?? '',
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '', fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
motherId: l.motherId ?? '', motherId: l.motherId ?? '',
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '', motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '', totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
expectedGoHomeDate: l.expectedGoHomeDate ?? '', expectedGoHomeDate: l.expectedGoHomeDate ?? '',
notes: l.notes ?? '', notes: l.notes ?? '',
@@ -178,7 +178,7 @@ export default function WurfFormPage() {
) : ( ) : (
<AnimalPicker <AnimalPicker
gender="male" gender="male"
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))} onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name || de.pages.gerbils.nameless }))}
/> />
)} )}
</fieldset> </fieldset>
@@ -199,7 +199,7 @@ export default function WurfFormPage() {
) : ( ) : (
<AnimalPicker <AnimalPicker
gender="female" gender="female"
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))} onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name || de.pages.gerbils.nameless }))}
/> />
)} )}
</fieldset> </fieldset>

View File

@@ -80,6 +80,8 @@ export const de = {
enclosure: 'Gehege', enclosure: 'Gehege',
colorVariety: 'Farbschlag', colorVariety: 'Farbschlag',
origin: 'Herkunft', origin: 'Herkunft',
originBreeder: 'Herkunft (Freitext)',
isDeaf: 'Gehörlos',
receiver: 'Abnehmer', receiver: 'Abnehmer',
genotype: 'Genotyp', genotype: 'Genotyp',
notes: 'Notizen', notes: 'Notizen',
@@ -114,6 +116,9 @@ export const de = {
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne) // BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand', isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.', isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja',
isDeafNo: 'Nein',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',