FEAT-14b: Charakterbogen on animal detail (persist) + Abgabe composer; feed traits+note into sale-ad; e2e smoke

This commit is contained in:
2026-06-06 09:08:53 +02:00
parent 5b11780074
commit 5d8bbfa93b
3 changed files with 75 additions and 11 deletions

View File

@@ -0,0 +1,21 @@
/** FEAT-14b: Charakterbogen — Eigenschaft umschalten, persistieren, im Abgabe-Komposer wiederfinden. */
import { de, expect, skipUnlessMock, test } from './fixtures'
test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer wiederfinden', async ({
page,
}) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
// Eigenschaft 'neugierig' aktivieren und speichern.
await page.getByRole('checkbox', { name: 'neugierig' }).check()
await page.getByRole('button', { name: de.character.save }).click()
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
await page.goto('/abgabe')
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
})

View File

@@ -7,7 +7,9 @@ import { listGerbilPhotos, photoSrc, type GerbilPhoto } from '../api/photos'
import { generateSaleAd } from '../api/saleAd' import { generateSaleAd } from '../api/saleAd'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { traitLabels } from '../format/traits'
import FarbschlagImage from './FarbschlagImage' import FarbschlagImage from './FarbschlagImage'
import Charakterbogen from './Charakterbogen'
type SaleStatus = 'free' | 'loose' | 'reserved' type SaleStatus = 'free' | 'loose' | 'reserved'
@@ -34,9 +36,13 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
const [status, setStatus] = useState<SaleStatus>('free') const [status, setStatus] = useState<SaleStatus>('free')
const [reservedName, setReservedName] = useState('') const [reservedName, setReservedName] = useState('')
const [tagline, setTagline] = useState('') const [tagline, setTagline] = useState('')
// Per-animal personality text, seeded from notes on first render. // Per-animal character note, seeded from the persisted characterNote (or notes).
const [personality, setPersonality] = useState<Record<string, string>>(() => const [personality, setPersonality] = useState<Record<string, string>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.notes ?? ''])), Object.fromEntries(animals.map((a) => [a.id, a.characterNote ?? a.notes ?? ''])),
)
// Per-animal selected trait keys, seeded from the persisted Charakterbogen.
const [traitsByAnimal, setTraitsByAnimal] = useState<Record<string, string[]>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.characterTraits ?? []])),
) )
const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({}) const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({})
const [hints, setHints] = useState('') const [hints, setHints] = useState('')
@@ -147,7 +153,9 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
name: a.name, name: a.name,
farbschlag: farbschlagOf(a), farbschlag: farbschlagOf(a),
dateOfBirth: a.dateOfBirth, dateOfBirth: a.dateOfBirth,
notes: personality[a.id] ?? a.notes ?? null, notes: a.notes ?? null,
traits: traitLabels(traitsByAnimal[a.id] ?? []),
characterNote: personality[a.id] ?? null,
})), })),
statusLine, statusLine,
hints, hints,
@@ -201,14 +209,12 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''} {a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
</span> </span>
</div> </div>
<label className="field"> <Charakterbogen
<span>{t.listing.personality}</span> traits={traitsByAnimal[a.id] ?? []}
<textarea note={personality[a.id] ?? ''}
value={personality[a.id] ?? ''} onTraitsChange={(tr) => setTraitsByAnimal((s) => ({ ...s, [a.id]: tr }))}
placeholder={t.listing.personalityPlaceholder} onNoteChange={(v) => setPersonality((s) => ({ ...s, [a.id]: v }))}
onChange={(e) => setPersonality((s) => ({ ...s, [a.id]: e.target.value }))}
/> />
</label>
<div className="photo-select"> <div className="photo-select">
{(photosById.get(a.id) ?? []).length === 0 ? ( {(photosById.get(a.id) ?? []).length === 0 ? (
<small className="muted">{t.listing.noPhotos}</small> <small className="muted">{t.listing.noPhotos}</small>

View File

@@ -7,6 +7,7 @@ import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import Charakterbogen from '../components/Charakterbogen'
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto // FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
import GerbilHealthTab from '../components/GerbilHealthTab' import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab' import GerbilPhotosTab from '../components/GerbilPhotosTab'
@@ -43,6 +44,13 @@ export default function GerbilDetailPage() {
const gerbil = useApi(() => getGerbil(id), [id]) const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' })) const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
// FEAT-14: Charakterbogen (persisted on the Gerbil).
const [charTraits, setCharTraits] = useState<string[]>([])
const [charNote, setCharNote] = useState('')
const [charInit, setCharInit] = useState<string | null>(null)
const saveCharacter = useMutation(() =>
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
)
const colorVarieties = useApi(() => listColorVarieties(), []) const colorVarieties = useApi(() => listColorVarieties(), [])
const enclosures = useApi(() => listEnclosures(), []) const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), []) const contacts = useApi(() => listContacts(), [])
@@ -82,6 +90,13 @@ export default function GerbilDetailPage() {
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—') const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
// Seed the Charakterbogen once from the loaded gerbil (adjust-state-during-render).
if (charInit !== g.id) {
setCharInit(g.id)
setCharTraits(g.characterTraits ?? [])
setCharNote(g.characterNote ?? '')
}
return ( return (
<section className="page"> <section className="page">
<header className="page-head"> <header className="page-head">
@@ -181,6 +196,28 @@ export default function GerbilDetailPage() {
<p className="muted">{t.detail.genotypeNotSet}</p> <p className="muted">{t.detail.genotypeNotSet}</p>
)} )}
<h3>{de.character.sectionTitle}</h3>
<Charakterbogen
traits={charTraits}
note={charNote}
onTraitsChange={setCharTraits}
onNoteChange={setCharNote}
/>
<div className="form-actions">
<button
type="button"
className="btn btn--primary"
disabled={saveCharacter.pending}
onClick={async () => {
const r = await saveCharacter.run()
if (r.ok) gerbil.reload()
}}
>
{saveCharacter.pending ? t.form.saving : de.character.save}
</button>
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
<h3 className="visually-hidden">{t.detail.moreData}</h3> <h3 className="visually-hidden">{t.detail.moreData}</h3>
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => ( {(['photos', 'health', 'weight'] as const).map((key) => (