feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll
Detailseite (Rennmausakte): - Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen, Charakter als Chips, getabte Fotos/Gesundheit/Gewicht. - Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen). - Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular). Listen: - Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge, Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button. - Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header. Zucht-Nachname: - Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix". - „Eigene Zucht" ist die Standard-Herkunft neuer Tiere. Weiteres: - Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage). - Toast-Rückmeldungen für alle Speichern-Aktionen. - Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,17 +6,30 @@ import { listLitters as listLittersPaged } from '../api/litters'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||
import type { Gender, GerbilStatus } from '../api/types'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import Charakterbogen from '../components/Charakterbogen'
|
||||
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
|
||||
import GerbilHealthTab from '../components/GerbilHealthTab'
|
||||
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
||||
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
||||
import GerbilWeightTab from '../components/GerbilWeightTab'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
import { useToast } from '../components/toast'
|
||||
import './rennmausakte.css'
|
||||
|
||||
type DetailTab = 'photos' | 'health' | 'weight'
|
||||
|
||||
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
|
||||
const STATUS_COLORS: Record<GerbilStatus, string> = {
|
||||
Breeding: '#5e8c5a',
|
||||
Pet: '#5a7da8',
|
||||
ForSale: '#c2703d',
|
||||
Deceased: '#8a847b',
|
||||
GivenAway: '#b08e5c',
|
||||
}
|
||||
const GENDER_SYMBOL: Record<Gender, string> = { unknown: '?', male: '♂', female: '♀' }
|
||||
|
||||
/** Parse a stored genotype string and derive its normalized form + Farbschlag. */
|
||||
function describeGenotype(genotype: string | null): { display: string; farbschlag: string } | null {
|
||||
if (!genotype || !genotype.trim()) return null
|
||||
@@ -24,22 +37,24 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
|
||||
const g = fromDisplayString(genotype)
|
||||
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
|
||||
} catch {
|
||||
// Unparseable: show the safely-rendered raw value, no derived Farbschlag.
|
||||
return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
|
||||
}
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: ReactNode }) {
|
||||
/** A labelled key/value row inside a card (semantic dt/dd, styled as a grid). */
|
||||
function Kv({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="def-row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{value ?? '—'}</dd>
|
||||
<div className="ak-kv">
|
||||
<dt className="ak-kv-label">{label}</dt>
|
||||
<dd className="ak-kv-value">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GerbilDetailPage() {
|
||||
const t = de.pages.gerbils
|
||||
const gerbilName = useGerbilName()
|
||||
const toast = useToast()
|
||||
const { id = '' } = useParams()
|
||||
const [tab, setTab] = useState<DetailTab>('photos')
|
||||
|
||||
@@ -49,7 +64,9 @@ export default function GerbilDetailPage() {
|
||||
const [charTraits, setCharTraits] = useState<string[]>([])
|
||||
const [charNote, setCharNote] = useState('')
|
||||
const [charInit, setCharInit] = useState<string | null>(null)
|
||||
const [isCharacterExpanded, setIsCharacterExpanded] = useState(true)
|
||||
// Charakter ist v. a. für lebende Bestandstiere relevant → bei Zucht/Liebhaber
|
||||
// aufgeklappt, sonst (Abgabe/Verstorben/Abgegeben) eingeklappt (manuell öffenbar).
|
||||
const [charExpanded, setCharExpanded] = useState(true)
|
||||
const saveCharacter = useMutation(() =>
|
||||
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
|
||||
)
|
||||
@@ -79,11 +96,26 @@ export default function GerbilDetailPage() {
|
||||
[litters.data],
|
||||
)
|
||||
|
||||
const receiverContact = useMemo(() => {
|
||||
const receiverId = gerbil.data?.receiverContactId
|
||||
if (!receiverId || !contacts.data) return null
|
||||
return contacts.data.find((c) => c.id === receiverId) ?? null
|
||||
}, [gerbil.data?.receiverContactId, contacts.data])
|
||||
// Parents come from the gerbil's own litter (litterId → litter.fatherId/motherId).
|
||||
// Fetch each parent gerbil for its name + link (mirrors WurfDetailPage).
|
||||
const ownLitterId = gerbil.data?.litterId ?? null
|
||||
const ownLitter = useMemo(
|
||||
() => (ownLitterId ? ((litters.data ?? []).find((l) => l.id === ownLitterId) ?? null) : null),
|
||||
[litters.data, ownLitterId],
|
||||
)
|
||||
const fatherId = ownLitter?.fatherId ?? null
|
||||
const motherId = ownLitter?.motherId ?? null
|
||||
const father = useApi(() => (fatherId ? getGerbil(fatherId) : Promise.resolve(null)), [fatherId])
|
||||
const mother = useApi(() => (motherId ? getGerbil(motherId) : Promise.resolve(null)), [motherId])
|
||||
|
||||
// Character chips — toggle while preserving the catalog order (stable output).
|
||||
const selectedTraits = new Set(charTraits)
|
||||
const toggleTrait = (key: string) => {
|
||||
const next = new Set(selectedTraits)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
setCharTraits(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||
}
|
||||
|
||||
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (gerbil.error || !gerbil.data) {
|
||||
@@ -98,244 +130,329 @@ export default function GerbilDetailPage() {
|
||||
}
|
||||
|
||||
const g = gerbil.data
|
||||
const showEnclosure = g.status !== 'Deceased' && (
|
||||
g.status !== 'GivenAway' ||
|
||||
(receiverContact ? (receiverContact.isBreeder || receiverContact.isReceiver) : false)
|
||||
)
|
||||
// Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege.
|
||||
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
|
||||
|
||||
const geno = describeGenotype(g.genotype)
|
||||
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||
|
||||
// Seed the Charakterbogen once from the loaded gerbil (adjust-state-during-render).
|
||||
const parentLink = (
|
||||
pid: string | null,
|
||||
p: Parameters<typeof gerbilName>[0] | null,
|
||||
): ReactNode =>
|
||||
pid && p ? (
|
||||
<Link to={`/rennmaeuse/${pid}`}>{gerbilName(p) || de.pages.gerbils.nameless}</Link>
|
||||
) : (
|
||||
de.pages.litters.detail.unknownParent
|
||||
)
|
||||
|
||||
// Seed the character state once from the loaded gerbil (adjust-state-during-render).
|
||||
if (charInit !== g.id) {
|
||||
setCharInit(g.id)
|
||||
setCharTraits(g.characterTraits ?? [])
|
||||
setCharNote(g.characterNote ?? '')
|
||||
setIsCharacterExpanded(g.status !== 'Deceased' && g.status !== 'GivenAway')
|
||||
setCharExpanded(g.status === 'Breeding' || g.status === 'Pet')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div className="profile-header-summary">
|
||||
<GerbilProfilePhoto gerbilId={g.id} />
|
||||
<div className="profile-header-info">
|
||||
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
||||
<div className="profile-header-badges">
|
||||
<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>
|
||||
)}
|
||||
<section className="ak">
|
||||
<div className="ak-hero">
|
||||
<GerbilProfilePhoto
|
||||
gerbilId={g.id}
|
||||
className="ak-herophoto"
|
||||
fallback={
|
||||
<div className="ak-herophoto ak-herophoto--empty" aria-hidden="true">
|
||||
🐭
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="ak-herobar">
|
||||
<Link to="/rennmaeuse" className="ak-back" aria-label={t.detail.back}>
|
||||
‹
|
||||
</Link>
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
||||
<div className="ak-heroover">
|
||||
<h1 className="ak-name">{gerbilName(g) || de.pages.gerbils.nameless}</h1>
|
||||
<span className="ak-statusbadge" style={{ color: STATUS_COLORS[g.status] }}>
|
||||
{statusLabel(g.status)}
|
||||
</span>
|
||||
{g.isResident === false && (
|
||||
<span className="ak-badge-extern" title={de.pages.gerbils.externalTitle}>
|
||||
{de.pages.gerbils.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ak-content">
|
||||
<div className="ak-facts">
|
||||
<span className="ak-fact">
|
||||
<span aria-hidden="true">{GENDER_SYMBOL[g.gender]}</span>
|
||||
{genderLabel(g.gender)}
|
||||
{g.isCastrated ? ' (Kastrat)' : ''}
|
||||
</span>
|
||||
{g.dateOfBirth && (
|
||||
<span className="ak-fact">
|
||||
<span aria-hidden="true">🎂</span>
|
||||
{formatDate(g.dateOfBirth)}
|
||||
</span>
|
||||
)}
|
||||
{storedColorName && (
|
||||
<span className="ak-fact">
|
||||
<span aria-hidden="true">🎨</span>
|
||||
{storedColorName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ak-actions">
|
||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="ak-btn primary">
|
||||
{t.detail.edit}
|
||||
</Link>
|
||||
<Link to={`/genetik?parent=${g.id}`} className="btn">
|
||||
<Link to={`/genetik?parent=${g.id}`} className="ak-btn">
|
||||
{t.detail.testMating}
|
||||
</Link>
|
||||
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="ak-btn">
|
||||
{de.pages.stammbaum.openButton}
|
||||
</Link>
|
||||
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
className="ak-btn"
|
||||
disabled={forSale.pending}
|
||||
onClick={async () => {
|
||||
const r = await forSale.run()
|
||||
if (r.ok) gerbil.reload()
|
||||
if (r.ok) {
|
||||
toast.success(de.common.saved)
|
||||
gerbil.reload()
|
||||
} else {
|
||||
toast.error(r.error)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{de.pages.abgabe.markAction}
|
||||
</button>
|
||||
)}
|
||||
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
|
||||
{de.pages.stammbaum.openButton}
|
||||
</Link>
|
||||
{/* FEAT-13: Abgabe abschließen — Vertrag-Assistent mit diesem Tier vorausgewählt. */}
|
||||
{g.status !== 'Deceased' && g.status !== 'GivenAway' && (
|
||||
<Link to={`/vertraege/neu?tiere=${g.id}`} className="btn">
|
||||
<Link to={`/vertraege/neu?tiere=${g.id}`} className="ak-btn">
|
||||
{de.pages.vertraege.wizard.title}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/rennmaeuse" className="btn">
|
||||
<Link to="/rennmaeuse" className="ak-btn">
|
||||
{t.detail.back}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h3>{t.detail.masterData}</h3>
|
||||
<dl className="def-list">
|
||||
<Row
|
||||
label={t.fields.gender}
|
||||
value={
|
||||
<>
|
||||
<section className="ak-card">
|
||||
<h2 className="ak-h2">{t.detail.masterData}</h2>
|
||||
<dl className="ak-kvlist">
|
||||
<Kv label={t.fields.gender}>
|
||||
{genderLabel(g.gender)}
|
||||
{g.isCastrated && <span style={{ marginLeft: '0.5rem', opacity: 0.8 }}>(Kastrat)</span>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Row label={t.fields.status} value={statusLabel(g.status)} />
|
||||
<Row label={t.fields.dateOfBirth} value={formatDate(g.dateOfBirth)} />
|
||||
{g.status === 'Deceased' && (
|
||||
<>
|
||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
||||
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
|
||||
</>
|
||||
)}
|
||||
{g.status === 'GivenAway' && (
|
||||
<Row label={t.fields.goHomeDate} value={formatDate(g.goHomeDate)} />
|
||||
)}
|
||||
<Row
|
||||
label={t.fields.colorVariety}
|
||||
value={
|
||||
storedColorName ? (
|
||||
<span className="farbschlag-value">
|
||||
<FarbschlagImage name={storedColorName} size={32} />
|
||||
{storedColorName}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)
|
||||
}
|
||||
/>
|
||||
{g.spottingType && (
|
||||
<Row
|
||||
label={t.fields.spottingType}
|
||||
value={
|
||||
de.pages.gerbils.spottingTypes[g.spottingType as keyof typeof de.pages.gerbils.spottingTypes] ||
|
||||
g.spottingType
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showEnclosure && (
|
||||
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
||||
)}
|
||||
<Row
|
||||
label={t.fields.litter}
|
||||
value={
|
||||
g.litterId && litterName.has(g.litterId)
|
||||
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
||||
: lookup(litterName, g.litterId)
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={t.fields.origin}
|
||||
value={
|
||||
g.originContactId && contactName.has(g.originContactId)
|
||||
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
|
||||
: (g.originBreeder || null)
|
||||
}
|
||||
/>
|
||||
{g.status !== 'Deceased' && (
|
||||
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
||||
)}
|
||||
<Row label={t.fields.notes} value={g.notes || null} />
|
||||
</dl>
|
||||
|
||||
<h3>{t.detail.genetics}</h3>
|
||||
{geno ? (
|
||||
<dl className="def-list">
|
||||
<Row label={t.fields.genotype} value={<code>{geno.display}</code>} />
|
||||
<Row
|
||||
label={t.detail.resolvedPrefix}
|
||||
value={
|
||||
{g.isCastrated && <span style={{ marginLeft: '0.4rem', opacity: 0.8 }}>(Kastrat)</span>}
|
||||
</Kv>
|
||||
<Kv label={t.fields.status}>{statusLabel(g.status)}</Kv>
|
||||
<Kv label={t.fields.dateOfBirth}>{formatDate(g.dateOfBirth)}</Kv>
|
||||
{g.status === 'Deceased' && (
|
||||
<>
|
||||
<Kv label={t.fields.dateOfDeath}>{formatDate(g.dateOfDeath)}</Kv>
|
||||
<Kv label={t.fields.causeOfDeath}>{g.causeOfDeath || '—'}</Kv>
|
||||
</>
|
||||
)}
|
||||
{g.status === 'GivenAway' && (
|
||||
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
|
||||
)}
|
||||
<Kv label={t.fields.colorVariety}>
|
||||
{storedColorName ? (
|
||||
<>
|
||||
<FarbschlagImage name={storedColorName} size={22} />
|
||||
{storedColorName}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Kv>
|
||||
{g.spottingType && (
|
||||
<Kv label={t.fields.spottingType}>
|
||||
{de.pages.gerbils.spottingTypes[
|
||||
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
|
||||
] || g.spottingType}
|
||||
</Kv>
|
||||
)}
|
||||
{showEnclosure && (
|
||||
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
|
||||
)}
|
||||
<Kv label={t.fields.litter}>
|
||||
{g.litterId && litterName.has(g.litterId) ? (
|
||||
<Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
||||
) : (
|
||||
lookup(litterName, g.litterId)
|
||||
)}
|
||||
</Kv>
|
||||
{ownLitter && (
|
||||
<>
|
||||
<Kv label={t.detail.parentLittersRoleVater}>{parentLink(fatherId, father.data)}</Kv>
|
||||
<Kv label={t.detail.parentLittersRoleMutter}>{parentLink(motherId, mother.data)}</Kv>
|
||||
</>
|
||||
)}
|
||||
<Kv label={t.fields.origin}>
|
||||
{g.originContactId && contactName.has(g.originContactId) ? (
|
||||
<Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
|
||||
) : (
|
||||
g.originBreeder || '—'
|
||||
)}
|
||||
</Kv>
|
||||
{g.status !== 'Deceased' && (
|
||||
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
|
||||
)}
|
||||
<Kv label={t.fields.notes}>{g.notes || '—'}</Kv>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="ak-card">
|
||||
<h2 className="ak-h2">{t.detail.genetics}</h2>
|
||||
{geno ? (
|
||||
<dl className="ak-kvlist">
|
||||
<Kv label={t.fields.genotype}>
|
||||
<code className="ak-genotype">{geno.display}</code>
|
||||
</Kv>
|
||||
<Kv label={t.detail.resolvedPrefix}>
|
||||
{geno.farbschlag}
|
||||
{storedColorName &&
|
||||
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
||||
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||
<small className="mismatch-hint"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="muted">{t.detail.genotypeNotSet}</p>
|
||||
)}
|
||||
</Kv>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="ak-empty">{t.detail.genotypeNotSet}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details
|
||||
className="charakter-details"
|
||||
open={isCharacterExpanded}
|
||||
onToggle={(e) => setIsCharacterExpanded(e.currentTarget.open)}
|
||||
>
|
||||
<summary className="charakter-summary">
|
||||
<h3>{de.character.sectionTitle}</h3>
|
||||
<span className="charakter-chevron" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div className="charakter-body">
|
||||
<Charakterbogen
|
||||
traits={charTraits}
|
||||
note={charNote}
|
||||
onTraitsChange={setCharTraits}
|
||||
onNoteChange={setCharNote}
|
||||
<section className="ak-card">
|
||||
<button
|
||||
type="button"
|
||||
className="ak-card-toggle"
|
||||
aria-expanded={charExpanded}
|
||||
onClick={() => setCharExpanded((v) => !v)}
|
||||
>
|
||||
<h2 className="ak-h2">{de.character.sectionTitle}</h2>
|
||||
<span className="ak-card-chev" aria-hidden="true">
|
||||
{charExpanded ? '▴' : '▾'}
|
||||
</span>
|
||||
</button>
|
||||
{charExpanded && (
|
||||
<>
|
||||
{TRAIT_CATEGORIES.map((cat) => {
|
||||
const count = cat.traits.filter((tr) => selectedTraits.has(tr.key)).length
|
||||
return (
|
||||
<div className="ak-cgroup" key={cat.category}>
|
||||
<div className="ak-glabel">
|
||||
{cat.category}
|
||||
{count > 0 && <span className="ak-gcount">{count}</span>}
|
||||
</div>
|
||||
<div className="ak-chips">
|
||||
{cat.traits.map((tr) => {
|
||||
const on = selectedTraits.has(tr.key)
|
||||
return (
|
||||
<button
|
||||
key={tr.key}
|
||||
type="button"
|
||||
className={'ak-chip' + (on ? ' on' : '') + (tr.warn ? ' warn' : '')}
|
||||
aria-pressed={on}
|
||||
onClick={() => toggleTrait(tr.key)}
|
||||
>
|
||||
{tr.warn && <span aria-hidden="true">⚠</span>}
|
||||
{tr.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<textarea
|
||||
className="ak-notes"
|
||||
value={charNote}
|
||||
placeholder={de.character.notePlaceholder}
|
||||
onChange={(e) => setCharNote(e.target.value)}
|
||||
/>
|
||||
<div className="form-actions" style={{ marginTop: '1rem' }}>
|
||||
<div className="ak-saverow">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
className="ak-btn primary"
|
||||
disabled={saveCharacter.pending}
|
||||
onClick={async () => {
|
||||
const r = await saveCharacter.run()
|
||||
if (r.ok) gerbil.reload()
|
||||
if (r.ok) {
|
||||
toast.success(de.common.saved)
|
||||
gerbil.reload()
|
||||
} else {
|
||||
toast.error(r.error)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{saveCharacter.pending ? t.form.saving : de.character.save}
|
||||
</button>
|
||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ak-card">
|
||||
<h2 className="ak-h2">{t.detail.parentLittersTitle}</h2>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||
<p className="ak-empty">{t.detail.parentLittersEmpty}</p>
|
||||
)}
|
||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||
<ul className="card-list">
|
||||
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||
<li key={litter.id}>
|
||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{litter.name}</span>
|
||||
<span className="badge badge--active">
|
||||
{litter.fatherId === id
|
||||
? t.detail.parentLittersRoleVater
|
||||
: t.detail.parentLittersRoleMutter}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||
{litter.totalBorn !== null && (
|
||||
<span className="gerbil-card__meta">
|
||||
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ak-card">
|
||||
<h2 className="visually-hidden">{t.detail.moreData}</h2>
|
||||
<div className="ak-tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className={tab === key ? 'ak-tab active' : 'ak-tab'}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{t.detail.tabs[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h3>{t.detail.parentLittersTitle}</h3>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||
)}
|
||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||
<ul className="card-list">
|
||||
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||
<li key={litter.id}>
|
||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{litter.name}</span>
|
||||
<span className="badge badge--active">
|
||||
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||
{litter.totalBorn !== null && (
|
||||
<span className="gerbil-card__meta">
|
||||
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||
<div className="tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className={tab === key ? 'tab tab--active' : 'tab'}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{t.detail.tabs[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tab-panel" role="tabpanel">
|
||||
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
||||
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
||||
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
||||
<div className="ak-tabpanel" role="tabpanel">
|
||||
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
||||
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
||||
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user