Compare commits
3 Commits
ba81f48e0f
...
13ff97a12b
| Author | SHA1 | Date | |
|---|---|---|---|
| 13ff97a12b | |||
| 5d8bbfa93b | |||
| 5b11780074 |
21
gerbil-manager-web/e2e/charakter.spec.ts
Normal file
21
gerbil-manager-web/e2e/charakter.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
@@ -20,6 +20,9 @@ export interface SaleAdAnimal {
|
|||||||
farbschlag: string | null
|
farbschlag: string | null
|
||||||
dateOfBirth: string | null
|
dateOfBirth: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
/** FEAT-14: German trait LABELS (human-readable for the prompt), not keys. */
|
||||||
|
traits?: string[]
|
||||||
|
characterNote?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaleAdRequest {
|
export interface SaleAdRequest {
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export interface Gerbil {
|
|||||||
/** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */
|
/** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
/**
|
||||||
|
* FEAT-14: Charakterbogen. Selected trait KEYS (see de.character.traits).
|
||||||
|
* Optional until FEAT-14a backend exposes the fields; treat absent as [].
|
||||||
|
*/
|
||||||
|
characterTraits?: string[] | null
|
||||||
|
characterNote?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -52,6 +58,8 @@ export interface CreateGerbil {
|
|||||||
receiverContactId?: string | null
|
receiverContactId?: string | null
|
||||||
genotype?: string | null
|
genotype?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
|
characterTraits?: string[] | null
|
||||||
|
characterNote?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
62
gerbil-manager-web/src/components/Charakterbogen.tsx
Normal file
62
gerbil-manager-web/src/components/Charakterbogen.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { de } from '../strings/de'
|
||||||
|
import { ALL_TRAITS } from '../format/traits'
|
||||||
|
import './charakterbogen.css'
|
||||||
|
|
||||||
|
export interface CharakterbogenProps {
|
||||||
|
/** Selected trait keys. */
|
||||||
|
traits: string[]
|
||||||
|
note: string
|
||||||
|
onTraitsChange: (traits: string[]) => void
|
||||||
|
onNoteChange: (note: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
|
||||||
|
* Controlled & reusable: rendered on the animal detail page (persisted) and in
|
||||||
|
* the Abgabe listing composer (feeds the AI sale-text). German labels from
|
||||||
|
* de.character.traits; the stored value is the trait KEY.
|
||||||
|
*/
|
||||||
|
export default function Charakterbogen({
|
||||||
|
traits,
|
||||||
|
note,
|
||||||
|
onTraitsChange,
|
||||||
|
onNoteChange,
|
||||||
|
}: CharakterbogenProps) {
|
||||||
|
const t = de.character
|
||||||
|
const selected = new Set(traits)
|
||||||
|
|
||||||
|
const toggle = (key: string) => {
|
||||||
|
const next = new Set(selected)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
// Preserve the vocabulary order for stable output.
|
||||||
|
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="charakterbogen">
|
||||||
|
<ul className="trait-grid">
|
||||||
|
{ALL_TRAITS.map((tr) => (
|
||||||
|
<li key={tr.key}>
|
||||||
|
<label className="trait-chip">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(tr.key)}
|
||||||
|
onChange={() => toggle(tr.key)}
|
||||||
|
/>
|
||||||
|
<span>{tr.label}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.noteLabel}</span>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
placeholder={t.notePlaceholder}
|
||||||
|
onChange={(e) => onNoteChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
41
gerbil-manager-web/src/components/charakterbogen.css
Normal file
41
gerbil-manager-web/src/components/charakterbogen.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
||||||
|
|
||||||
|
.charakterbogen .trait-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.charakterbogen .trait-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.charakterbogen .trait-grid {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-chip input {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
15
gerbil-manager-web/src/format/traits.ts
Normal file
15
gerbil-manager-web/src/format/traits.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
|
export const ALL_TRAITS = de.character.traits
|
||||||
|
|
||||||
|
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
||||||
|
|
||||||
|
export function traitLabel(key: string): string {
|
||||||
|
return LABEL_BY_KEY.get(key) ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selected keys -> German labels (for display + the AI sale-text prompt). */
|
||||||
|
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
||||||
|
return (keys ?? []).map(traitLabel)
|
||||||
|
}
|
||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -625,6 +625,34 @@ export const de = {
|
|||||||
},
|
},
|
||||||
unknownFarbschlag: 'Unbekannter Farbschlag',
|
unknownFarbschlag: 'Unbekannter Farbschlag',
|
||||||
},
|
},
|
||||||
|
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
|
||||||
|
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
|
||||||
|
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
|
||||||
|
character: {
|
||||||
|
sectionTitle: 'Charakter & Eigenschaften',
|
||||||
|
noteLabel: 'Notizen zum Charakter',
|
||||||
|
notePlaceholder: 'Ein paar Sätze zum Charakter dieses Tieres …',
|
||||||
|
save: 'Charakter speichern',
|
||||||
|
saved: 'Charakter gespeichert.',
|
||||||
|
none: 'Noch keine Eigenschaften ausgewählt.',
|
||||||
|
traits: [
|
||||||
|
{ key: 'zutraulich', label: 'zutraulich' },
|
||||||
|
{ key: 'handzahm', label: 'handzahm' },
|
||||||
|
{ key: 'neugierig', label: 'neugierig' },
|
||||||
|
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
|
||||||
|
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
|
||||||
|
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
||||||
|
{ key: 'verschmust', label: 'verschmust' },
|
||||||
|
{ key: 'eigenstaendig', label: 'eigenständig' },
|
||||||
|
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||||
|
{ key: 'futterfreudig', label: 'futterfreudig' },
|
||||||
|
{ key: 'buddelt', label: 'buddelt gern' },
|
||||||
|
{ key: 'klettert', label: 'klettert gern' },
|
||||||
|
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
||||||
|
{ key: 'vertraeglich', label: 'gut verträglich' },
|
||||||
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
|
],
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Strings = typeof de
|
export type Strings = typeof de
|
||||||
|
|||||||
Reference in New Issue
Block a user