feat(ausstellungen): Ausstellungs-/Auszeichnungsergebnisse je Tier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:43:19 +02:00
parent 5e14124322
commit d380a0c2ef
16 changed files with 2520 additions and 2 deletions

View File

@@ -0,0 +1,61 @@
/**
* EXHIBITION: typed API client for show/exhibition results & awards per animal
* (RennmausPro `ausz_tb`). Backend: GET /exhibitions[?gerbilId=], POST, PUT, DELETE.
* Decoupled from gerbils (loose nullable gerbilId, no FK) — rows survive the import wipe.
*/
import { api } from './client'
import type { DateOnlyString } from './types'
const RESOURCE = '/exhibitions'
export interface ExhibitionResult {
id: string
gerbilId: string | null
entityName: string | null
eventName: string
/** ISO date-time string (backend DateTime?), or null. */
date: DateOnlyString | string | null
placement: string | null
award: string | null
note: string | null
createdAt: string
}
/** Payload for POST /exhibitions. */
export interface CreateExhibitionResult {
gerbilId?: string | null
entityName?: string | null
eventName: string
date?: string | null
placement?: string | null
award?: string | null
note?: string | null
}
/** Payload for PUT /exhibitions/{id} (partial). */
export type UpdateExhibitionResult = Partial<CreateExhibitionResult>
/** Alle Ergebnisse eines Tieres (Backend sortiert neueste zuerst). */
export function listExhibitionResults(gerbilId: string): Promise<ExhibitionResult[]> {
return api.get<ExhibitionResult[]>(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`)
}
/** Alle Ergebnisse (ungefiltert), für eine künftige Gesamtübersicht. */
export function listAllExhibitionResults(): Promise<ExhibitionResult[]> {
return api.get<ExhibitionResult[]>(RESOURCE)
}
export function createExhibitionResult(body: CreateExhibitionResult): Promise<ExhibitionResult> {
return api.post<ExhibitionResult>(RESOURCE, body)
}
export function updateExhibitionResult(
id: string,
body: UpdateExhibitionResult,
): Promise<ExhibitionResult> {
return api.put<ExhibitionResult>(`${RESOURCE}/${id}`, body)
}
export function deleteExhibitionResult(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -0,0 +1,238 @@
/**
* EXHIBITION: Ausstellungen-Tab — Show-/Ausstellungsergebnisse & Auszeichnungen
* eines Tieres (RennmausPro `ausz_tb`). Liste (neueste zuerst) + Inline-Formular
* zum Anlegen/Bearbeiten, Löschen mit Bestätigung. Deutsch durchgehend (de.ts).
*
* Eigenständige Komponente mit nur EINER Einbindungs-Zeile in GerbilDetailPage
* (entkoppelt: loses gerbilId ohne FK, übersteht den Import-Wipe).
*/
import { useState, type FormEvent } from 'react'
import { de } from '../strings/de'
import {
createExhibitionResult,
deleteExhibitionResult,
listExhibitionResults,
updateExhibitionResult,
type CreateExhibitionResult,
type ExhibitionResult,
} from '../api/exhibitions'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate } from '../format/labels'
interface FormState {
eventName: string
date: string
placement: string
award: string
note: string
}
const emptyForm = (): FormState => ({
eventName: '',
date: '',
placement: '',
award: '',
note: '',
})
/** A stored date-time → the date input's "yyyy-MM-dd" value (empty if none). */
function toDateInput(value: string | null): string {
if (!value) return ''
return value.slice(0, 10)
}
export default function GerbilExhibitionsTab({
gerbilId,
entityName,
}: {
gerbilId: string
entityName: string
}) {
const t = de.pages.tierTabs.exhibitions
const ta = de.pages.tierTabs.actions
const records = useApi(() => listExhibitionResults(gerbilId), [gerbilId])
// editing: null = kein Formular, 'new' = anlegen, sonst Record-Id (bearbeiten)
const [editing, setEditing] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const save = useMutation((body: CreateExhibitionResult) =>
editing && editing !== 'new'
? updateExhibitionResult(editing, body)
: createExhibitionResult(body),
)
const removal = useMutation((recordId: string) => deleteExhibitionResult(recordId))
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }))
function openCreate() {
setForm(emptyForm())
setErrors({})
setEditing('new')
}
function openEdit(r: ExhibitionResult) {
setForm({
eventName: r.eventName,
date: toDateInput(r.date as string | null),
placement: r.placement ?? '',
award: r.award ?? '',
note: r.note ?? '',
})
setErrors({})
setEditing(r.id)
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
const next: Partial<Record<keyof FormState, string>> = {}
if (form.eventName.trim() === '') next.eventName = t.validation.eventNameRequired
setErrors(next)
if (Object.keys(next).length > 0) return
const trimmed = (s: string) => (s.trim() === '' ? null : s.trim())
await save.run({
gerbilId,
entityName,
eventName: form.eventName.trim(),
// date input gives "yyyy-MM-dd"; send as ISO so the backend DateTime? parses it.
date: form.date ? `${form.date}T00:00:00Z` : null,
placement: trimmed(form.placement),
award: trimmed(form.award),
note: trimmed(form.note),
})
setEditing(null)
records.reload()
}
async function onDelete(recordId: string) {
if (!window.confirm(t.deleteConfirm)) return
await removal.run(recordId)
records.reload()
}
return (
<div>
{editing === null && (
<button type="button" className="btn btn--primary" onClick={openCreate}>
+ {t.newButton}
</button>
)}
{editing !== null && (
<form className="form" onSubmit={onSubmit} noValidate>
<h4>{editing === 'new' ? t.createTitle : t.editTitle}</h4>
<label className="field">
<span>{t.fields.eventName} *</span>
<input
className="input"
value={form.eventName}
placeholder={t.placeholders.eventName}
onChange={(e) => set('eventName', e.target.value)}
aria-invalid={Boolean(errors.eventName)}
/>
{errors.eventName && <small className="error-text">{errors.eventName}</small>}
</label>
<label className="field">
<span>{t.fields.date}</span>
<input
type="date"
className="input"
value={form.date}
onChange={(e) => set('date', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.placement}</span>
<input
className="input"
value={form.placement}
placeholder={t.placeholders.placement}
onChange={(e) => set('placement', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.award}</span>
<input
className="input"
value={form.award}
placeholder={t.placeholders.award}
onChange={(e) => set('award', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.note}</span>
<textarea value={form.note} onChange={(e) => set('note', e.target.value)} />
</label>
{save.error && <div className="alert alert--error">{save.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={save.pending}>
{save.pending ? ta.saving : ta.save}
</button>
<button type="button" className="btn" onClick={() => setEditing(null)}>
{ta.cancel}
</button>
</div>
</form>
)}
{records.loading && <p className="muted">{de.common.loading}</p>}
{records.error && (
<div className="alert alert--error">
<span>{records.error}</span>
<button type="button" className="btn" onClick={records.reload}>
{de.common.retry}
</button>
</div>
)}
{removal.error && <div className="alert alert--error">{removal.error}</div>}
{!records.loading && !records.error && (records.data ?? []).length === 0 && (
<p className="muted">{t.empty}</p>
)}
{(records.data ?? []).length > 0 && (
<ul className="card-list" style={{ marginTop: '1rem' }}>
{(records.data ?? []).map((r) => (
<li key={r.id} className="record-card">
<div className="record-card__head">
<span className="record-card__date">
{r.date ? formatDate(toDateInput(r.date as string | null)) : '—'}
</span>
{r.placement && <span className="badge">{r.placement}</span>}
{r.award && <span className="badge badge--active">{r.award}</span>}
</div>
<p className="record-card__body">
<strong>{r.eventName}</strong>
</p>
{r.note && <p className="muted">{r.note}</p>}
<div className="record-card__actions">
<button type="button" className="btn" onClick={() => openEdit(r)}>
{ta.edit}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => onDelete(r.id)}
disabled={removal.pending}
>
{ta.delete}
</button>
</div>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -10,6 +10,7 @@ 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 GerbilExhibitionsTab from '../components/GerbilExhibitionsTab'
import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
@@ -20,7 +21,7 @@ import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast'
import './rennmausakte.css'
type DetailTab = 'photos' | 'health' | 'weight'
type DetailTab = 'photos' | 'health' | 'weight' | 'exhibitions'
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
const STATUS_COLORS: Record<GerbilStatus, string> = {
@@ -461,7 +462,7 @@ export default function GerbilDetailPage() {
<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) => (
{(['photos', 'health', 'weight', 'exhibitions'] as const).map((key) => (
<button
key={key}
type="button"
@@ -478,6 +479,12 @@ export default function GerbilDetailPage() {
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
{tab === 'exhibitions' && (
<GerbilExhibitionsTab
gerbilId={g.id}
entityName={gerbilName(g) || de.pages.gerbils.nameless}
/>
)}
</div>
</section>
</div>

View File

@@ -113,6 +113,7 @@ export const de = {
photos: 'Fotos',
health: 'Gesundheit',
weight: 'Gewicht',
exhibitions: 'Ausstellungen',
},
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.',
@@ -731,6 +732,29 @@ export const de = {
fileRequired: 'Bitte zuerst ein Foto auswählen.',
},
},
// ── Ausstellungen/Auszeichnungen je Tier (RennmausPro ausz_tb) ──
exhibitions: {
empty: 'Noch keine Ausstellungsergebnisse erfasst.',
newButton: 'Neues Ergebnis',
createTitle: 'Neues Ausstellungsergebnis',
editTitle: 'Ausstellungsergebnis bearbeiten',
fields: {
eventName: 'Veranstaltung',
date: 'Datum',
placement: 'Platzierung',
award: 'Auszeichnung',
note: 'Notiz',
},
placeholders: {
eventName: 'z. B. Nationale Rennmausschau 2026',
placement: 'z. B. 1. Platz',
award: 'z. B. Best in Show',
},
validation: {
eventNameRequired: 'Bitte eine Veranstaltung angeben.',
},
deleteConfirm: 'Dieses Ausstellungsergebnis wirklich löschen?',
},
},
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
datenexport: {