feat(abgabe): Reservierungs-/Abgabe-Status (verfügbar/reserviert/abgegeben)

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 40c92c70d4
18 changed files with 3056 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
import GerbilFormPage from './pages/GerbilFormPage'
import GenetikPage from './pages/GenetikPage'
import AbgabePage from './pages/AbgabePage'
import ReservierungenPage from './pages/ReservierungenPage'
import KontaktePage from './pages/KontaktePage'
import KontaktDetailPage from './pages/KontaktDetailPage'
import KontaktFormPage from './pages/KontaktFormPage'
@@ -69,6 +70,8 @@ export default function App() {
</Route>
<Route path="genetik" element={<GenetikPage />} />
<Route path="abgabe" element={<AbgabePage />} />
{/* ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben) */}
<Route path="reservierungen" element={<ReservierungenPage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe" element={<HilfePage />} />
<Route path="farbkatalog" element={<FarbkatalogPage />} />

View File

@@ -0,0 +1,66 @@
/**
* ABGABE-STATUS: API client for the reservation/sale-status pipeline (/reservations).
* Status verfügbar → reserviert → abgegeben. This is the informal pre-handover status —
* separate from the legal Vertrag (SaleContract).
*/
import { api } from './client'
const RESOURCE = '/reservations'
/** Canonical status values (match the backend contract, case-sensitive). */
export type ReservationStatus = 'verfuegbar' | 'reserviert' | 'abgegeben'
export interface SaleReservation {
id: string
gerbilId: string
gerbilName: string | null
status: ReservationStatus
reservedForContactId: string | null
contactName: string | null
/** ISO-8601 appointment date or null. */
appointmentDate: string | null
price: number | null
note: string | null
/** ISO-8601 hand-over date or null. */
handedOverDate: string | null
createdAt: string
updatedAt: string
}
/** Payload for POST /reservations. */
export interface CreateReservation {
gerbilId: string
gerbilName?: string | null
status?: ReservationStatus
reservedForContactId?: string | null
contactName?: string | null
appointmentDate?: string | null
price?: number | null
note?: string | null
handedOverDate?: string | null
}
/** Payload for PUT /reservations/{id}. */
export type UpdateReservation = Partial<Omit<CreateReservation, 'gerbilId'>>
/** List all reservations, newest-updated first. */
export function listReservations(): Promise<SaleReservation[]> {
return api.get<SaleReservation[]>(RESOURCE)
}
/** List the reservation status rows for one animal. */
export function listReservationsForGerbil(gerbilId: string): Promise<SaleReservation[]> {
return api.get<SaleReservation[]>(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`)
}
export function createReservation(body: CreateReservation): Promise<SaleReservation> {
return api.post<SaleReservation>(RESOURCE, body)
}
export function updateReservation(id: string, body: UpdateReservation): Promise<SaleReservation> {
return api.put<SaleReservation>(`${RESOURCE}/${id}`, body)
}
export function deleteReservation(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -24,6 +24,8 @@ const SECONDARY: NavItem[] = [
{ to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// ABGABE-STATUS: Reservierungs-/Abgabe-Status
{ to: '/reservierungen', label: de.nav.reservations, icon: '🔖' },
// INBOX-1: Anfragen-Posteingang
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },

View File

@@ -0,0 +1,417 @@
/**
* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Verwaltung.
* Liste der (vor allem abzugebenden) Tiere mit Status verfügbar → reserviert → abgegeben,
* inkl. Reservierung für einen Kontakt, Termin, Preis und Notiz. Tier- und Kontakt-Bezüge
* sind als Links hinterlegt. Dieser Status ist die Vormerkung VOR dem rechtlichen Vertrag
* (Verträge-Seite) — er ersetzt ihn nicht.
*/
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
import { listContactsPaged } from '../api/contacts'
import {
createReservation,
deleteReservation,
listReservations,
updateReservation,
type ReservationStatus,
type SaleReservation,
} from '../api/reservations'
import { useApi, useMutation } from '../hooks/useApi'
import type { Contact, Gerbil } from '../api/types'
import './reservierungen.css'
const STATUSES: ReservationStatus[] = ['verfuegbar', 'reserviert', 'abgegeben']
type FilterKey = 'all' | ReservationStatus
/** ISO-Datetime → "TT.MM.JJJJ"; leer/ungültig → Strich. */
function dateOnly(iso: string | null): string {
if (!iso) return de.pages.reservierungen.fields.none
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
/** ISO-Datetime → Wert für <input type="date"> ("YYYY-MM-DD"). */
function toDateInput(iso: string | null): string {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString().slice(0, 10)
}
/** <input type="date">-Wert ("YYYY-MM-DD") → ISO-Datetime oder null. */
function fromDateInput(value: string): string | null {
if (!value) return null
return new Date(`${value}T00:00:00Z`).toISOString()
}
interface FormState {
id: string | null
gerbilId: string
status: ReservationStatus
reservedForContactId: string
appointmentDate: string
price: string
note: string
handedOverDate: string
}
const emptyForm: FormState = {
id: null,
gerbilId: '',
status: 'reserviert',
reservedForContactId: '',
appointmentDate: '',
price: '',
note: '',
handedOverDate: '',
}
export default function ReservierungenPage() {
const t = de.pages.reservierungen
const reservations = useApi(() => listReservations(), [])
const gerbils = useApi(
() => listGerbils({ orderBy: 'name', page: 1, pageSize: 1000 }),
[],
)
const contacts = useApi(
() => listContactsPaged({ orderBy: 'name', page: 1, pageSize: 1000 }),
[],
)
const createM = useMutation(createReservation)
const updateM = useMutation(updateReservation)
const deleteM = useMutation(deleteReservation)
const [filter, setFilter] = useState<FilterKey>('all')
const [form, setForm] = useState<FormState | null>(null)
const [formError, setFormError] = useState<string | null>(null)
const gerbilById = useMemo(
() => new Map((gerbils.data?.items ?? []).map((g: Gerbil) => [g.id, g])),
[gerbils.data],
)
const contactById = useMemo(
() => new Map((contacts.data?.items ?? []).map((c: Contact) => [c.id, c])),
[contacts.data],
)
const rows = useMemo(() => {
const all = reservations.data ?? []
return filter === 'all' ? all : all.filter((r) => r.status === filter)
}, [reservations.data, filter])
const counts = useMemo(() => {
const all = reservations.data ?? []
const c: Record<FilterKey, number> = { all: all.length, verfuegbar: 0, reserviert: 0, abgegeben: 0 }
for (const r of all) c[r.status] += 1
return c
}, [reservations.data])
function gerbilName(r: SaleReservation): string {
return gerbilById.get(r.gerbilId)?.name ?? r.gerbilName ?? t.fields.none
}
function contactName(r: SaleReservation): string {
if (!r.reservedForContactId) return t.fields.none
return contactById.get(r.reservedForContactId)?.name ?? r.contactName ?? t.fields.none
}
function openCreate() {
setFormError(null)
setForm({ ...emptyForm })
}
function openEdit(r: SaleReservation) {
setFormError(null)
setForm({
id: r.id,
gerbilId: r.gerbilId,
status: r.status,
reservedForContactId: r.reservedForContactId ?? '',
appointmentDate: toDateInput(r.appointmentDate),
price: r.price != null ? String(r.price) : '',
note: r.note ?? '',
handedOverDate: toDateInput(r.handedOverDate),
})
}
async function saveForm() {
if (!form) return
if (!form.gerbilId) {
setFormError(t.form.chooseAnimal)
return
}
const g = gerbilById.get(form.gerbilId)
const c = form.reservedForContactId ? contactById.get(form.reservedForContactId) : undefined
const priceNum = form.price.trim() === '' ? null : Number(form.price.replace(',', '.'))
const payload = {
status: form.status,
reservedForContactId: form.reservedForContactId || null,
contactName: c?.name ?? null,
appointmentDate: fromDateInput(form.appointmentDate),
price: priceNum != null && !Number.isNaN(priceNum) ? priceNum : null,
note: form.note.trim() || null,
handedOverDate: fromDateInput(form.handedOverDate),
}
const result = form.id
? await updateM.run(form.id, payload)
: await createM.run({ gerbilId: form.gerbilId, gerbilName: g?.name ?? null, ...payload })
if (result.ok) {
setForm(null)
reservations.reload()
} else {
setFormError(t.form.saveError)
}
}
async function quickStatus(r: SaleReservation, status: ReservationStatus) {
const patch: Parameters<typeof updateReservation>[1] = { status }
if (status === 'abgegeben' && !r.handedOverDate) patch.handedOverDate = new Date().toISOString()
const result = await updateM.run(r.id, patch)
if (result.ok) reservations.reload()
}
async function remove(r: SaleReservation) {
if (!window.confirm(t.actions.confirmDelete)) return
const result = await deleteM.run(r.id)
if (result.ok) reservations.reload()
}
if (reservations.loading) return <p className="muted">{de.common.loading}</p>
if (reservations.error) return <p className="error">{t.loadError}</p>
return (
<section className="page reservierungen">
<h2>{t.title}</h2>
<p className="muted">{t.subtitle}</p>
<p className="muted reservierungen__hint">{t.contractHint}</p>
<div className="reservierungen__toolbar">
<div className="reservierungen__filters" role="group" aria-label={t.fields.status}>
{(['all', ...STATUSES] as FilterKey[]).map((key) => (
<button
key={key}
type="button"
className={
filter === key
? 'reservierungen-filter reservierungen-filter--active'
: 'reservierungen-filter'
}
onClick={() => setFilter(key)}
>
{t.filters[key]}
<span className="reservierungen-filter__count">{counts[key]}</span>
</button>
))}
</div>
<button type="button" className="btn btn--primary" onClick={openCreate}>
{t.newButton}
</button>
</div>
{form && (
<ReservationForm
t={t}
form={form}
setForm={setForm}
gerbils={gerbils.data?.items ?? []}
contacts={contacts.data?.items ?? []}
error={formError}
pending={createM.pending || updateM.pending}
onSave={saveForm}
onCancel={() => setForm(null)}
/>
)}
{rows.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<ul className="reservierungen__list">
{rows.map((r) => (
<li key={r.id} className={`reservation-card reservation-card--${r.status}`}>
<div className="reservation-card__head">
<Link to={`/rennmaeuse/${r.gerbilId}`} className="reservation-card__animal">
{gerbilName(r)}
</Link>
<span className={`reservation-badge reservation-badge--${r.status}`}>
{t.status[r.status]}
</span>
</div>
<dl className="reservation-card__meta">
<div>
<dt>{t.fields.reservedFor}</dt>
<dd>
{r.reservedForContactId ? (
<Link to={`/kontakte/${r.reservedForContactId}`}>{contactName(r)}</Link>
) : (
contactName(r)
)}
</dd>
</div>
<div>
<dt>{t.fields.appointment}</dt>
<dd>{dateOnly(r.appointmentDate)}</dd>
</div>
<div>
<dt>{t.fields.price}</dt>
<dd>{r.price != null ? `${r.price.toFixed(2)}` : t.fields.none}</dd>
</div>
<div>
<dt>{t.fields.handedOver}</dt>
<dd>{dateOnly(r.handedOverDate)}</dd>
</div>
</dl>
{r.note && <p className="reservation-card__note">{r.note}</p>}
<div className="reservation-card__actions">
{r.status !== 'reserviert' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'reserviert')}>
{t.actions.markReserved}
</button>
)}
{r.status !== 'abgegeben' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'abgegeben')}>
{t.actions.markHandedOver}
</button>
)}
{r.status !== 'verfuegbar' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'verfuegbar')}>
{t.actions.markAvailable}
</button>
)}
<button type="button" className="btn" onClick={() => openEdit(r)}>
{t.actions.edit}
</button>
<button type="button" className="btn btn--danger" onClick={() => remove(r)}>
{t.actions.delete}
</button>
</div>
</li>
))}
</ul>
)}
</section>
)
}
interface FormProps {
t: typeof de.pages.reservierungen
form: FormState
setForm: (f: FormState) => void
gerbils: Gerbil[]
contacts: Contact[]
error: string | null
pending: boolean
onSave: () => void
onCancel: () => void
}
function ReservationForm({ t, form, setForm, gerbils, contacts, error, pending, onSave, onCancel }: FormProps) {
const isEdit = form.id != null
return (
<form
className="reservation-form"
onSubmit={(e) => {
e.preventDefault()
onSave()
}}
>
<h3>{isEdit ? t.form.editTitle : t.form.addTitle}</h3>
<label>
{t.form.animal}
<select
value={form.gerbilId}
disabled={isEdit}
onChange={(e) => setForm({ ...form, gerbilId: e.target.value })}
>
<option value="">{t.form.animalPlaceholder}</option>
{gerbils.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</label>
<label>
{t.form.status}
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as ReservationStatus })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t.status[s]}
</option>
))}
</select>
</label>
<label>
{t.form.reservedFor}
<select
value={form.reservedForContactId}
onChange={(e) => setForm({ ...form, reservedForContactId: e.target.value })}
>
<option value="">{t.form.contactNone}</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label>
{t.form.appointment}
<input
type="date"
value={form.appointmentDate}
onChange={(e) => setForm({ ...form, appointmentDate: e.target.value })}
/>
</label>
<label>
{t.form.price}
<input
type="number"
step="0.01"
min="0"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
</label>
<label>
{t.form.handedOver}
<input
type="date"
value={form.handedOverDate}
onChange={(e) => setForm({ ...form, handedOverDate: e.target.value })}
/>
</label>
<label className="reservation-form__note">
{t.form.note}
<textarea
value={form.note}
rows={2}
onChange={(e) => setForm({ ...form, note: e.target.value })}
/>
</label>
{error && <p className="error">{error}</p>}
<div className="reservation-form__actions">
<button type="submit" className="btn btn--primary" disabled={pending}>
{t.form.save}
</button>
<button type="button" className="btn" onClick={onCancel}>
{t.form.cancel}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,172 @@
/* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Seite. */
.reservierungen__hint {
font-size: 0.85rem;
}
.reservierungen__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin: 1rem 0;
}
.reservierungen__filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.reservierungen-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--border, #ccc);
border-radius: 999px;
background: transparent;
cursor: pointer;
font: inherit;
}
.reservierungen-filter--active {
background: var(--accent, #3b6ea5);
color: #fff;
border-color: var(--accent, #3b6ea5);
}
.reservierungen-filter__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
padding: 0 0.35em;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
font-size: 0.8em;
}
.reservierungen-filter--active .reservierungen-filter__count {
background: rgba(255, 255, 255, 0.3);
}
.reservierungen__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.reservation-card {
border: 1px solid var(--border, #ddd);
border-left: 4px solid var(--border, #ddd);
border-radius: 8px;
padding: 0.85rem 1rem;
background: var(--surface, #fff);
}
.reservation-card--verfuegbar {
border-left-color: #2e7d32;
}
.reservation-card--reserviert {
border-left-color: #ef6c00;
}
.reservation-card--abgegeben {
border-left-color: #6d6d6d;
}
.reservation-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.reservation-card__animal {
font-weight: 600;
font-size: 1.05rem;
}
.reservation-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.reservation-badge--verfuegbar {
background: #2e7d32;
}
.reservation-badge--reserviert {
background: #ef6c00;
}
.reservation-badge--abgegeben {
background: #6d6d6d;
}
.reservation-card__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem 1rem;
margin: 0.6rem 0 0;
}
.reservation-card__meta div {
display: flex;
flex-direction: column;
}
.reservation-card__meta dt {
font-size: 0.75rem;
color: var(--muted, #777);
}
.reservation-card__meta dd {
margin: 0;
}
.reservation-card__note {
margin: 0.5rem 0 0;
font-style: italic;
color: var(--muted, #555);
}
.reservation-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.75rem;
}
.reservation-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
border: 1px solid var(--border, #ddd);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
background: var(--surface-alt, #fafafa);
}
.reservation-form h3 {
grid-column: 1 / -1;
margin: 0;
}
.reservation-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.reservation-form__note {
grid-column: 1 / -1;
}
.reservation-form .error {
grid-column: 1 / -1;
}
.reservation-form__actions {
grid-column: 1 / -1;
display: flex;
gap: 0.5rem;
}

View File

@@ -22,6 +22,8 @@ export const de = {
more: 'Mehr',
// FEAT-12a (Kevin): Abgabe
forSale: 'Abgabe',
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben)
reservations: 'Reservierungen',
// FEAT-13 (Kelly): Verträge + Einstellungen
contracts: 'Verträge',
settings: 'Einstellungen',
@@ -358,6 +360,69 @@ export const de = {
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
},
},
// ── ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben) ──
reservierungen: {
title: 'Reservierungen',
subtitle: 'Reservierungs- und Abgabe-Status der Abgabetiere verwalten.',
loadError: 'Reservierungen konnten nicht geladen werden.',
empty: 'Noch keine Reservierungen erfasst.',
newButton: 'Neue Reservierung',
// Status-Werte (Anzeige).
status: {
verfuegbar: 'Verfügbar',
reserviert: 'Reserviert',
abgegeben: 'Abgegeben',
},
// Filter-Chips nach Status.
filters: {
all: 'Alle',
verfuegbar: 'Verfügbar',
reserviert: 'Reserviert',
abgegeben: 'Abgegeben',
},
// Spalten / Feld-Beschriftungen.
fields: {
animal: 'Tier',
status: 'Status',
reservedFor: 'Reserviert für',
appointment: 'Termin',
price: 'Preis',
note: 'Notiz',
handedOver: 'Abgegeben am',
none: '—',
},
// Formular (Anlegen / Bearbeiten).
form: {
addTitle: 'Reservierung anlegen',
editTitle: 'Reservierung bearbeiten',
animal: 'Tier',
animalPlaceholder: 'Tier auswählen …',
status: 'Status',
reservedFor: 'Reserviert für (Kontakt)',
contactNone: '— niemand —',
appointment: 'Termin',
price: 'Preis (€)',
note: 'Notiz',
handedOver: 'Abgegeben am',
save: 'Speichern',
cancel: 'Abbrechen',
saveError: 'Speichern fehlgeschlagen.',
chooseAnimal: 'Bitte ein Tier auswählen.',
},
// Aktionen pro Zeile.
actions: {
edit: 'Bearbeiten',
delete: 'Löschen',
markReserved: 'Reservieren',
markHandedOver: 'Als abgegeben markieren',
markAvailable: 'Wieder freigeben',
confirmDelete: 'Diese Reservierung wirklich löschen?',
deleteError: 'Löschen fehlgeschlagen.',
},
// Hinweis: Status ≠ Vertrag.
contractHint:
'Hinweis: Der Status ist eine Vormerkung. Die rechtliche Abgabe erfolgt weiterhin über einen Vertrag.',
},
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
becken: {
title: 'Gehege',