FEAT: Implement deceased/givenaway enclosure visibility rules, preserve external clan name, and hide receiver fields for deceased gerbils

This commit is contained in:
2026-06-13 01:40:19 +02:00
parent 5732398e60
commit 396d8f05d8
53 changed files with 7431 additions and 274 deletions

View File

@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
import StammbaumPage from './pages/StammbaumPage'
import StatistikPage from './pages/StatistikPage'
import HilfePage from './pages/HilfePage'
import FarbkatalogPage from './pages/FarbkatalogPage'
import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage'
@@ -70,6 +71,7 @@ export default function App() {
<Route path="abgabe" element={<AbgabePage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe" element={<HilfePage />} />
<Route path="farbkatalog" element={<FarbkatalogPage />} />
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
<Route path="vertraege">
<Route index element={<VertraegeListPage />} />

View File

@@ -10,6 +10,8 @@ export interface CreateContact {
phone?: string | null
address?: string | null
notes?: string | null
isBreeder?: boolean
isReceiver?: boolean
}
/** Payload for PUT /contacts/{id}. */

View File

@@ -22,13 +22,15 @@ export async function listEnclosures(): Promise<Enclosure[]> {
}
export async function listContacts(): Promise<Contact[]> {
const paged = await api.get<Paged<Contact>>(`${resources.contacts}${ALL}`)
const paged = await api.get<Paged<Contact>>(
`${resources.contacts}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'name' })}`
)
return paged.items
}
export async function listLitters(): Promise<Litter[]> {
const paged = await api.get<Paged<Litter>>(
`${resources.litters}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'date desc' })}`,
`${resources.litters}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'date desc' })}`,
)
return paged.items
}

View File

@@ -60,6 +60,8 @@ export interface Gerbil {
isDeaf?: boolean | null
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
profilePhotoUrl?: string | null
spottingType?: string | null
isCastrated?: boolean
}
/** Payload for POST /gerbils. */
@@ -83,6 +85,8 @@ export interface CreateGerbil {
characterNote?: string | null
isResident?: boolean
isDeaf?: boolean | null
spottingType?: string | null
isCastrated?: boolean
}
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
@@ -108,6 +112,8 @@ export interface Contact {
phone: string | null
address: string | null
notes: string | null
isBreeder: boolean
isReceiver: boolean
}
export interface Litter {

View File

@@ -31,6 +31,7 @@ const SECONDARY: NavItem[] = [
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
{ to: '/farbkatalog', label: de.nav.colorCatalog, icon: '🎨' },
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
]

View File

@@ -0,0 +1,156 @@
.color-picker-container {
position: relative;
width: 100%;
}
.color-picker-trigger {
font: inherit;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
min-height: 44px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.color-picker-trigger:hover {
border-color: var(--color-accent);
}
.color-picker-trigger:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-soft);
}
.color-picker-trigger-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-picker-trigger-name {
font-weight: 500;
}
.color-picker-placeholder {
color: var(--color-text-muted);
}
.color-picker-caret {
color: var(--color-text-muted);
font-size: 0.8rem;
transition: transform 0.15s ease;
}
.color-picker-container[aria-expanded="true"] .color-picker-caret {
transform: rotate(180deg);
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 0.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(59, 48, 38, 0.12);
display: flex;
flex-direction: column;
max-height: 320px;
overflow: hidden;
}
.color-picker-search-wrapper {
padding: 0.5rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.color-picker-search {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 0.9rem;
min-height: 36px;
}
.color-picker-search:focus {
outline: none;
border-color: var(--color-accent);
}
.color-picker-options {
overflow-y: auto;
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 1px;
}
.color-picker-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.4rem 0.6rem;
border: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 0.95rem;
cursor: pointer;
border-radius: 0.375rem;
text-align: left;
min-height: 40px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.color-picker-option:hover {
background: var(--color-accent-soft);
}
.color-picker-option--selected {
background: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 600;
}
.color-picker-option-image-wrapper {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.color-picker-option-image-placeholder {
width: 24px;
height: 24px;
border-radius: 0.25rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
flex-shrink: 0;
}
.color-picker-no-results {
padding: 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}

View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useRef } from 'react'
import { de } from '../strings/de'
import { type ColorVariety } from '../api/types'
import FarbschlagImage from './FarbschlagImage'
import './ColorVarietyPicker.css'
interface ColorVarietyPickerProps {
value: string
onChange: (id: string) => void
colorVarieties: ColorVariety[]
}
export default function ColorVarietyPicker({ value, onChange, colorVarieties }: ColorVarietyPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside of it
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Find currently selected variety
const selectedVariety = colorVarieties.find((cv) => cv.id === value)
// Filter varieties by search query
const filtered = colorVarieties.filter((cv) =>
cv.name.toLowerCase().includes(search.toLowerCase())
)
const handleSelect = (id: string) => {
onChange(id)
setIsOpen(false)
setSearch('')
}
return (
<div className="color-picker-container" ref={containerRef}>
<button
type="button"
className="color-picker-trigger"
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className="color-picker-trigger-content">
{selectedVariety ? (
<>
<FarbschlagImage name={selectedVariety.name} size={24} />
<span className="color-picker-trigger-name">{selectedVariety.name}</span>
</>
) : (
<span className="color-picker-placeholder">{de.pages.gerbils.form.none}</span>
)}
</span>
<span className="color-picker-caret"></span>
</button>
{isOpen && (
<div className="color-picker-dropdown">
<div className="color-picker-search-wrapper">
<input
type="text"
className="color-picker-search"
placeholder="Farbschlag suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="color-picker-options" role="listbox">
<button
type="button"
className={`color-picker-option ${!value ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect('')}
role="option"
aria-selected={!value}
>
<div className="color-picker-option-image-placeholder" />
<span className="color-picker-option-name">{de.pages.gerbils.form.none}</span>
</button>
{filtered.map((cv) => {
const isSelected = cv.id === value
return (
<button
key={cv.id}
type="button"
className={`color-picker-option ${isSelected ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect(cv.id)}
role="option"
aria-selected={isSelected}
>
<div className="color-picker-option-image-wrapper">
<FarbschlagImage name={cv.name} size={24} />
</div>
<span className="color-picker-option-name">{cv.name}</span>
</button>
)
})}
{filtered.length === 0 && (
<div className="color-picker-no-results">
Keine Farbschläge gefunden
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -65,3 +65,54 @@
color: #d97706;
white-space: nowrap;
}
/* Collapsible details styling for the Charakter & Eigenschaften section */
.charakter-details {
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
background: var(--color-surface, #fff);
margin-top: 1.5rem;
margin-bottom: 1.5rem;
overflow: hidden;
}
.charakter-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
background: var(--color-accent-soft, #f9f9f9);
transition: background 0.2s ease;
}
.charakter-summary::-webkit-details-marker {
display: none;
}
.charakter-summary:hover {
background: var(--color-hover, #f5f5f5);
}
.charakter-summary h3 {
margin: 0;
font-size: 1.25rem;
color: var(--color-text, #333);
}
.charakter-chevron {
font-size: 1.1rem;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.charakter-details[open] > .charakter-summary .charakter-chevron {
transform: rotate(180deg);
}
.charakter-body {
padding: 1.25rem;
border-top: 1px solid var(--color-border, #ddd);
}

View File

@@ -73,10 +73,15 @@ export function useInfiniteList<T>(
error: null,
})
// Tracks an in-flight request synchronously so loadMore() can't advance (and
// re-fetch/append) the same page twice if the scroll sentinel fires repeatedly.
const inFlightRef = useRef(false)
useEffect(() => {
let cancelled = false
const page = req.page
const id = `${req.nonce}:${req.key}#${page}`
inFlightRef.current = true
loaderRef
.current(page)
.then((res) => {
@@ -92,6 +97,9 @@ export function useInfiniteList<T>(
.catch((err) => {
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
})
.finally(() => {
if (!cancelled) inFlightRef.current = false
})
return () => {
cancelled = true
}
@@ -109,7 +117,10 @@ export function useInfiniteList<T>(
const error = inFlight ? null : sameQuery ? resolved.error : null
const hasMore = sameQuery && !inFlight && items.length < total
const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), [])
const loadMore = useCallback(() => {
if (inFlightRef.current) return
setReq((r) => ({ ...r, page: r.page + 1 }))
}, [])
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }

View File

@@ -796,10 +796,149 @@ textarea {
}
/* Profilfoto im Detail-Kopf */
.profile-photo {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--color-border);
.profile-header-summary {
display: flex;
align-items: center;
gap: 1.5rem;
}
.profile-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.profile-header-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.profile-photo {
width: 200px;
height: 200px;
border-radius: 1.25rem;
object-fit: cover;
border: 4px solid var(--color-border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-photo:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
}
.form-row-2col {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 0.5rem;
}
.form-row-2col > :first-child {
flex: 2;
min-width: 0;
}
.form-row-2col > :last-child {
flex: 1;
min-width: 0;
min-height: 44px;
display: flex;
align-items: center;
}
.form-group-box {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 1.25rem;
background: var(--color-surface);
box-shadow: inset 0 1px 3px rgba(59, 48, 38, 0.02);
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0.5rem 0 1.25rem 0;
max-width: 32rem; /* match form max-width */
}
.form-group-box__title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
color: var(--color-accent);
margin-bottom: 0.25rem;
border-bottom: 1px dashed var(--color-border);
padding-bottom: 0.5rem;
}
/* Gender Segmented Control / Radio Buttons */
.gender-selector-group {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
max-width: 32rem;
}
.gender-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.gender-btn:hover {
background: var(--color-bg);
border-color: var(--color-text-muted);
}
.gender-btn__icon {
font-size: 1.15rem;
line-height: 1;
}
/* Active styles */
.gender-btn--male.is-active {
border-color: #3b82f6;
background: #eff6ff;
color: #1d4ed8;
box-shadow: 0 0 0 1px #3b82f6;
}
.gender-btn--male.is-active .gender-btn__icon {
color: #2563eb;
}
.gender-btn--female.is-active {
border-color: #ec4899;
background: #fdf2f8;
color: #be185d;
box-shadow: 0 0 0 1px #ec4899;
}
.gender-btn--female.is-active .gender-btn__icon {
color: #db2777;
}
.gender-btn--unknown.is-active {
border-color: #6b7280;
background: #f3f4f6;
color: #374151;
box-shadow: 0 0 0 1px #6b7280;
}
.gender-btn--unknown.is-active .gender-btn__icon {
color: #4b5563;
}

View File

@@ -0,0 +1,81 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { CATALOG, farbschlagImageUrl } from '../genetics'
import './farbkatalog.css'
export default function FarbkatalogPage() {
const [search, setSearch] = useState('')
// Filter color catalog by query
const filtered = CATALOG.filter((variety) => {
const q = search.toLowerCase()
return (
variety.name.toLowerCase().includes(q) ||
(variety.english && variety.english.toLowerCase().includes(q)) ||
variety.canonicalGenotype.toLowerCase().includes(q)
);
})
return (
<section className="page farbkatalog-page">
<header className="page-head">
<div>
<h2>{de.pages.farbkatalog.title}</h2>
<p className="muted">
{de.pages.farbkatalog.subtitle.replace('{count}', String(CATALOG.length))}
</p>
</div>
</header>
<div className="farbkatalog-search-bar">
<input
type="search"
className="input"
placeholder={de.pages.farbkatalog.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="farbkatalog-grid">
{filtered.map((variety) => {
const imgUrl = farbschlagImageUrl(variety.image)
return (
<div key={variety.name} className="farbkatalog-card">
<div className="farbkatalog-card-media">
{imgUrl ? (
<img
src={imgUrl}
alt={variety.name}
className="farbkatalog-card-img"
loading="lazy"
/>
) : (
<div className="farbkatalog-card-media-placeholder">
<span>{variety.name.substring(0, 2).toUpperCase()}</span>
</div>
)}
</div>
<div className="farbkatalog-card-body">
<h4 className="farbkatalog-card-title">{variety.name}</h4>
{variety.english && (
<span className="farbkatalog-card-subtitle">{variety.english}</span>
)}
<div className="farbkatalog-card-genotype">
<code>{variety.canonicalGenotype}</code>
</div>
</div>
</div>
)
})}
{filtered.length === 0 && (
<div className="farbkatalog-empty">
<span className="farbkatalog-empty-icon">🔍</span>
<p>{de.pages.farbkatalog.empty}</p>
</div>
)}
</div>
</section>
)
}

View File

@@ -49,6 +49,7 @@ 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)
const saveCharacter = useMutation(() =>
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
)
@@ -78,6 +79,12 @@ 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])
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
if (gerbil.error || !gerbil.data) {
return (
@@ -91,6 +98,11 @@ export default function GerbilDetailPage() {
}
const g = gerbil.data
const showEnclosure = g.status !== 'Deceased' && (
g.status !== 'GivenAway' ||
(receiverContact ? (receiverContact.isBreeder || receiverContact.isReceiver) : false)
)
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
@@ -100,20 +112,25 @@ export default function GerbilDetailPage() {
setCharInit(g.id)
setCharTraits(g.characterTraits ?? [])
setCharNote(g.characterNote ?? '')
setIsCharacterExpanded(g.status !== 'Deceased' && g.status !== 'GivenAway')
}
return (
<section className="page">
<header className="page-head">
<div>
<div className="profile-header-summary">
<GerbilProfilePhoto gerbilId={g.id} />
<h2>{g.name || de.pages.gerbils.nameless}</h2>
<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 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>
)}
</div>
</div>
</div>
<div className="head-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
@@ -122,7 +139,7 @@ export default function GerbilDetailPage() {
<Link to={`/genetik?parent=${g.id}`} className="btn">
{t.detail.testMating}
</Link>
{g.status !== 'ForSale' && (
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
<button
type="button"
className="btn"
@@ -152,7 +169,15 @@ export default function GerbilDetailPage() {
<h3>{t.detail.masterData}</h3>
<dl className="def-list">
<Row label={t.fields.gender} value={genderLabel(g.gender)} />
<Row
label={t.fields.gender}
value={
<>
{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' && (
@@ -177,7 +202,18 @@ export default function GerbilDetailPage() {
)
}
/>
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
{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={
@@ -194,7 +230,9 @@ export default function GerbilDetailPage() {
: (g.originBreeder || null)
}
/>
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
{g.status !== 'Deceased' && (
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
)}
<Row label={t.fields.notes} value={g.notes || null} />
</dl>
@@ -209,7 +247,7 @@ export default function GerbilDetailPage() {
{geno.farbschlag}
{storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag && (
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="mismatch-hint"> {t.detail.farbschlagMismatch}</small>
)}
</>
@@ -220,27 +258,38 @@ export default function GerbilDetailPage() {
<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>
<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}
/>
<div className="form-actions" style={{ marginTop: '1rem' }}>
<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>
</div>
</details>
<h3>{t.detail.parentLittersTitle}</h3>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}

View File

@@ -3,12 +3,12 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel'
import ColorVarietyPicker from '../components/ColorVarietyPicker'
import '../components/NameSuggestPanel.css'
interface FormState {
@@ -29,11 +29,14 @@ interface FormState {
notes: string
isResident: boolean
isDeaf: boolean | null
spottingType: string
isSchecke: boolean
isCastrated: boolean
}
const EMPTY: FormState = {
name: '',
gender: '',
gender: 'unknown',
status: 'Breeding',
dateOfBirth: '',
dateOfDeath: '',
@@ -49,6 +52,9 @@ const EMPTY: FormState = {
notes: '',
isResident: true,
isDeaf: null,
spottingType: '',
isSchecke: false,
isCastrated: false,
}
function formFromGerbil(g: {
@@ -69,7 +75,11 @@ function formFromGerbil(g: {
notes: string | null
isResident?: boolean | null
isDeaf?: boolean | null
spottingType?: string | null
isCastrated?: boolean | null
}): FormState {
const hasSp = g.genotype?.includes('Sp') || false
const hasSpottingType = Boolean(g.spottingType)
return {
name: g.name,
gender: g.gender,
@@ -88,6 +98,9 @@ function formFromGerbil(g: {
notes: g.notes ?? '',
isResident: g.isResident ?? true,
isDeaf: g.isDeaf ?? null,
spottingType: g.spottingType ?? '',
isSchecke: hasSp || hasSpottingType,
isCastrated: g.isCastrated ?? false,
}
}
@@ -154,7 +167,9 @@ export default function GerbilFormPage() {
function validate(): boolean {
const next: Partial<Record<keyof FormState, string>> = {}
if (form.name.trim() === '') next.name = t.validation.nameRequired
if (form.gender === '') next.gender = t.validation.genderRequired
if (form.gender === 'unknown' || form.gender === '') {
next.gender = t.validation.genderRequired
}
if (!isGenotypeValid(form.genotype)) next.genotype = t.validation.genotypeInvalid
if (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) {
next.dateOfDeath = t.validation.dateOfDeathBeforeBirth
@@ -184,6 +199,8 @@ export default function GerbilFormPage() {
notes: nn(form.notes),
isResident: form.isResident,
isDeaf: form.isDeaf,
spottingType: nn(form.spottingType),
isCastrated: form.isCastrated,
}
const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -192,9 +209,6 @@ export default function GerbilFormPage() {
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
const selectedColorName =
(colorVarieties.data ?? []).find((cv) => cv.id === form.colorVarietyId)?.name ?? null
return (
<section className="page">
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
@@ -229,22 +243,176 @@ export default function GerbilFormPage() {
)}
<label className="field">
<span>{t.fields.gender} *</span>
<select
value={form.gender}
onChange={(e) => set('gender', e.target.value as Gender | '')}
aria-invalid={Boolean(errors.gender)}
>
<span>{t.fields.origin}</span>
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
<option value="">{t.form.none}</option>
{GENDERS.map((g) => (
<option key={g} value={g}>
{genderLabel(g)}
{(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{errors.gender && <small className="error-text">{errors.gender}</small>}
</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>
<div className="field">
<span>{t.fields.gender} *</span>
<div className="gender-selector-group">
{(['male', 'female'] as const).map((g) => {
const active = form.gender === g
let icon = '♂'
let colorClass = 'male'
if (g === 'female') {
icon = '♀'
colorClass = 'female'
}
return (
<button
key={g}
type="button"
className={`gender-btn gender-btn--${colorClass} ${active ? 'is-active' : ''}`}
onClick={() => set('gender', active ? 'unknown' : g)}
>
<span className="gender-btn__icon">{icon}</span>
<span className="gender-btn__label">{genderLabel(g)}</span>
</button>
)
})}
</div>
{errors.gender && <small className="error-text">{errors.gender}</small>}
</div>
<label className="field field--check">
<span>{t.fields.isCastrated}</span>
<input
type="checkbox"
checked={form.isCastrated}
onChange={(e) => set('isCastrated', e.target.checked)}
/>
</label>
<label className="field">
<span>{t.fields.dateOfBirth}</span>
<input
type="date"
className="input"
value={form.dateOfBirth}
onChange={(e) => set('dateOfBirth', e.target.value)}
/>
</label>
<div className="form-group-box">
<div className="form-group-box__title">{de.pages.gerbils.form.colorSectionTitle}</div>
<div className="form-row-2col">
<div className="field">
<span>{t.fields.colorVariety}</span>
<ColorVarietyPicker
value={form.colorVarietyId}
colorVarieties={colorVarieties.data ?? []}
onChange={(newId) => {
set('colorVarietyId', newId)
if (newId) {
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
}
}}
/>
</div>
<label className="field field--check">
<span>{de.pages.gerbils.form.isScheckeLabel}</span>
<input
type="checkbox"
checked={form.isSchecke}
onChange={(e) => {
const checked = e.target.checked
set('isSchecke', checked)
if (!checked) {
set('spottingType', '')
}
}}
/>
</label>
</div>
{form.isSchecke && (
<label className="field">
<span>{t.fields.spottingType}</span>
<select
value={form.spottingType}
onChange={(e) => set('spottingType', e.target.value)}
>
<option value="">{de.pages.gerbils.spottingTypes.none}</option>
{Object.keys(de.pages.gerbils.spottingTypes)
.filter((key) => key !== 'none')
.map((key) => (
<option key={key} value={key}>
{de.pages.gerbils.spottingTypes[key as keyof typeof de.pages.gerbils.spottingTypes]}
</option>
))}
</select>
</label>
)}
<label className="field">
<span>{t.fields.genotype}</span>
<input
className="input"
value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => {
const val = e.target.value
set('genotype', val)
if (val.includes('Sp')) {
set('isSchecke', true)
}
if (val.trim() !== '' && isGenotypeValid(val)) {
try {
const name = genotypeToFarbschlag(fromDisplayString(val))
if (name === UNKNOWN_FARBSCHLAG) {
set('colorVarietyId', '')
} else {
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
set('colorVarietyId', match?.id ?? '')
}
} catch {
// invalid while typing — leave colorVarietyId unchanged
}
}
}}
aria-invalid={Boolean(errors.genotype)}
/>
<small className={errors.genotype ? 'error-text' : 'muted'}>
{errors.genotype ?? t.form.genotypeHint}
</small>
</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>
</div>
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
<div className="field">
@@ -269,16 +437,6 @@ export default function GerbilFormPage() {
</label>
)}
<label className="field">
<span>{t.fields.dateOfBirth}</span>
<input
type="date"
className="input"
value={form.dateOfBirth}
onChange={(e) => set('dateOfBirth', e.target.value)}
/>
</label>
{form.status === 'Deceased' && (
<>
<label className="field">
@@ -315,31 +473,6 @@ export default function GerbilFormPage() {
</label>
)}
<label className="field">
<span>{t.fields.colorVariety}</span>
<span className="farbschlag-value">
<select
value={form.colorVarietyId}
onChange={(e) => {
const newId = e.target.value
set('colorVarietyId', newId)
if (newId) {
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
}
}}
>
<option value="">{t.form.none}</option>
{(colorVarieties.data ?? []).map((cv) => (
<option key={cv.id} value={cv.id}>
{cv.name}
</option>
))}
</select>
{selectedColorName && <FarbschlagImage name={selectedColorName} size={36} />}
</span>
</label>
<label className="field">
<span>{t.fields.enclosure}</span>
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
@@ -364,27 +497,7 @@ export default function GerbilFormPage() {
</select>
</label>
<label className="field">
<span>{t.fields.origin}</span>
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
<option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</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">
<span>{t.fields.receiver}</span>
@@ -393,7 +506,7 @@ export default function GerbilFormPage() {
onChange={(e) => set('receiverContactId', e.target.value)}
>
<option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => (
{(contacts.data ?? []).filter((c) => c.isReceiver || c.id === form.receiverContactId).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
@@ -401,55 +514,16 @@ export default function GerbilFormPage() {
</select>
</label>
<label className="field">
<span>{t.fields.genotype}</span>
<input
className="input"
value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => {
const val = e.target.value
set('genotype', val)
if (val.trim() !== '' && isGenotypeValid(val)) {
try {
const name = genotypeToFarbschlag(fromDisplayString(val))
if (name === UNKNOWN_FARBSCHLAG) {
set('colorVarietyId', '')
} else {
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
set('colorVarietyId', match?.id ?? '')
}
} catch {
// invalid while typing — leave colorVarietyId unchanged
}
}
}}
aria-invalid={Boolean(errors.genotype)}
/>
<small className={errors.genotype ? 'error-text' : 'muted'}>
{errors.genotype ?? t.form.genotypeHint}
</small>
</label>
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</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}>
<span>{t.form.isResidentLabel}</span>

View File

@@ -96,6 +96,17 @@ export default function KontaktDetailPage() {
{deleteError && <div className="alert alert--error">{deleteError}</div>}
<dl className="def-list">
<div className="def-row">
<dt>Rolle</dt>
<dd>
{[
c.isBreeder ? 'Züchter' : null,
c.isReceiver ? 'Abnehmer' : null,
]
.filter(Boolean)
.join(' · ') || '—'}
</dd>
</div>
<div className="def-row">
<dt>{t.fields.email}</dt>
<dd>{c.email ?? '—'}</dd>

View File

@@ -12,9 +12,11 @@ interface FormState {
phone: string
address: string
notes: string
isBreeder: boolean
isReceiver: boolean
}
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' }
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '', isBreeder: false, isReceiver: true }
/** "" -> null, sonst der Wert. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
@@ -40,6 +42,8 @@ export default function KontaktFormPage() {
phone: existing.data.phone ?? '',
address: existing.data.address ?? '',
notes: existing.data.notes ?? '',
isBreeder: existing.data.isBreeder,
isReceiver: existing.data.isReceiver,
})
}
@@ -63,6 +67,8 @@ export default function KontaktFormPage() {
phone: nn(form.phone),
address: nn(form.address),
notes: nn(form.notes),
isBreeder: form.isBreeder,
isReceiver: form.isReceiver,
})
if (result.ok) navigate(`/kontakte/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
@@ -116,6 +122,24 @@ export default function KontaktFormPage() {
<small className="muted">{t.form.addressHint}</small>
</label>
<label className="field field--check">
<span>{t.fields.isBreeder}</span>
<input
type="checkbox"
checked={form.isBreeder}
onChange={(e) => set('isBreeder', e.target.checked)}
/>
</label>
<label className="field field--check">
<span>{t.fields.isReceiver}</span>
<input
type="checkbox"
checked={form.isReceiver}
onChange={(e) => set('isReceiver', e.target.checked)}
/>
</label>
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />

View File

@@ -73,7 +73,39 @@ export default function KontaktePage() {
{items.map((c) => (
<li key={c.id}>
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
<span className="gerbil-card__name">{c.name}</span>
<span className="gerbil-card__name">
{c.name}
{c.isBreeder && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
background: '#e0f2fe',
color: '#0369a1',
fontWeight: 'normal',
}}
>
Züchter
</span>
)}
{c.isReceiver && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
background: '#fef3c7',
color: '#b45309',
fontWeight: 'normal',
}}
>
Abnehmer
</span>
)}
</span>
<span className="gerbil-card__meta">
{[c.phone, c.email].filter(Boolean).join(' · ')}
</span>

View File

@@ -47,7 +47,7 @@ export default function VertragWizardPage() {
const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' })
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const contactItems = useMemo(() => contacts.data?.items ?? [], [contacts.data])
const contactItems = useMemo(() => (contacts.data?.items ?? []).filter((c) => c.isReceiver), [contacts.data])
const filteredContacts = useMemo(() => {
const needle = contactSearch.trim().toLowerCase()
return needle === ''
@@ -62,6 +62,8 @@ export default function VertragWizardPage() {
email: newContact.email.trim() || null,
phone: newContact.phone.trim() || null,
address: newContact.address.trim() || null,
isReceiver: true,
isBreeder: false,
}),
)
async function onCreateContact() {

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listLitters } from '../api/litters'
import { listGerbils } from '../api/gerbils'
import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { andFilter, condition, escapeGridifyValue, type GridifyQuery } from '../api/gridify'
import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels'
@@ -17,7 +17,7 @@ const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc:
function yearOptions(): number[] {
const current = new Date().getFullYear()
return Array.from({ length: 16 }, (_, i) => current - i)
return Array.from({ length: 30 }, (_, i) => current - i)
}
interface Pair {
@@ -47,6 +47,7 @@ function derivePairs(litters: Litter[]): Pair[] {
export default function WuerfeListPage() {
const t = de.pages.litters
const [tab, setTab] = useState<Tab>('litters')
const [search, setSearch] = useState('')
const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc')
const [page, setPage] = useState(1)
@@ -62,6 +63,7 @@ export default function WuerfeListPage() {
// Litters tab (paged, filtered).
const filter = andFilter(
search.trim() && `(name=*${escapeGridifyValue(search)}/i|notes=*${escapeGridifyValue(search)}/i|litterLetter=*${escapeGridifyValue(search)}/i)`,
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
)
@@ -76,6 +78,16 @@ export default function WuerfeListPage() {
)
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
// Search-enabled pairs list (client-side search by parent name)
const filteredPairs = useMemo(() => {
if (!search.trim()) return pairs
const term = search.toLowerCase()
return pairs.filter((p) =>
parentName(p.fatherId).toLowerCase().includes(term) ||
parentName(p.motherId).toLowerCase().includes(term)
)
}, [pairs, search, nameById])
const total = litters.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = litters.data?.items ?? []
@@ -113,8 +125,26 @@ export default function WuerfeListPage() {
{tab === 'litters' && (
<>
<FilterPanel
activeCount={year !== '' ? 1 : 0}
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
searchField={
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
aria-label="Wurf suchen"
/>
}
activeCount={(year !== '' ? 1 : 0) + (search.trim() !== '' ? 1 : 0)}
onReset={() => {
setSearch('')
setYear('')
setSort('dateDesc')
setPage(1)
}}
>
<label className="field">
<span>{t.filterYear}</span>
@@ -202,11 +232,31 @@ export default function WuerfeListPage() {
{tab === 'pairs' && (
<>
<div style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
<input
type="search"
className="input"
style={{ maxWidth: '320px' }}
placeholder="Elternteil suchen …"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Elternteil suchen"
/>
{search && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => setSearch('')}
>
{de.pages.gerbils.filters.reset}
</button>
)}
</div>
{allLitters.loading && <p className="muted">{de.common.loading}</p>}
{!allLitters.loading && pairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{pairs.length > 0 && (
{!allLitters.loading && filteredPairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{filteredPairs.length > 0 && (
<ul className="card-list">
{pairs.map((p) => (
{filteredPairs.map((p) => (
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
<span className="gerbil-card__name">
{parentName(p.fatherId)} × {parentName(p.motherId)}

View File

@@ -0,0 +1,142 @@
.farbkatalog-page {
display: flex;
flex-direction: column;
}
.farbkatalog-search-bar {
margin: 1.5rem 0;
max-width: 36rem;
width: 100%;
}
.farbkatalog-search-bar .input {
box-shadow: 0 2px 8px rgba(59, 48, 38, 0.04);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.farbkatalog-search-bar .input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-soft);
}
.farbkatalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
margin-top: 0.5rem;
}
.farbkatalog-card {
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 2px 6px rgba(59, 48, 38, 0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.farbkatalog-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(59, 48, 38, 0.08);
border-color: var(--color-accent);
}
.farbkatalog-card-media {
position: relative;
width: 100%;
aspect-ratio: 16/11;
background: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-border);
}
.farbkatalog-card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.farbkatalog-card:hover .farbschlag-card-img,
.farbkatalog-card:hover .farbkatalog-card-img {
transform: scale(1.05);
}
.farbkatalog-card-media-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-accent-soft) 0%, #ecd0b9 100%);
color: var(--color-accent);
font-weight: 700;
font-size: 1.5rem;
letter-spacing: 0.1em;
}
.farbkatalog-card-body {
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.farbkatalog-card-title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.25;
}
.farbkatalog-card-subtitle {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.15rem;
display: block;
}
.farbkatalog-card-genotype {
margin-top: auto;
padding-top: 0.75rem;
}
.farbkatalog-card-genotype code {
font-family: monospace;
font-size: 0.72rem;
background: var(--color-bg);
color: var(--color-accent);
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
display: inline-block;
max-width: 100%;
overflow-x: auto;
}
.farbkatalog-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: 0.75rem;
text-align: center;
color: var(--color-text-muted);
}
.farbkatalog-empty-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
}
@media (min-width: 768px) {
.farbkatalog-grid {
gap: 1.25rem;
}
}

View File

@@ -66,6 +66,42 @@
padding: 0.75rem 0;
}
/* Back-to-top: floating round button, above the mobile action bar. */
.rmf-backtotop {
position: fixed;
right: 1rem;
bottom: calc(4.75rem + env(safe-area-inset-bottom));
z-index: 25;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, var(--color-border));
background: var(--color-surface);
color: var(--color-accent);
font-size: 1.25rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 6px rgba(61, 46, 35, 0.12), 0 8px 22px rgba(61, 46, 35, 0.12);
display: inline-flex;
align-items: center;
justify-content: center;
animation: gerbilRowIn 0.2s cubic-bezier(0.22, 1, 0.36, 1);
transition: transform 0.12s cubic-bezier(0.22, 1, 0.36, 1), background 0.15s;
}
.rmf-backtotop:hover {
background: var(--color-accent-soft);
}
.rmf-backtotop:active {
transform: scale(0.92);
}
@media (min-width: 768px) {
/* Desktop has a sidebar, not a bottom bar — sit at the normal bottom inset. */
.rmf-backtotop {
bottom: 1.5rem;
}
}
/* ---------- DESIGN (Rennmaus Filter): sticky header that blends out on scroll ----------
The whole header (title + search + filter pills + bar) sticks to the top; the list
scrolls underneath. The title row collapses once a 1px sentinel above the header

View File

@@ -34,6 +34,7 @@ export const de = {
help: 'Hilfe',
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
website: 'Webseite',
colorCatalog: 'Farbkatalog',
},
pages: {
gerbils: {
@@ -46,6 +47,8 @@ export const de = {
searchPlaceholder: 'Name suchen …',
// DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte.
born: 'geb.',
// Zurück-nach-oben-Button (erscheint beim Scrollen der langen Liste).
backToTop: 'Nach oben',
// 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.',
@@ -88,7 +91,9 @@ export const de = {
isDeaf: 'Gehörlos',
receiver: 'Abnehmer',
genotype: 'Genotyp',
isCastrated: 'Kastrat',
notes: 'Notizen',
spottingType: 'Scheckungsart',
},
// Detailseite
detail: {
@@ -128,6 +133,8 @@ export const de = {
isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja',
isDeafNo: 'Nein',
isScheckeLabel: 'Ist eine Schecke',
colorSectionTitle: 'Farbe & Genetik',
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
statusDerivedHint: {
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
@@ -158,6 +165,17 @@ export const de = {
GivenAway: 'Abgegeben',
ForSale: 'Zur Abgabe',
},
spottingTypes: {
none: '— keine Scheckungsart —',
Punktschecke: 'Punktschecke',
Kragenschecke: 'Kragenschecke',
Ansatzschecke: 'Ansatzschecke',
Starkschecke: 'Starkschecke',
Superschecke: 'Superschecke',
'DP (Dark Patch)': 'DP (Dark Patch)',
WP: 'Minimalschecke (WP)',
wp: 'WP',
},
},
// ── FEAT-3 (Kevin): Zucht — Würfe + Zuchtpaare ──
litters: {
@@ -170,6 +188,7 @@ export const de = {
newButton: 'Neuer Wurf',
empty: 'Keine Würfe gefunden.',
countLabel: 'Würfe',
searchPlaceholder: 'Wurf suchen …',
filterYear: 'Jahr',
allYears: 'Alle Jahre',
sort: {
@@ -239,6 +258,12 @@ export const de = {
title: 'Züchter',
comingSoon: 'Die Züchter-Verwaltung entsteht gerade. Bald kannst du hier Züchter erfassen.',
},
farbkatalog: {
title: 'Farbkatalog',
subtitle: 'Übersicht aller {count} registrierten Farbschläge und deren genetischen Formeln.',
searchPlaceholder: 'Nach Name, englischem Begriff oder Genen suchen (z. B. \'Zobel\', \'dd\', \'ee\', \'pp\')...',
empty: 'Keine Farbschläge gefunden, die Ihrer Suche entsprechen.',
},
notFound: {
title: 'Seite nicht gefunden',
message: 'Die angeforderte Seite existiert nicht.',
@@ -570,6 +595,8 @@ export const de = {
phone: 'Telefon',
address: 'Adresse',
notes: 'Notizen',
isBreeder: 'Ist Züchter (Zucht)',
isReceiver: 'Ist Abnehmer (Käufer)',
},
linked: {
title: 'Verknüpfte Tiere',