diff --git a/gerbil-manager-web/src/format/labels.ts b/gerbil-manager-web/src/format/labels.ts new file mode 100644 index 0000000..051a03d --- /dev/null +++ b/gerbil-manager-web/src/format/labels.ts @@ -0,0 +1,19 @@ +/** German label helpers for enum values and lookups. */ +import { de } from '../strings/de' +import type { Gender, GerbilStatus } from '../api/types' + +export function genderLabel(gender: Gender): string { + return de.pages.gerbils.genderLabels[gender] +} + +export function statusLabel(status: GerbilStatus): string { + return de.pages.gerbils.statusLabels[status] +} + +/** Format an ISO date ("YYYY-MM-DD") as German "TT.MM.JJJJ"; null -> dash. */ +export function formatDate(iso: string | null | undefined): string { + if (!iso) return '—' + const [y, m, d] = iso.split('-') + if (!y || !m || !d) return iso + return `${d}.${m}.${y}` +} diff --git a/gerbil-manager-web/src/index.css b/gerbil-manager-web/src/index.css index bfcbce0..9d7e786 100644 --- a/gerbil-manager-web/src/index.css +++ b/gerbil-manager-web/src/index.css @@ -155,3 +155,195 @@ a { padding: 1.5rem 2rem; } } + +/* ── Shared UI ───────────────────────────────────────────────── */ + +.muted { + color: var(--color-text-muted); + margin: 0.25rem 0; +} + +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 44px; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + cursor: pointer; + text-decoration: none; +} + +.btn:hover:not(:disabled) { + background: var(--color-accent-soft); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn--primary { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.btn--primary:hover:not(:disabled) { + filter: brightness(0.95); + background: var(--color-accent); +} + +.btn--danger { + border-color: #b54a4a; + color: #b54a4a; +} + +.input, +select, +textarea { + font: inherit; + padding: 0.5rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background: var(--color-surface); + color: var(--color-text); + min-height: 44px; + width: 100%; +} + +textarea { + min-height: 5rem; + resize: vertical; +} + +.alert { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + margin: 0.75rem 0; +} + +.alert--error { + background: #fbeaea; + border: 1px solid #e3b3b3; + color: #8a2f2f; +} + +/* ── Filters ─────────────────────────────────────────────────── */ + +.filters { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-end; + margin: 1rem 0; +} + +.filters .input[type='search'] { + flex: 1 1 12rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.field > select, +.field > .input { + min-width: 9rem; +} + +/* ── Card list (mobile-first); table-like rows on wider screens ── */ + +.card-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.gerbil-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.25rem 0.75rem; + align-items: center; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + border-radius: 0.6rem; + background: var(--color-surface); + text-decoration: none; + color: var(--color-text); +} + +.gerbil-card:hover { + background: var(--color-accent-soft); +} + +.gerbil-card__name { + font-weight: 600; +} + +.gerbil-card__meta { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.badge { + justify-self: end; + font-size: 0.72rem; + padding: 0.15rem 0.5rem; + border-radius: 1rem; + background: var(--color-accent-soft); + color: var(--color-accent); + white-space: nowrap; +} + +.badge--deceased { + background: #ececec; + color: #666; +} + +.badge--givenaway { + background: #e7eef6; + color: #3a6ea5; +} + +@media (min-width: 768px) { + .gerbil-card { + grid-template-columns: 2fr 1fr 1.5fr 1fr auto; + grid-auto-flow: column; + } + .badge { + justify-self: start; + } +} + +.pager { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin: 1.5rem 0; +} diff --git a/gerbil-manager-web/src/pages/GerbilsPage.tsx b/gerbil-manager-web/src/pages/GerbilsPage.tsx index 9758665..36e5516 100644 --- a/gerbil-manager-web/src/pages/GerbilsPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilsPage.tsx @@ -1,10 +1,207 @@ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import { de } from '../strings/de' +import { listGerbils } from '../api/gerbils' +import { listColorVarieties } from '../api/lookups' +import { andFilter, condition, type GridifyQuery } from '../api/gridify' +import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types' +import { useApi } from '../hooks/useApi' +import { formatDate, genderLabel, statusLabel } from '../format/labels' + +const PAGE_SIZE = 20 + +type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc' +const SORT_ORDER_BY: Record = { + nameAsc: 'name', + nameDesc: 'name desc', + birthDesc: 'dateOfBirth desc', + birthAsc: 'dateOfBirth', +} export default function GerbilsPage() { + const t = de.pages.gerbils + const [search, setSearch] = useState('') + const [status, setStatus] = useState('Active') + const [gender, setGender] = useState('') + const [colorVarietyId, setColorVarietyId] = useState('') + const [sort, setSort] = useState('nameAsc') + const [page, setPage] = useState(1) + + const colorVarieties = useApi(() => listColorVarieties(), []) + const colorNameById = useMemo(() => { + const map = new Map() + for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name) + return map + }, [colorVarieties.data]) + + const filter = andFilter( + search.trim() && condition({ field: 'name', op: 'contains', value: search.trim() }), + status && condition({ field: 'status', op: '==', value: status }), + gender && condition({ field: 'gender', op: '==', value: gender }), + colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }), + ) + const orderBy = SORT_ORDER_BY[sort] + const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE } + + const gerbils = useApi(() => listGerbils(query), [filter, orderBy, page]) + + const resetFilters = () => { + setSearch('') + setStatus('Active') + setGender('') + setColorVarietyId('') + setPage(1) + setSort('nameAsc') + } + + // When a filter changes, jump back to page 1. + const onFilterChange = (setter: (v: T) => void) => (value: T) => { + setter(value) + setPage(1) + } + + const total = gerbils.data?.totalCount ?? 0 + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const items = gerbils.data?.items ?? [] + return ( -
-

{de.pages.gerbils.title}

-

{de.pages.gerbils.comingSoon}

+
+
+
+

{t.title}

+ {gerbils.data && ( +

+ {total} {t.countLabel} +

+ )} +
+ + + {t.newButton} + +
+ +
+ onFilterChange(setSearch)(e.target.value)} + aria-label={t.fields.name} + /> + + + + + +
+ + {gerbils.loading &&

{de.common.loading}

} + {gerbils.error && ( +
+ {gerbils.error} + +
+ )} + + {!gerbils.loading && !gerbils.error && items.length === 0 && ( +

{t.empty}

+ )} + + {items.length > 0 && ( +
    + {items.map((g) => ( +
  • + + {g.name} + + {statusLabel(g.status)} + + {genderLabel(g.gender)} + + {g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'} + + {formatDate(g.dateOfBirth)} + +
  • + ))} +
+ )} + + {totalPages > 1 && ( + + )}
) } diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 0de775f..7e8f568 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -25,7 +25,90 @@ export const de = { }, gerbils: { title: 'Rennmäuse', - comingSoon: 'Die Rennmaus-Verwaltung entsteht gerade. Bald kannst du hier deine Tiere anlegen und pflegen.', + // Liste + newButton: 'Neue Rennmaus', + empty: 'Keine Rennmäuse gefunden.', + countLabel: 'Tiere', + searchPlaceholder: 'Name suchen …', + // Filter-Beschriftungen + filters: { + title: 'Filter', + status: 'Status', + gender: 'Geschlecht', + colorVariety: 'Farbschlag', + all: 'Alle', + reset: 'Filter zurücksetzen', + sortBy: 'Sortieren nach', + }, + // Sortier-Optionen + sort: { + nameAsc: 'Name (A–Z)', + nameDesc: 'Name (Z–A)', + birthDesc: 'Geburtsdatum (neueste)', + birthAsc: 'Geburtsdatum (älteste)', + }, + // Feld-Beschriftungen (Stammdaten) + fields: { + name: 'Name', + gender: 'Geschlecht', + status: 'Status', + dateOfBirth: 'Geburtsdatum', + dateOfDeath: 'Todesdatum', + causeOfDeath: 'Todesursache', + goHomeDate: 'Abgabedatum', + litter: 'Wurf', + enclosure: 'Becken', + colorVariety: 'Farbschlag', + origin: 'Herkunft', + receiver: 'Abnehmer', + genotype: 'Genotyp', + notes: 'Notizen', + }, + // Detailseite + detail: { + masterData: 'Stammdaten', + genetics: 'Genetik', + resolvedFarbschlag: 'Errechneter Farbschlag', + genotypeNotSet: 'Kein Genotyp hinterlegt.', + edit: 'Bearbeiten', + back: 'Zurück zur Liste', + tabs: { + photos: 'Fotos', + health: 'Gesundheit', + weight: 'Gewicht', + }, + tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.', + notFound: 'Diese Rennmaus wurde nicht gefunden.', + }, + // Formular (anlegen/bearbeiten) + form: { + createTitle: 'Neue Rennmaus anlegen', + editTitle: 'Rennmaus bearbeiten', + none: '— keine Angabe —', + genotypeHint: + 'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.', + save: 'Speichern', + cancel: 'Abbrechen', + saving: 'Speichern …', + }, + // Validierungsmeldungen + validation: { + nameRequired: 'Bitte einen Namen eingeben.', + genderRequired: 'Bitte ein Geschlecht auswählen.', + genotypeInvalid: 'Der Genotyp ist ungültig.', + dateOfDeathBeforeBirth: 'Das Todesdatum darf nicht vor dem Geburtsdatum liegen.', + }, + // Aufzählungen + genderLabels: { + unknown: 'Unbekannt', + male: 'Männlich', + female: 'Weiblich', + }, + statusLabels: { + Active: 'Aktiv', + Deceased: 'Verstorben', + GivenAway: 'Abgegeben', + }, }, litters: { title: 'Würfe', @@ -51,6 +134,15 @@ export const de = { }, common: { loading: 'Lädt …', + retry: 'Erneut versuchen', + previous: 'Zurück', + next: 'Weiter', + page: 'Seite', + of: 'von', + yes: 'Ja', + no: 'Nein', + delete: 'Löschen', + confirmDelete: 'Wirklich löschen?', }, // Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES, // die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.