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,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>
)
}