feat(ruecknahmen): zurückgenommene Tiere erfassen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:43:18 +02:00
parent 5e14124322
commit b4dc37de78
17 changed files with 2645 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ import WebseiteEditorPage from './pages/WebseiteEditorPage'
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage'
import RuecknahmenPage from './pages/RuecknahmenPage'
function BeckenRedirect() {
const { '*': splat } = useParams()
@@ -90,6 +91,8 @@ export default function App() {
<Route index element={<AnfragenPage />} />
<Route path=":id" element={<AnfrageDetailPage />} />
</Route>
{/* RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere */}
<Route path="ruecknahmen" element={<RuecknahmenPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>

View File

@@ -0,0 +1,48 @@
/** RÜCKNAHMEN: API-Client für zurückgenommene Tiere (/returns). */
import { api } from './client'
const RESOURCE = '/returns'
/** Payload für POST/PUT /returns. */
export interface ReturnRecordInput {
gerbilId?: string | null
gerbilName?: string | null
returnDate?: string | null
returnPrice?: number | null
originalPrice?: number | null
originalSaleDate?: string | null
fromContactId?: string | null
fromContactName?: string | null
note?: string | null
}
export interface ReturnRecord {
id: string
gerbilId: string | null
gerbilName: string | null
returnDate: string | null
returnPrice: number | null
originalPrice: number | null
originalSaleDate: string | null
fromContactId: string | null
fromContactName: string | null
note: string | null
createdAt: string
}
export function listReturns(gerbilId?: string): Promise<ReturnRecord[]> {
const q = gerbilId ? `?gerbilId=${encodeURIComponent(gerbilId)}` : ''
return api.get<ReturnRecord[]>(`${RESOURCE}${q}`)
}
export function createReturn(body: ReturnRecordInput): Promise<ReturnRecord> {
return api.post<ReturnRecord>(RESOURCE, body)
}
export function updateReturn(id: string, body: ReturnRecordInput): Promise<ReturnRecord> {
return api.put<ReturnRecord>(`${RESOURCE}/${id}`, body)
}
export function deleteReturn(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -26,6 +26,8 @@ const SECONDARY: NavItem[] = [
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// INBOX-1: Anfragen-Posteingang
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
// RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere
{ to: '/ruecknahmen', label: de.nav.returns, icon: '↩️' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
// FEAT-13: Abgabeverträge + Zuchtprofil
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },

View File

@@ -0,0 +1,353 @@
/**
* RÜCKNAHMEN: Verwaltungsseite für zurückgekommene/zurückgenommene Tiere (/ruecknahmen).
* Liste + Inline-Formular zum Erfassen/Bearbeiten. Tier-/Kontakt-Bezüge als Links.
*/
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import {
createReturn,
deleteReturn,
listReturns,
updateReturn,
type ReturnRecord,
type ReturnRecordInput,
} from '../api/returns'
import { listGerbils } from '../api/gerbils'
import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate } from '../format/labels'
const EMPTY_FORM = {
gerbilId: '',
gerbilName: '',
returnDate: '',
returnPrice: '',
originalPrice: '',
originalSaleDate: '',
fromContactId: '',
fromContactName: '',
note: '',
}
type FormState = typeof EMPTY_FORM
function toInput(form: FormState): ReturnRecordInput {
const num = (s: string) => (s.trim() === '' ? null : Number(s))
return {
gerbilId: form.gerbilId || null,
gerbilName: form.gerbilName.trim() || null,
returnDate: form.returnDate ? `${form.returnDate}T00:00:00Z` : null,
returnPrice: num(form.returnPrice),
originalPrice: num(form.originalPrice),
originalSaleDate: form.originalSaleDate ? `${form.originalSaleDate}T00:00:00Z` : null,
fromContactId: form.fromContactId || null,
fromContactName: form.fromContactName.trim() || null,
note: form.note.trim() || null,
}
}
/** ISO/Date-Wert → "YYYY-MM-DD" für <input type=date>. */
function isoDateInput(value: string | null): string {
if (!value) return ''
return value.slice(0, 10)
}
function fromRecord(r: ReturnRecord): FormState {
return {
gerbilId: r.gerbilId ?? '',
gerbilName: r.gerbilName ?? '',
returnDate: isoDateInput(r.returnDate),
returnPrice: r.returnPrice == null ? '' : String(r.returnPrice),
originalPrice: r.originalPrice == null ? '' : String(r.originalPrice),
originalSaleDate: isoDateInput(r.originalSaleDate),
fromContactId: r.fromContactId ?? '',
fromContactName: r.fromContactName ?? '',
note: r.note ?? '',
}
}
function formatPrice(value: number | null): string {
if (value == null) return '—'
return `${value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
export default function RuecknahmenPage() {
const t = de.pages.ruecknahmen
const returns = useApi(() => listReturns(), [])
const gerbils = useApi(
() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }),
[],
)
const contacts = useApi(
() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }),
[],
)
const gerbilName = useMemo(
() => new Map((gerbils.data?.items ?? []).map((g) => [g.id, g.name])),
[gerbils.data],
)
const contactName = useMemo(
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
[contacts.data],
)
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [editingId, setEditingId] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const save = useMutation((body: ReturnRecordInput) =>
editingId ? updateReturn(editingId, body) : createReturn(body),
)
const removal = useMutation((id: string) => deleteReturn(id))
function set<K extends keyof FormState>(key: K, value: string) {
setForm((f) => ({ ...f, [key]: value }))
}
function startNew() {
setForm(EMPTY_FORM)
setEditingId(null)
setValidationError(null)
setShowForm(true)
}
function startEdit(r: ReturnRecord) {
setForm(fromRecord(r))
setEditingId(r.id)
setValidationError(null)
setShowForm(true)
}
function cancelForm() {
setShowForm(false)
setEditingId(null)
setForm(EMPTY_FORM)
setValidationError(null)
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setValidationError(null)
if (!form.gerbilId && !form.gerbilName.trim()) {
setValidationError(t.validationNoAnimal)
return
}
const result = await save.run(toInput(form))
if (result.ok) {
cancelForm()
returns.reload()
}
}
async function onDelete(id: string) {
if (!window.confirm(t.confirmDelete)) return
const result = await removal.run(id)
if (result.ok) returns.reload()
}
if (returns.loading) return <p className="muted">{de.common.loading}</p>
if (returns.error) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{returns.error}</span>
<button type="button" className="btn" onClick={returns.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
const items = returns.data ?? []
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">{items.length === 0 ? t.subtitle : t.countText(items.length)}</p>
</div>
{!showForm && (
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={startNew}>
{t.newButton}
</button>
</div>
)}
</header>
{showForm && (
<form className="card" onSubmit={onSubmit} style={{ marginBottom: '1rem' }}>
<h3>{editingId ? t.editTitle : t.formTitle}</h3>
<label>
{t.fields.gerbil}
<select value={form.gerbilId} onChange={(e) => set('gerbilId', e.target.value)}>
<option value="">{t.gerbilPlaceholder}</option>
{(gerbils.data?.items ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</label>
<label>
{t.fields.gerbilName}
<input
type="text"
value={form.gerbilName}
onChange={(e) => set('gerbilName', e.target.value)}
/>
<small className="muted">{t.fields.gerbilNameHint}</small>
</label>
<label>
{t.fields.returnDate}
<input
type="date"
value={form.returnDate}
onChange={(e) => set('returnDate', e.target.value)}
/>
</label>
<label>
{t.fields.returnPrice}
<input
type="number"
step="0.01"
min="0"
value={form.returnPrice}
onChange={(e) => set('returnPrice', e.target.value)}
/>
</label>
<label>
{t.fields.originalPrice}
<input
type="number"
step="0.01"
min="0"
value={form.originalPrice}
onChange={(e) => set('originalPrice', e.target.value)}
/>
</label>
<label>
{t.fields.originalSaleDate}
<input
type="date"
value={form.originalSaleDate}
onChange={(e) => set('originalSaleDate', e.target.value)}
/>
</label>
<label>
{t.fields.fromContact}
<select value={form.fromContactId} onChange={(e) => set('fromContactId', e.target.value)}>
<option value="">{t.contactPlaceholder}</option>
{(contacts.data?.items ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label>
{t.fields.fromContactName}
<input
type="text"
value={form.fromContactName}
onChange={(e) => set('fromContactName', e.target.value)}
/>
<small className="muted">{t.fields.fromContactNameHint}</small>
</label>
<label>
{t.fields.note}
<textarea
value={form.note}
placeholder={t.fields.notePlaceholder}
onChange={(e) => set('note', e.target.value)}
/>
</label>
{validationError && <div className="alert alert--error">{validationError}</div>}
{save.error && <div className="alert alert--error">{t.saveError}</div>}
<div className="head-actions">
<button type="submit" className="btn btn--primary" disabled={save.pending}>
{save.pending ? t.saving : t.save}
</button>
<button type="button" className="btn" onClick={cancelForm} disabled={save.pending}>
{t.cancel}
</button>
</div>
</form>
)}
{removal.error && <div className="alert alert--error">{removal.error}</div>}
{items.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<ul className="card-list">
{items.map((r) => {
const displayName = r.gerbilId
? (gerbilName.get(r.gerbilId) ?? r.gerbilName ?? t.unknownAnimal)
: (r.gerbilName ?? t.unknownAnimal)
const displayContact = r.fromContactId
? (contactName.get(r.fromContactId) ?? r.fromContactName)
: r.fromContactName
return (
<li key={r.id} className="gerbil-card">
<span className="gerbil-card__name">
{r.gerbilId ? (
<Link to={`/rennmaeuse/${r.gerbilId}`}>{displayName}</Link>
) : (
displayName
)}
</span>
<span className="gerbil-card__meta">
{t.returnedOn} {formatDate(isoDateInput(r.returnDate) || null)}
{displayContact && (
<>
{' · '}
{t.from}{' '}
{r.fromContactId ? (
<Link to={`/kontakte/${r.fromContactId}`}>{displayContact}</Link>
) : (
displayContact
)}
</>
)}
{r.returnPrice != null && <> · {formatPrice(r.returnPrice)}</>}
{r.note && <> · {r.note}</>}
</span>
<span className="head-actions">
<button type="button" className="btn" onClick={() => startEdit(r)}>
{t.edit}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => onDelete(r.id)}
disabled={removal.pending}
>
{t.delete}
</button>
</span>
</li>
)
})}
</ul>
)}
</section>
)
}

View File

@@ -27,6 +27,8 @@ export const de = {
settings: 'Einstellungen',
// INBOX-1 (Kelly): Anfragen-Posteingang
requests: 'Anfragen',
// RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere
returns: 'Rücknahmen',
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation',
@@ -820,6 +822,48 @@ export const de = {
},
},
},
// ── RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere (getback_tb) ──
ruecknahmen: {
title: 'Rücknahmen',
subtitle: 'Tiere, die zur Zucht zurückgekommen sind',
countText: (n: number) =>
n === 1 ? '1 Rücknahme erfasst' : `${n} Rücknahmen erfasst`,
empty: 'Noch keine Rücknahmen erfasst.',
newButton: 'Rücknahme erfassen',
// Formular
formTitle: 'Rücknahme erfassen',
editTitle: 'Rücknahme bearbeiten',
fields: {
gerbil: 'Tier',
gerbilName: 'Tiername',
gerbilNameHint: 'Falls das Tier nicht in der Liste steht, hier den Namen eintragen.',
returnDate: 'Rücknahmedatum',
returnPrice: 'Rücknahmepreis (€)',
originalPrice: 'Ursprünglicher Abgabepreis (€)',
originalSaleDate: 'Ursprüngliches Abgabedatum',
fromContact: 'Zurück von (Kontakt)',
fromContactName: 'Name (frei)',
fromContactNameHint: 'Falls der Kontakt nicht in der Liste steht, hier den Namen eintragen.',
note: 'Grund / Notiz',
notePlaceholder: 'Warum kam das Tier zurück?',
},
gerbilPlaceholder: '— Tier wählen —',
contactPlaceholder: '— Kontakt wählen —',
// Listen-Karte
returnedOn: 'zurück am',
from: 'von',
unknownAnimal: '(unbekanntes Tier)',
// Aktionen
save: 'Speichern',
saving: 'Wird gespeichert …',
edit: 'Bearbeiten',
cancel: 'Abbrechen',
delete: 'Löschen',
confirmDelete: 'Diese Rücknahme wirklich löschen?',
validationNoAnimal: 'Bitte ein Tier wählen oder einen Tiernamen eintragen.',
saveSuccess: 'Rücknahme gespeichert.',
saveError: 'Rücknahme konnte nicht gespeichert werden.',
},
},
// ── HELP-1: In-App-Anleitung ──
hilfe: {