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:
@@ -12,6 +12,7 @@ export interface CreateContact {
|
||||
notes?: string | null
|
||||
isBreeder?: boolean
|
||||
isReceiver?: boolean
|
||||
nameSuffix?: string | null
|
||||
}
|
||||
|
||||
/** Payload for PUT /contacts/{id}. */
|
||||
|
||||
47
gerbil-manager-web/src/api/enclosurePhotos.ts
Normal file
47
gerbil-manager-web/src/api/enclosurePhotos.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/** Fotos für Gehege (Enclosure) — analog zu api/photos, gleiche Dateiablage. */
|
||||
import { API_BASE_URL, ApiError, api } from './client'
|
||||
import { de } from '../strings/de'
|
||||
import { photoSrc, type GerbilPhoto } from './photos'
|
||||
|
||||
/** Identical shape to a gerbil photo (PhotoDto). */
|
||||
export type EnclosurePhoto = GerbilPhoto
|
||||
|
||||
export function listEnclosurePhotos(enclosureId: string): Promise<EnclosurePhoto[]> {
|
||||
return api.get<EnclosurePhoto[]>(`/enclosures/${enclosureId}/photos`)
|
||||
}
|
||||
|
||||
export async function uploadEnclosurePhoto(
|
||||
enclosureId: string,
|
||||
file: File,
|
||||
caption: string | null,
|
||||
): Promise<EnclosurePhoto> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (caption) form.append('caption', caption)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/enclosures/${enclosureId}/photos`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(de.api.errors.network, null)
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
response.status === 404
|
||||
? de.api.errors.notFound
|
||||
: response.status >= 500
|
||||
? de.api.errors.server
|
||||
: de.api.errors.unknown
|
||||
throw new ApiError(message, response.status)
|
||||
}
|
||||
return (await response.json()) as EnclosurePhoto
|
||||
}
|
||||
|
||||
export function deleteEnclosurePhoto(photoId: string): Promise<void> {
|
||||
return api.delete(`/enclosure-photos/${photoId}`)
|
||||
}
|
||||
|
||||
export { photoSrc }
|
||||
@@ -10,6 +10,8 @@ export interface BreederProfile {
|
||||
homepage: string
|
||||
/** Ort der Unterschriftszeile („{Ort}, den {Datum}“). */
|
||||
city: string
|
||||
/** Namens-Anhängsel („Nachname") der eigenen Zucht, z. B. „von den kleinen Chaoten“. */
|
||||
nameSuffix: string
|
||||
}
|
||||
|
||||
export const EMPTY_BREEDER_PROFILE: BreederProfile = {
|
||||
@@ -20,6 +22,7 @@ export const EMPTY_BREEDER_PROFILE: BreederProfile = {
|
||||
email: '',
|
||||
homepage: '',
|
||||
city: '',
|
||||
nameSuffix: '',
|
||||
}
|
||||
|
||||
/** Pflichtangaben für einen brauchbaren Vertrag (Hinweis-Logik der UI). */
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface Contact {
|
||||
notes: string | null
|
||||
isBreeder: boolean
|
||||
isReceiver: boolean
|
||||
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
|
||||
nameSuffix: string | null
|
||||
}
|
||||
|
||||
export interface Litter {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import GerbilIcon from './GerbilIcon'
|
||||
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||
import './appShell.css'
|
||||
|
||||
interface NavItem {
|
||||
@@ -83,7 +84,9 @@ export default function AppShell() {
|
||||
</nav>
|
||||
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
<BreederSuffixProvider>
|
||||
<Outlet />
|
||||
</BreederSuffixProvider>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
17
gerbil-manager-web/src/components/BreederSuffixProvider.tsx
Normal file
17
gerbil-manager-web/src/components/BreederSuffixProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* ZUCHT-SUFFIX: provides the own-Zucht name suffix (from the breeder profile) once
|
||||
* to the whole app. Consumers use `useGerbilName()` from ./breederSuffix.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { getBreederProfile } from '../api/settings'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { OwnSuffixContext } from './breederSuffix'
|
||||
|
||||
export function BreederSuffixProvider({ children }: { children: ReactNode }) {
|
||||
const profile = useApi(() => getBreederProfile(), [])
|
||||
return (
|
||||
<OwnSuffixContext.Provider value={profile.data?.nameSuffix ?? ''}>
|
||||
{children}
|
||||
</OwnSuffixContext.Provider>
|
||||
)
|
||||
}
|
||||
116
gerbil-manager-web/src/components/EnclosurePhotosSection.tsx
Normal file
116
gerbil-manager-web/src/components/EnclosurePhotosSection.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/** Bilder eines Gehges — Galerie-Raster + Upload + Löschen (wie der Tier-Fotos-Tab). */
|
||||
import { useRef, useState, type FormEvent } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import {
|
||||
deleteEnclosurePhoto,
|
||||
listEnclosurePhotos,
|
||||
photoSrc,
|
||||
uploadEnclosurePhoto,
|
||||
} from '../api/enclosurePhotos'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from './toast'
|
||||
|
||||
export default function EnclosurePhotosSection({ enclosureId }: { enclosureId: string }) {
|
||||
const tp = de.pages.tierTabs.photos
|
||||
const toast = useToast()
|
||||
|
||||
const photos = useApi(() => listEnclosurePhotos(enclosureId), [enclosureId])
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [caption, setCaption] = useState('')
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
|
||||
const upload = useMutation((file: File) =>
|
||||
uploadEnclosurePhoto(enclosureId, file, caption.trim() === '' ? null : caption.trim()),
|
||||
)
|
||||
const removal = useMutation((photoId: string) => deleteEnclosurePhoto(photoId))
|
||||
|
||||
async function onUpload(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const file = fileInput.current?.files?.[0]
|
||||
if (!file) {
|
||||
setFileError(tp.validation.fileRequired)
|
||||
return
|
||||
}
|
||||
setFileError(null)
|
||||
const r = await upload.run(file)
|
||||
if (r.ok) {
|
||||
toast.success(de.common.saved)
|
||||
setCaption('')
|
||||
if (fileInput.current) fileInput.current.value = ''
|
||||
photos.reload()
|
||||
} else {
|
||||
toast.error(r.error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(photoId: string) {
|
||||
if (!window.confirm(tp.deleteConfirm)) return
|
||||
const r = await removal.run(photoId)
|
||||
if (r.ok) {
|
||||
toast.success(de.common.deleted)
|
||||
photos.reload()
|
||||
} else {
|
||||
toast.error(r.error)
|
||||
}
|
||||
}
|
||||
|
||||
const items = photos.data ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="form" onSubmit={onUpload} noValidate>
|
||||
<label className="field">
|
||||
<span>{tp.chooseFile}</span>
|
||||
<input ref={fileInput} type="file" accept="image/*" className="input" />
|
||||
{fileError && <small className="error-text">{fileError}</small>}
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{tp.captionLabel}</span>
|
||||
<input className="input" value={caption} onChange={(e) => setCaption(e.target.value)} />
|
||||
</label>
|
||||
{upload.error && <div className="alert alert--error">{upload.error}</div>}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn--primary" disabled={upload.pending}>
|
||||
{upload.pending ? tp.uploading : tp.uploadButton}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{photos.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{photos.error && (
|
||||
<div className="alert alert--error">
|
||||
<span>{photos.error}</span>
|
||||
<button type="button" className="btn" onClick={photos.reload}>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{removal.error && <div className="alert alert--error">{removal.error}</div>}
|
||||
|
||||
{!photos.loading && !photos.error && items.length === 0 && <p className="muted">{tp.empty}</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="photo-grid">
|
||||
{[...items]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((p) => (
|
||||
<li key={p.id} className="photo-card">
|
||||
<img src={photoSrc(p)} alt={p.caption ?? p.fileName} loading="lazy" />
|
||||
<div className="photo-card__bar">
|
||||
<span className="photo-card__caption">{p.caption ?? ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => onDelete(p.id)}
|
||||
disabled={removal.pending}
|
||||
>
|
||||
{de.common.delete}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
/**
|
||||
* FEAT-6: Profilfoto im Detail-Kopf — erstes Foto nach sortOrder.
|
||||
* Selbstständig ladend; rendert nichts, solange es keine Fotos gibt
|
||||
* (oder die Foto-Endpunkte noch nicht live sind).
|
||||
* FEAT-6: Profilfoto — erstes Foto nach sortOrder. Selbstständig ladend.
|
||||
* Ohne Foto rendert es `fallback` (oder nichts).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
|
||||
export default function GerbilProfilePhoto({ gerbilId }: { gerbilId: string }) {
|
||||
interface GerbilProfilePhotoProps {
|
||||
gerbilId: string
|
||||
/** CSS class for the <img> (default: the small detail-header avatar). */
|
||||
className?: string
|
||||
/** Rendered when the gerbil has no photo yet (e.g. a hero placeholder). */
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
export default function GerbilProfilePhoto({
|
||||
gerbilId,
|
||||
className = 'profile-photo',
|
||||
fallback = null,
|
||||
}: GerbilProfilePhotoProps) {
|
||||
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
|
||||
const profile = profilePhoto(photos.data ?? [])
|
||||
if (!profile) return null
|
||||
return (
|
||||
<img
|
||||
className="profile-photo"
|
||||
src={photoSrc(profile)}
|
||||
alt={profile.caption ?? profile.fileName}
|
||||
/>
|
||||
)
|
||||
if (!profile) return <>{fallback}</>
|
||||
return <img className={className} src={photoSrc(profile)} alt={profile.caption ?? profile.fileName} />
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatDate, genderLabel } from '../format/labels'
|
||||
import { traitLabels } from '../format/traits'
|
||||
import FarbschlagImage from './FarbschlagImage'
|
||||
import Charakterbogen from './Charakterbogen'
|
||||
import { useGerbilName } from './breederSuffix'
|
||||
|
||||
type SaleStatus = 'free' | 'loose' | 'reserved'
|
||||
|
||||
@@ -32,6 +33,7 @@ function groupGender(animals: Gerbil[]): string {
|
||||
|
||||
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
||||
const t = de.pages.abgabe
|
||||
const gerbilName = useGerbilName()
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<SaleStatus>('free')
|
||||
const [reservedName, setReservedName] = useState('')
|
||||
@@ -203,7 +205,7 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
|
||||
<div key={a.id} className="group-composer__animal">
|
||||
<div className="group-composer__animal-head">
|
||||
<FarbschlagImage name={farbschlagOf(a)} size={36} />
|
||||
<strong>{a.name}</strong>
|
||||
<strong>{gerbilName(a) || a.name}</strong>
|
||||
<span className="muted">
|
||||
{farbschlagOf(a)} · {genderLabel(a.gender)}
|
||||
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
|
||||
|
||||
43
gerbil-manager-web/src/components/ToastProvider.tsx
Normal file
43
gerbil-manager-web/src/components/ToastProvider.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/** Toast notifications: provider + auto-dismissing toast stack. */
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ToastContext, type ToastApi, type ToastVariant } from './toast'
|
||||
import './toast.css'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const idRef = useRef(0)
|
||||
const remove = useCallback((id: number) => setToasts((ts) => ts.filter((t) => t.id !== id)), [])
|
||||
|
||||
const api = useMemo<ToastApi>(() => {
|
||||
const show = (message: string, variant: ToastVariant = 'info') => {
|
||||
const id = (idRef.current += 1)
|
||||
setToasts((ts) => [...ts, { id, message, variant }])
|
||||
window.setTimeout(() => remove(id), 3500)
|
||||
}
|
||||
return { show, success: (m) => show(m, 'success'), error: (m) => show(m, 'error') }
|
||||
}, [remove])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div className="toast-stack" role="status" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`toast toast--${t.variant}`}
|
||||
onClick={() => remove(t.id)}
|
||||
>
|
||||
{t.message}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
17
gerbil-manager-web/src/components/breederSuffix.ts
Normal file
17
gerbil-manager-web/src/components/breederSuffix.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* ZUCHT-SUFFIX: the own-Zucht name-suffix context + the `useGerbilName()` helper.
|
||||
* Kept separate from the provider component so each module has a single concern
|
||||
* (and satisfies react-refresh's "components-only" rule for .tsx files).
|
||||
*/
|
||||
import { createContext, useContext } from 'react'
|
||||
import { gerbilDisplayName } from '../format/gerbilName'
|
||||
import type { Gerbil } from '../api/types'
|
||||
|
||||
export const OwnSuffixContext = createContext<string>('')
|
||||
|
||||
/** Returns a function that renders a gerbil's display name incl. the Zucht suffix. */
|
||||
export function useGerbilName() {
|
||||
const ownSuffix = useContext(OwnSuffixContext)
|
||||
return (g: Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>): string =>
|
||||
gerbilDisplayName(g, ownSuffix)
|
||||
}
|
||||
55
gerbil-manager-web/src/components/toast.css
Normal file
55
gerbil-manager-web/src/components/toast.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Toast stack: centered above the mobile action bar (normal bottom inset on desktop). */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--color-text);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 1.1rem;
|
||||
box-shadow: 0 6px 24px rgba(43, 33, 25, 0.28);
|
||||
text-align: center;
|
||||
animation: toastIn 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.toast--success {
|
||||
background: #5e8c5a;
|
||||
}
|
||||
.toast--error {
|
||||
background: #a6433a;
|
||||
}
|
||||
.toast--info {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.toast-stack {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
19
gerbil-manager-web/src/components/toast.ts
Normal file
19
gerbil-manager-web/src/components/toast.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Toast notifications: the context + `useToast()` hook (kept separate from the
|
||||
* provider component so each module has a single concern / fast-refresh stays happy). */
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
export interface ToastApi {
|
||||
show: (message: string, variant?: ToastVariant) => void
|
||||
success: (message: string) => void
|
||||
error: (message: string) => void
|
||||
}
|
||||
|
||||
const NOOP: ToastApi = { show: () => {}, success: () => {}, error: () => {} }
|
||||
|
||||
export const ToastContext = createContext<ToastApi>(NOOP)
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return useContext(ToastContext)
|
||||
}
|
||||
24
gerbil-manager-web/src/format/gerbilName.ts
Normal file
24
gerbil-manager-web/src/format/gerbilName.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* ZUCHT-SUFFIX: a gerbil's display name with the own-Zucht "suffix" (like a last
|
||||
* name) appended — "Danako" + "von den kleinen Chaoten" → "Danako von den kleinen
|
||||
* Chaoten". Per the chosen rule, only OWN-bred animals get the suffix; animals from
|
||||
* other breeders keep their plain name.
|
||||
*/
|
||||
import type { Gerbil } from '../api/types'
|
||||
|
||||
type NameParts = Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>
|
||||
|
||||
/** Own-bred = no external origin recorded (no origin contact, no freetext breeder). */
|
||||
export function isOwnBred(g: NameParts): boolean {
|
||||
return !g.originContactId && !(g.originBreeder && g.originBreeder.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Name + own-Zucht suffix for own-bred animals; plain name otherwise.
|
||||
* Returns '' for a nameless animal (callers fall back to the "(ohne Namen)" label).
|
||||
*/
|
||||
export function gerbilDisplayName(g: NameParts, ownSuffix: string): string {
|
||||
if (!g.name) return ''
|
||||
const suffix = ownSuffix.trim()
|
||||
return suffix && isOwnBred(g) ? `${g.name} ${suffix}` : g.name
|
||||
}
|
||||
@@ -348,13 +348,65 @@ textarea {
|
||||
border: 1px dashed #bbb;
|
||||
}
|
||||
|
||||
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
|
||||
/* Checkbox-style filter (label text + toggle on one row), vs. the stacked .field. */
|
||||
.field--check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Toggle-switch styling for boolean checkboxes + the multi-select pickers
|
||||
(mobile-friendlier than a checkbox). The element stays a real <input
|
||||
type="checkbox"> — role, keyboard and form semantics are unchanged. */
|
||||
.field--check input[type='checkbox'],
|
||||
.wizard-pick input[type='checkbox'],
|
||||
.namegen-usages-grid input[type='checkbox'] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.45rem;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.field--check input[type='checkbox']::after,
|
||||
.wizard-pick input[type='checkbox']::after,
|
||||
.namegen-usages-grid input[type='checkbox']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: calc(1.45rem - 4px);
|
||||
height: calc(1.45rem - 4px);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(61, 46, 35, 0.35);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.field--check input[type='checkbox']:checked,
|
||||
.wizard-pick input[type='checkbox']:checked,
|
||||
.namegen-usages-grid input[type='checkbox']:checked {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
.field--check input[type='checkbox']:checked::after,
|
||||
.wizard-pick input[type='checkbox']:checked::after,
|
||||
.namegen-usages-grid input[type='checkbox']:checked::after {
|
||||
transform: translateX(calc(2.5rem - 1.45rem));
|
||||
}
|
||||
.field--check input[type='checkbox']:focus-visible,
|
||||
.wizard-pick input[type='checkbox']:focus-visible,
|
||||
.namegen-usages-grid input[type='checkbox']:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.gerbil-card {
|
||||
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
import { installUnhandledRejectionGuard } from './dev/unhandledRejectionGuard'
|
||||
|
||||
installUnhandledRejectionGuard()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { Gerbil } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||
import GroupComposer from '../components/GroupComposer'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
import './abgabe.css'
|
||||
|
||||
const UNGROUPED = 'ungrouped'
|
||||
|
||||
export default function AbgabePage() {
|
||||
const t = de.pages.abgabe
|
||||
const gerbilName = useGerbilName()
|
||||
|
||||
const forSale = useApi(
|
||||
() =>
|
||||
@@ -82,7 +84,7 @@ export default function AbgabePage() {
|
||||
<ul className="abgabe-roster__list">
|
||||
{animals.map((a) => (
|
||||
<li key={a.id}>
|
||||
<span>{a.name}</span>
|
||||
<span>{gerbilName(a) || a.name}</span>
|
||||
<select
|
||||
value={assignment(a)}
|
||||
onChange={(e) => moveTo(a.id, e.target.value)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { listGerbils } from '../api/gerbils'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { condition } from '../api/gridify'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
|
||||
|
||||
export default function BeckenDetailPage() {
|
||||
const t = de.pages.becken
|
||||
@@ -109,6 +110,9 @@ export default function BeckenDetailPage() {
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<h3>{t.photosTitle}</h3>
|
||||
<EnclosurePhotosSection enclosureId={e.id} />
|
||||
|
||||
<h3>{t.occupancy.title}</h3>
|
||||
{occupants.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{occupants.error && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createEnclosure, getEnclosure, updateEnclosure, type CreateEnclosure } from '../api/enclosures'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
@@ -18,6 +19,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
export default function BeckenFormPage() {
|
||||
const t = de.pages.becken
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -45,8 +47,13 @@ export default function BeckenFormPage() {
|
||||
}
|
||||
setErrors({})
|
||||
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
||||
if (result.ok) navigate(`/gehege/${result.value.id}`)
|
||||
// On failure mutation.error drives the inline alert; run() never throws.
|
||||
if (result.ok) {
|
||||
toast.success(de.common.saved)
|
||||
navigate(`/gehege/${result.value.id}`)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
// On failure mutation.error also drives the inline alert; run() never throws.
|
||||
}
|
||||
|
||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
type BreederProfile,
|
||||
} from '../api/settings'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
// EXPORT-1 (Oscar): Datenexport-Karte
|
||||
import DatenexportCard from '../components/DatenexportCard'
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const t = de.pages.einstellungen
|
||||
const tz = t.zuchtprofil
|
||||
const toast = useToast()
|
||||
|
||||
const [form, setForm] = useState<BreederProfile>(EMPTY_BREEDER_PROFILE)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
@@ -40,7 +42,12 @@ export default function EinstellungenPage() {
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const result = await mutation.run(form)
|
||||
if (result.ok) setSaved(true)
|
||||
if (result.ok) {
|
||||
setSaved(true)
|
||||
toast.success(de.common.saved)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||
@@ -81,6 +88,7 @@ export default function EinstellungenPage() {
|
||||
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
{field('zuchtName', tz.fields.zuchtName)}
|
||||
{field('nameSuffix', tz.fields.nameSuffix, tz.fields.nameSuffixHint)}
|
||||
{field('name', tz.fields.name, tz.fields.nameHint)}
|
||||
{field('address', tz.fields.address, tz.fields.addressHint)}
|
||||
{field('city', tz.fields.city)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -3,12 +3,14 @@ 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 { getBreederProfile } from '../api/settings'
|
||||
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 NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import ColorVarietyPicker from '../components/ColorVarietyPicker'
|
||||
import { useToast } from '../components/toast'
|
||||
import '../components/NameSuggestPanel.css'
|
||||
|
||||
interface FormState {
|
||||
@@ -120,6 +122,7 @@ function isGenotypeValid(genotype: string): boolean {
|
||||
export default function GerbilFormPage() {
|
||||
const t = de.pages.gerbils
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -133,6 +136,9 @@ export default function GerbilFormPage() {
|
||||
const enclosures = useApi(() => listEnclosures(), [])
|
||||
const contacts = useApi(() => listContacts(), [])
|
||||
const litters = useApi(() => listLitters(), [])
|
||||
// Own Zucht is the default Herkunft (empty origin = own-bred); label it with the
|
||||
// breeder profile's Zuchtname so new gerbils default to the own cattery.
|
||||
const breederProfile = useApi(() => getBreederProfile(), [])
|
||||
|
||||
// Prefill from the loaded gerbil once (edit mode). React's "adjust state
|
||||
// during render" pattern — runs without an effect and re-renders immediately.
|
||||
@@ -203,7 +209,12 @@ export default function GerbilFormPage() {
|
||||
isCastrated: form.isCastrated,
|
||||
}
|
||||
const result = await mutation.run(body)
|
||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||
if (result.ok) {
|
||||
toast.success(de.common.saved)
|
||||
navigate(`/rennmaeuse/${result.value.id}`)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
// On failure mutation.error drives the inline alert; run() never throws.
|
||||
}
|
||||
|
||||
@@ -245,7 +256,11 @@ export default function GerbilFormPage() {
|
||||
<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>
|
||||
<option value="">
|
||||
{breederProfile.data?.zuchtName?.trim()
|
||||
? `${t.form.ownBreeding} — ${breederProfile.data.zuchtName.trim()}`
|
||||
: t.form.ownBreeding}
|
||||
</option>
|
||||
{(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -473,17 +488,20 @@ export default function GerbilFormPage() {
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.enclosure}</span>
|
||||
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
|
||||
<option value="">{t.form.none}</option>
|
||||
{(enclosures.data ?? []).map((en) => (
|
||||
<option key={en.id} value={en.id}>
|
||||
{en.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege. */}
|
||||
{form.status !== 'GivenAway' && form.status !== 'Deceased' && (
|
||||
<label className="field">
|
||||
<span>{t.fields.enclosure}</span>
|
||||
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
|
||||
<option value="">{t.form.none}</option>
|
||||
{(enclosures.data ?? []).map((en) => (
|
||||
<option key={en.id} value={en.id}>
|
||||
{en.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.litter}</span>
|
||||
|
||||
Binary file not shown.
@@ -5,6 +5,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createContact, getContact, updateContact, type CreateContact } from '../api/contacts'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
@@ -14,9 +15,19 @@ interface FormState {
|
||||
notes: string
|
||||
isBreeder: boolean
|
||||
isReceiver: boolean
|
||||
nameSuffix: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '', isBreeder: false, isReceiver: true }
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
notes: '',
|
||||
isBreeder: false,
|
||||
isReceiver: true,
|
||||
nameSuffix: '',
|
||||
}
|
||||
|
||||
/** "" -> null, sonst der Wert. */
|
||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
@@ -24,6 +35,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
export default function KontaktFormPage() {
|
||||
const t = de.pages.kontakte
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -44,6 +56,7 @@ export default function KontaktFormPage() {
|
||||
notes: existing.data.notes ?? '',
|
||||
isBreeder: existing.data.isBreeder,
|
||||
isReceiver: existing.data.isReceiver,
|
||||
nameSuffix: existing.data.nameSuffix ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,9 +82,15 @@ export default function KontaktFormPage() {
|
||||
notes: nn(form.notes),
|
||||
isBreeder: form.isBreeder,
|
||||
isReceiver: form.isReceiver,
|
||||
nameSuffix: form.isBreeder ? nn(form.nameSuffix) : null,
|
||||
})
|
||||
if (result.ok) navigate(`/kontakte/${result.value.id}`)
|
||||
// On failure mutation.error drives the inline alert; run() never throws.
|
||||
if (result.ok) {
|
||||
toast.success(de.common.saved)
|
||||
navigate(`/kontakte/${result.value.id}`)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
// On failure mutation.error also drives the inline alert; run() never throws.
|
||||
}
|
||||
|
||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||
@@ -140,6 +159,18 @@ export default function KontaktFormPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{form.isBreeder && (
|
||||
<label className="field">
|
||||
<span>{t.fields.nameSuffix}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.nameSuffix}
|
||||
onChange={(e) => set('nameSuffix', e.target.value)}
|
||||
/>
|
||||
<small className="muted">{t.fields.nameSuffixHint}</small>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.notes}</span>
|
||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||
|
||||
@@ -1,56 +1,126 @@
|
||||
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
|
||||
import { useState } from 'react'
|
||||
/** FEAT-2: Kontakte-Liste — Quick-Chip-Filter (Rolle), Sticky-Header + Infinite Scroll. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listContactsPaged } from '../api/contacts'
|
||||
import { condition } from '../api/gridify'
|
||||
import { andFilter, condition } from '../api/gridify'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
import {
|
||||
GerbilFilterBar,
|
||||
ActiveFilterChips,
|
||||
type FilterCategory,
|
||||
} from '../components/GerbilFilterBar'
|
||||
// Reuse the Rennmäuse list's sticky header + card styles for a consistent look.
|
||||
import './gerbils.css'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function KontaktePage() {
|
||||
const t = de.pages.kontakte
|
||||
const [search, setSearch] = useState('')
|
||||
// Role filter: 'breeder' (Züchter) / 'receiver' (Abnehmer); multi-select.
|
||||
const [roles, setRoles] = useState<string[]>([])
|
||||
// Sticky header: collapse the title block once scrolled (IntersectionObserver sentinel).
|
||||
const [condensed, setCondensed] = useState(false)
|
||||
const topSentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
const el = topSentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver(([entry]) => setCondensed(!entry.isIntersecting))
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
const categories: FilterCategory[] = [
|
||||
{
|
||||
key: 'rolle',
|
||||
label: t.filters.role,
|
||||
options: [
|
||||
{ value: 'breeder', label: t.filters.breeder },
|
||||
{ value: 'receiver', label: t.filters.receiver },
|
||||
],
|
||||
},
|
||||
]
|
||||
const selected = { rolle: roles }
|
||||
const toggleRole = (_key: string, value: string) =>
|
||||
setRoles((r) => (r.includes(value) ? r.filter((v) => v !== value) : [...r, value]))
|
||||
const removeRole = (_key: string, value: string) => setRoles((r) => r.filter((v) => v !== value))
|
||||
const clearAll = () => setRoles([])
|
||||
|
||||
// Role conditions map to different fields, so they're OR'd into one group here
|
||||
// (rather than via orGroup which assumes a single field).
|
||||
const roleConds = [
|
||||
roles.includes('breeder') && condition({ field: 'isBreeder', op: '==', value: true }),
|
||||
roles.includes('receiver') && condition({ field: 'isReceiver', op: '==', value: true }),
|
||||
].filter((c): c is string => Boolean(c))
|
||||
const roleFilter =
|
||||
roleConds.length === 0 ? '' : roleConds.length === 1 ? roleConds[0] : `(${roleConds.join('|')})`
|
||||
const filter = andFilter(
|
||||
search.trim() && condition({ field: 'name', op: 'contains', value: search.trim() }),
|
||||
roleFilter,
|
||||
)
|
||||
|
||||
const filter = search.trim()
|
||||
? condition({ field: 'name', op: 'contains', value: search.trim() })
|
||||
: undefined
|
||||
// Infinite scroll: one page at a time, accumulated. The trailing `id` makes the
|
||||
// ordering a total order so OFFSET pagination never repeats/omits a row.
|
||||
const list = useInfiniteList(
|
||||
(page) => listContactsPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
`${filter ?? ''}`,
|
||||
(page) =>
|
||||
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
filter,
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(list)
|
||||
const { items, total, hasMore, loading, loadingMore } = list
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{!loading && (
|
||||
<p className="muted">
|
||||
{total} {t.countLabel}
|
||||
</p>
|
||||
<section className="page gerbils-page">
|
||||
<div ref={topSentinelRef} className="gerbils-scrolltop" aria-hidden="true" />
|
||||
|
||||
<div className="gerbils-topbar">
|
||||
<header className={`page-head gerbils-head${condensed ? ' gerbils-head--condensed' : ''}`}>
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{!loading && (
|
||||
<p className="muted">
|
||||
{total} {t.countLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/kontakte/neu" className="btn btn--primary">
|
||||
+ {t.newButton}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="rmf-searchbar">
|
||||
<span className="rmf-search-ico" aria-hidden="true">
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="rmf-searchfield"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="rmf-search-clear"
|
||||
onClick={() => setSearch('')}
|
||||
aria-label={de.filterBar.searchClear}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/kontakte/neu" className="btn btn--primary">
|
||||
+ {t.newButton}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="filters">
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
<GerbilFilterBar categories={categories} selected={selected} onToggle={toggleRole} />
|
||||
</div>
|
||||
|
||||
<ActiveFilterChips
|
||||
categories={categories}
|
||||
selected={selected}
|
||||
onRemove={removeRole}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
|
||||
{loading && <p className="muted">{de.common.loading}</p>}
|
||||
{list.error && (
|
||||
<div className="alert alert--error">
|
||||
@@ -66,7 +136,7 @@ export default function KontaktePage() {
|
||||
{items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{items.map((c) => (
|
||||
<li key={c.id}>
|
||||
<li key={c.id} className="gerbil-row">
|
||||
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">
|
||||
{c.name}
|
||||
@@ -82,7 +152,7 @@ export default function KontaktePage() {
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
Züchter
|
||||
{t.filters.breeder}
|
||||
</span>
|
||||
)}
|
||||
{c.isReceiver && (
|
||||
@@ -97,7 +167,7 @@ export default function KontaktePage() {
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
Abnehmer
|
||||
{t.filters.receiver}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { de } from '../strings/de'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
|
||||
import type { Litter } from '../api/types'
|
||||
import type { Gerbil, Litter } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
import { formatDate } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -53,14 +54,19 @@ export default function WuerfeListPage() {
|
||||
const [year, setYear] = useState('')
|
||||
const [sort, setSort] = useState<SortKey>('dateDesc')
|
||||
|
||||
// Parent names (shared by both tabs).
|
||||
// Parent names (shared by both tabs) — keep the full gerbil so the Zucht suffix applies.
|
||||
const gerbilName = useGerbilName()
|
||||
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||
const nameById = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name)
|
||||
const gerbilById = useMemo(() => {
|
||||
const map = new Map<string, Gerbil>()
|
||||
for (const g of gerbils.data?.items ?? []) map.set(g.id, g)
|
||||
return map
|
||||
}, [gerbils.data])
|
||||
const parentName = (id: string | null) => (id ? (nameById.get(id) ?? t.detail.unknownParent) : '—')
|
||||
const parentName = (id: string | null): string => {
|
||||
if (!id) return '—'
|
||||
const gp = gerbilById.get(id)
|
||||
return gp ? gerbilName(gp) || t.detail.unknownParent : t.detail.unknownParent
|
||||
}
|
||||
|
||||
// Litters tab (paged, filtered).
|
||||
const filter = andFilter(
|
||||
@@ -90,7 +96,7 @@ export default function WuerfeListPage() {
|
||||
parentName(p.fatherId).toLowerCase().includes(term) ||
|
||||
parentName(p.motherId).toLowerCase().includes(term)
|
||||
)
|
||||
}, [pairs, search, nameById])
|
||||
}, [pairs, search, gerbilById])
|
||||
|
||||
const items = litters.items
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isValidGenotype } from '../format/genotypeText'
|
||||
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
||||
import BreedingResultView from '../components/BreedingResultView'
|
||||
import GerbilIcon from '../components/GerbilIcon'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
import './wuerfe.css'
|
||||
|
||||
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
|
||||
@@ -23,6 +24,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
||||
receiver: string | null
|
||||
tJf: JuvenileFields
|
||||
}) {
|
||||
const gerbilName = useGerbilName()
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const rawUrl = g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
@@ -46,7 +48,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
||||
)}
|
||||
</div>
|
||||
<div className="juvenile-body">
|
||||
<span className="gerbil-card__name">{g.name}</span>
|
||||
<span className="gerbil-card__name">{gerbilName(g)}</span>
|
||||
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
|
||||
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
|
||||
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
|
||||
@@ -60,6 +62,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
||||
|
||||
export default function WurfDetailPage() {
|
||||
const t = de.pages.litters
|
||||
const gerbilName = useGerbilName()
|
||||
const { id = '' } = useParams()
|
||||
|
||||
const litter = useApi(() => getLitter(id), [id])
|
||||
@@ -137,8 +140,8 @@ export default function WurfDetailPage() {
|
||||
|
||||
const l = litter.data
|
||||
const registered = juveniles.data?.totalCount ?? 0
|
||||
const parentLink = (id: string | null, g: { name: string } | null) =>
|
||||
id && g ? <Link to={`/rennmaeuse/${id}`}>{g.name}</Link> : t.detail.unknownParent
|
||||
const parentLink = (id: string | null, g: Gerbil | null) =>
|
||||
id && g ? <Link to={`/rennmaeuse/${id}`}>{gerbilName(g)}</Link> : t.detail.unknownParent
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLitter, getLitter, updateLitter } from '../api/litters'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import type { CreateLitter } from '../api/types'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
import AnimalPicker from '../components/AnimalPicker'
|
||||
|
||||
interface FormState {
|
||||
@@ -47,6 +48,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
export default function WurfFormPage() {
|
||||
const t = de.pages.litters
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -126,6 +128,7 @@ export default function WurfFormPage() {
|
||||
setSubmitError(null)
|
||||
const result = await mutation.run(body)
|
||||
if (result.ok) {
|
||||
toast.success(de.common.saved)
|
||||
navigate(`/wuerfe/${result.value.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
415
gerbil-manager-web/src/pages/rennmausakte.css
Normal file
415
gerbil-manager-web/src/pages/rennmausakte.css
Normal file
@@ -0,0 +1,415 @@
|
||||
/* DESIGN (Rennmausakte.html): gerbil detail page — hero photo + card sections.
|
||||
Navigation is handled by the app shell (sidebar/bottom bar), so the design's own
|
||||
sidebar/drawer is intentionally dropped. Palette uses the app's design tokens. */
|
||||
|
||||
.ak {
|
||||
--ak-panel: #efe6d4;
|
||||
--ak-tan: #ece0c8;
|
||||
--ak-warn: #c2702a;
|
||||
--ak-danger: #a6433a;
|
||||
--ak-kv-pad: 14px;
|
||||
/* Cancel app-main's horizontal padding so the hero spans full width. app-main has
|
||||
no top padding (see index.css), so the hero already sits flush at the top. */
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.ak {
|
||||
margin: 0 -2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.ak-hero {
|
||||
position: relative;
|
||||
}
|
||||
.ak-herophoto {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
background: var(--ak-panel);
|
||||
}
|
||||
.ak-herophoto--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.ak-herobar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.ak-back {
|
||||
color: #fff;
|
||||
background: rgba(20, 12, 6, 0.42);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ak-heroover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 40px 18px 16px;
|
||||
background: linear-gradient(to top, rgba(20, 12, 6, 0.82), rgba(20, 12, 6, 0.1) 72%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ak-name {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
.ak-statusbadge {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ak-badge-extern {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: #6b5d4b;
|
||||
border: 1px dashed rgba(107, 93, 75, 0.5);
|
||||
}
|
||||
|
||||
.ak-content {
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px 40px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.ak-herophoto {
|
||||
height: 320px;
|
||||
}
|
||||
.ak-heroover {
|
||||
padding: 56px 36px 22px;
|
||||
}
|
||||
.ak-name {
|
||||
font-size: 40px;
|
||||
}
|
||||
.ak-content {
|
||||
padding: 24px 36px 40px;
|
||||
}
|
||||
/* On desktop the app sidebar provides navigation; the hero back arrow is redundant
|
||||
(the action row has "Zurück zur Liste"). */
|
||||
.ak-herobar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Quick facts ---------- */
|
||||
.ak-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.ak-fact {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 8px 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ---------- Actions ---------- */
|
||||
.ak-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
.ak-btn {
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 11px 18px;
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(61, 46, 35, 0.05);
|
||||
transition: transform 0.1s, filter 0.15s, background 0.15s;
|
||||
}
|
||||
.ak-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.ak-btn:hover {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.ak-btn.primary {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.ak-btn.primary:hover {
|
||||
filter: brightness(1.06);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
.ak-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.ak-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
margin-top: 14px;
|
||||
box-shadow: 0 1px 3px rgba(61, 46, 35, 0.06);
|
||||
}
|
||||
.ak-h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Collapsible card header (e.g. Charakter) — full-width toggle button. */
|
||||
.ak-card-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.ak-card-toggle .ak-h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.ak-card-chev {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Key/Value list (semantic dl/dt/dd, styled as a grid) ---------- */
|
||||
.ak-kvlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
.ak-kvlist dt,
|
||||
.ak-kvlist dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ak-kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 32%) 1fr;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: var(--ak-kv-pad) 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.ak-kv:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ak-kv-label {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
.ak-kv-value {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ak-kv-value a {
|
||||
color: var(--color-accent);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.ak-genotype {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
background: var(--ak-tan);
|
||||
color: var(--color-text);
|
||||
padding: 6px 11px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ak-mismatch {
|
||||
color: var(--ak-warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- Character chips ---------- */
|
||||
.ak-cgroup {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ak-cgroup:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.ak-glabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ak-gcount {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.ak-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
.ak-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1.5px solid var(--color-border);
|
||||
transition: background 0.14s, border-color 0.14s, color 0.14s, transform 0.14s
|
||||
cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.ak-chip:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.ak-chip.on {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.ak-chip.warn {
|
||||
border-color: color-mix(in srgb, var(--ak-warn) 45%, var(--color-border));
|
||||
color: var(--ak-warn);
|
||||
}
|
||||
.ak-chip.warn.on {
|
||||
background: var(--ak-warn);
|
||||
border-color: var(--ak-warn);
|
||||
color: #fff;
|
||||
}
|
||||
.ak-notes {
|
||||
width: 100%;
|
||||
min-height: 78px;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
color: var(--color-text);
|
||||
margin-top: 14px;
|
||||
background: #fbf7ef;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
}
|
||||
.ak-notes:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.ak-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 16px;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
/* ---------- Tabs ---------- */
|
||||
.ak-tabs {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.ak-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.ak-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 6px 2px 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ak-tab.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
.ak-tabpanel {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
/* Character save row */
|
||||
.ak-saverow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 14px;
|
||||
}
|
||||
@@ -125,6 +125,8 @@ export const de = {
|
||||
createTitle: 'Neue Rennmaus anlegen',
|
||||
editTitle: 'Rennmaus bearbeiten',
|
||||
none: '— keine Angabe —',
|
||||
// Herkunft-Standardoption: eigene Zucht (leere Herkunft = selbst gezüchtet).
|
||||
ownBreeding: 'Eigene Zucht',
|
||||
genotypeHint:
|
||||
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
||||
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||
@@ -360,6 +362,8 @@ export const de = {
|
||||
name: 'Name',
|
||||
notes: 'Notizen',
|
||||
},
|
||||
// Bilder-Sektion auf der Gehege-Detailseite.
|
||||
photosTitle: 'Bilder',
|
||||
occupancy: {
|
||||
title: 'Bewohner',
|
||||
empty: 'Dieses Gehege ist derzeit leer.',
|
||||
@@ -511,6 +515,9 @@ export const de = {
|
||||
'Noch unvollständig: Name, Adresse und Ort werden für den Vertrag benötigt.',
|
||||
fields: {
|
||||
zuchtName: 'Zuchtname',
|
||||
nameSuffix: 'Namens-Anhängsel der Zucht',
|
||||
nameSuffixHint:
|
||||
'Wird wie ein „Nachname" an eigene Tiere angehängt, z. B. „von den kleinen Chaoten“ → „Danako von den kleinen Chaoten“.',
|
||||
name: 'Vor- und Nachname',
|
||||
nameHint: 'Mit Anrede, z. B. „Frau Erika Muster“.',
|
||||
address: 'Adresse',
|
||||
@@ -587,6 +594,12 @@ export const de = {
|
||||
empty: 'Keine Kontakte gefunden.',
|
||||
countLabel: 'Kontakte',
|
||||
searchPlaceholder: 'Name suchen …',
|
||||
// Quick-Chip-Filter nach Rolle (Züchter / Abnehmer).
|
||||
filters: {
|
||||
role: 'Rolle',
|
||||
breeder: 'Züchter',
|
||||
receiver: 'Abnehmer',
|
||||
},
|
||||
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt
|
||||
// (DATA-2-Schema: email/phone/address) — gebraucht für den Käufer-Block der Verträge.
|
||||
fields: {
|
||||
@@ -597,6 +610,9 @@ export const de = {
|
||||
notes: 'Notizen',
|
||||
isBreeder: 'Ist Züchter (Zucht)',
|
||||
isReceiver: 'Ist Abnehmer (Käufer)',
|
||||
nameSuffix: 'Namens-Anhängsel der Zucht',
|
||||
nameSuffixHint:
|
||||
'Wird an Tiere aus dieser Zucht angehängt, z. B. „von den Wüstenwinden“.',
|
||||
},
|
||||
linked: {
|
||||
title: 'Verknüpfte Tiere',
|
||||
@@ -822,6 +838,10 @@ export const de = {
|
||||
delete: 'Löschen',
|
||||
confirmDelete: 'Wirklich löschen?',
|
||||
pageNav: 'Seitennavigation',
|
||||
// Toast-Rückmeldungen
|
||||
saved: 'Gespeichert',
|
||||
saveFailed: 'Speichern fehlgeschlagen',
|
||||
deleted: 'Gelöscht',
|
||||
},
|
||||
// Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES,
|
||||
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.
|
||||
|
||||
Reference in New Issue
Block a user