FEAT: Implement deceased/givenaway enclosure visibility rules, preserve external clan name, and hide receiver fields for deceased gerbils
This commit is contained in:
81
gerbil-manager-web/src/pages/FarbkatalogPage.tsx
Normal file
81
gerbil-manager-web/src/pages/FarbkatalogPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
142
gerbil-manager-web/src/pages/farbkatalog.css
Normal file
142
gerbil-manager-web/src/pages/farbkatalog.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user