Merge branch 'worktree-agent-a0e6f31173772c798'

# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/strings/de.ts
This commit is contained in:
2026-06-22 23:01:14 +02:00
17 changed files with 2723 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ import WebseiteEditorPage from './pages/WebseiteEditorPage'
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage'
import WartelistePage from './pages/WartelistePage'
function BeckenRedirect() {
const { '*': splat } = useParams()
@@ -101,6 +102,8 @@ export default function App() {
<Route index element={<AnfragenPage />} />
<Route path=":id" element={<AnfrageDetailPage />} />
</Route>
{/* WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb) */}
<Route path="warteliste" element={<WartelistePage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>

View File

@@ -0,0 +1,56 @@
/**
* WAITLIST (RennmausPro nachfrage_tb): API client for the prospective-buyer waiting list.
* Full CRUD against /waiting-list (GET list, POST, PUT /{id}, DELETE /{id}).
*
* Entries are decoupled from contacts on the backend (loose nullable contactId, no FK),
* so they survive the import re-ingest wipe — same pattern as Feedback.
*/
import { api } from './client'
const RESOURCE = '/waiting-list'
/** Workflow status (matches the backend contract). */
export type WaitingListStatus = 'offen' | 'erfuellt' | 'storniert'
export const WAITING_LIST_STATUSES: WaitingListStatus[] = ['offen', 'erfuellt', 'storniert']
/** Wished-for gender; null = no preference. */
export type WishGender = 'male' | 'female' | null
export interface WaitingListEntry {
id: string
contactId: string | null
contactName: string | null
wishColor: string | null
wishGender: string | null
requestedAt: string | null
status: string
note: string | null
createdAt: string
}
/** Payload for POST/PUT /waiting-list. */
export interface WaitingListInput {
contactId?: string | null
contactName?: string | null
wishColor?: string | null
wishGender?: string | null
requestedAt?: string | null
status: WaitingListStatus
note?: string | null
}
export function listWaitingList(): Promise<WaitingListEntry[]> {
return api.get<WaitingListEntry[]>(RESOURCE)
}
export function createWaitingListEntry(body: WaitingListInput): Promise<WaitingListEntry> {
return api.post<WaitingListEntry>(RESOURCE, body)
}
export function updateWaitingListEntry(id: string, body: WaitingListInput): Promise<WaitingListEntry> {
return api.put<WaitingListEntry>(`${RESOURCE}/${id}`, body)
}
export function deleteWaitingListEntry(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -28,6 +28,8 @@ const SECONDARY: NavItem[] = [
{ to: '/reservierungen', label: de.nav.reservations, icon: '🔖' },
// INBOX-1: Anfragen-Posteingang
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
{ to: '/warteliste', label: de.nav.waitingList, 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,375 @@
/**
* WAITLIST (RennmausPro nachfrage_tb): Warteliste/Nachfrage.
*
* Interessenten mit Wunschkriterien (Farbschlag, Geschlecht), Anfragedatum,
* Status (offen | erfuellt | storniert) und Notiz. Anlegen/Bearbeiten über ein
* Inline-Formular; Status direkt je Eintrag umstellbar; Filter „nur offene".
* Wunsch-Farbschlag aus dem bestehenden Farbkatalog wählbar; Kontakt-Bezug als Link.
*
* Entkoppelt vom Kontakt (lose contactId ohne FK) → überlebt den Import-Re-Ingest.
*/
import { useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from '../components/toast'
import { listContacts, listColorVarieties } from '../api/lookups'
import {
listWaitingList,
createWaitingListEntry,
updateWaitingListEntry,
deleteWaitingListEntry,
WAITING_LIST_STATUSES,
type WaitingListEntry,
type WaitingListInput,
type WaitingListStatus,
} from '../api/waitingList'
interface FormState {
contactId: string
contactName: string
wishColor: string
wishGender: '' | 'male' | 'female'
requestedAt: string
status: WaitingListStatus
note: string
}
const EMPTY: FormState = {
contactId: '',
contactName: '',
wishColor: '',
wishGender: '',
requestedAt: '',
status: 'offen',
note: '',
}
/** "" -> null, sonst der Wert. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
function isStatus(value: string): value is WaitingListStatus {
return (WAITING_LIST_STATUSES as string[]).includes(value)
}
export default function WartelistePage() {
const t = de.pages.warteliste
const toast = useToast()
const entries = useApi(() => listWaitingList(), [])
const contacts = useApi(() => listContacts(), [])
const colors = useApi(() => listColorVarieties(), [])
const [onlyOpen, setOnlyOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) // null = nicht im Formular, '' = neu
const [form, setForm] = useState<FormState>(EMPTY)
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const saveMutation = useMutation((args: { id: string | null; body: WaitingListInput }) =>
args.id ? updateWaitingListEntry(args.id, args.body) : createWaitingListEntry(args.body),
)
const deleteMutation = useMutation((id: string) => deleteWaitingListEntry(id))
const contactNameById = useMemo(() => {
const map = new Map<string, string>()
for (const c of contacts.data ?? []) map.set(c.id, c.name)
return map
}, [contacts.data])
const visible = useMemo(() => {
const rows = entries.data ?? []
return onlyOpen ? rows.filter((e) => e.status === 'offen') : rows
}, [entries.data, onlyOpen])
function openNew() {
setForm(EMPTY)
setEditingId('')
}
function openEdit(e: WaitingListEntry) {
setForm({
contactId: e.contactId ?? '',
contactName: e.contactName ?? '',
wishColor: e.wishColor ?? '',
wishGender: e.wishGender === 'male' || e.wishGender === 'female' ? e.wishGender : '',
requestedAt: e.requestedAt ? e.requestedAt.slice(0, 10) : '',
status: isStatus(e.status) ? e.status : 'offen',
note: e.note ?? '',
})
setEditingId(e.id)
}
function closeForm() {
setEditingId(null)
}
function buildBody(): WaitingListInput {
return {
contactId: nn(form.contactId),
contactName: nn(form.contactName),
wishColor: nn(form.wishColor),
wishGender: form.wishGender === '' ? null : form.wishGender,
requestedAt: form.requestedAt ? new Date(`${form.requestedAt}T00:00:00Z`).toISOString() : null,
status: form.status,
note: nn(form.note),
}
}
async function onSubmit(ev: FormEvent) {
ev.preventDefault()
if (!form.contactId && form.contactName.trim() === '') {
toast.error(t.validationName)
return
}
const result = await saveMutation.run({ id: editingId || null, body: buildBody() })
if (result.ok) {
toast.success(de.common.saved)
closeForm()
entries.reload()
} else {
toast.error(result.error)
}
}
/** Schnell-Statuswechsel direkt in der Liste (sendet den vollständigen Eintrag mit). */
async function changeStatus(entry: WaitingListEntry, status: WaitingListStatus) {
const body: WaitingListInput = {
contactId: entry.contactId,
contactName: entry.contactName,
wishColor: entry.wishColor,
wishGender: entry.wishGender,
requestedAt: entry.requestedAt,
status,
note: entry.note,
}
const result = await saveMutation.run({ id: entry.id, body })
if (result.ok) {
toast.success(de.common.saved)
entries.reload()
} else {
toast.error(result.error)
}
}
async function onDelete(entry: WaitingListEntry) {
if (!window.confirm(t.deleteConfirm)) return
const result = await deleteMutation.run(entry.id)
if (result.ok) {
toast.success(de.common.deleted)
entries.reload()
} else {
toast.error(result.error)
}
}
const statusLabel = (status: string): string =>
isStatus(status) ? t.status[status] : status
const genderLabel = (g: string | null): string =>
g === 'male' ? t.wishGender.male : g === 'female' ? t.wishGender.female : t.wishGender.any
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">{t.subtitle}</p>
{!entries.loading && !entries.error && (
<p className="muted">
{visible.length} {t.countLabel}
</p>
)}
</div>
<button type="button" className="btn btn--primary" onClick={openNew}>
+ {t.newButton}
</button>
</header>
<div className="filters">
<label className="field field--check">
<span>{t.onlyOpen}</span>
<input
type="checkbox"
checked={onlyOpen}
onChange={(ev) => setOnlyOpen(ev.target.checked)}
aria-label={t.onlyOpen}
/>
</label>
</div>
{editingId !== null && (
<form className="form" onSubmit={onSubmit} noValidate aria-label={editingId ? t.formTitleEdit : t.formTitleNew}>
<h3>{editingId ? t.formTitleEdit : t.formTitleNew}</h3>
<label className="field">
<span>{t.fields.contact}</span>
<select
className="input"
value={form.contactId}
onChange={(ev) => set('contactId', ev.target.value)}
>
<option value="">{t.fields.contactNone}</option>
{(contacts.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.contactName}</span>
<input
className="input"
value={form.contactName}
onChange={(ev) => set('contactName', ev.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.wishColor}</span>
<select
className="input"
value={form.wishColor}
onChange={(ev) => set('wishColor', ev.target.value)}
>
<option value="">{t.fields.wishColorAny}</option>
{(colors.data ?? []).map((c) => (
<option key={c.id} value={c.name}>
{c.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.wishGender}</span>
<select
className="input"
value={form.wishGender}
onChange={(ev) => set('wishGender', ev.target.value as FormState['wishGender'])}
>
<option value="">{t.wishGender.any}</option>
<option value="female">{t.wishGender.female}</option>
<option value="male">{t.wishGender.male}</option>
</select>
</label>
<label className="field">
<span>{t.fields.requestedAt}</span>
<input
className="input"
type="date"
value={form.requestedAt}
onChange={(ev) => set('requestedAt', ev.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.status}</span>
<select
className="input"
value={form.status}
onChange={(ev) => set('status', ev.target.value as WaitingListStatus)}
>
{WAITING_LIST_STATUSES.map((s) => (
<option key={s} value={s}>
{t.status[s]}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.note}</span>
<textarea value={form.note} onChange={(ev) => set('note', ev.target.value)} />
</label>
{saveMutation.error && <div className="alert alert--error">{saveMutation.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={saveMutation.pending}>
{t.save}
</button>
<button type="button" className="btn" onClick={closeForm}>
{t.cancel}
</button>
</div>
</form>
)}
{entries.loading && <p className="muted">{de.common.loading}</p>}
{entries.error && (
<div className="alert alert--error">
<span>{t.loadError}</span>
<button type="button" className="btn" onClick={entries.reload}>
{de.common.retry}
</button>
</div>
)}
{!entries.loading && !entries.error && visible.length === 0 && (
<p className="muted">{onlyOpen ? t.emptyOpen : t.empty}</p>
)}
{visible.length > 0 && (
<ul className="card-list">
{visible.map((e) => {
const contactName = e.contactId ? contactNameById.get(e.contactId) : null
return (
<li key={e.id}>
<div className="gerbil-card" style={{ display: 'block' }}>
<div className="gerbil-card__name">
{e.contactId && contactName ? (
<Link to={`/kontakte/${e.contactId}`}>{contactName}</Link>
) : (
(e.contactName ?? t.noContactLink)
)}
{' — '}
<span className={`badge badge--${e.status}`}>{statusLabel(e.status)}</span>
</div>
<div className="gerbil-card__meta">
{(e.wishColor ?? t.fields.wishColorAny)} · {genderLabel(e.wishGender)}
{e.requestedAt ? ` · ${e.requestedAt.slice(0, 10)}` : ''}
</div>
{e.note && <p className="muted">{e.note}</p>}
<div className="form-actions">
<label className="field">
<span>{t.fields.status}</span>
<select
className="input"
value={isStatus(e.status) ? e.status : 'offen'}
onChange={(ev) => changeStatus(e, ev.target.value as WaitingListStatus)}
aria-label={`${t.fields.status} ${e.contactName ?? contactName ?? ''}`.trim()}
disabled={saveMutation.pending}
>
{WAITING_LIST_STATUSES.map((s) => (
<option key={s} value={s}>
{t.status[s]}
</option>
))}
</select>
</label>
<button type="button" className="btn" onClick={() => openEdit(e)}>
{t.edit}
</button>
<button
type="button"
className="btn"
onClick={() => onDelete(e)}
disabled={deleteMutation.pending}
>
{t.delete}
</button>
</div>
</div>
</li>
)
})}
</ul>
)}
</section>
)
}

View File

@@ -29,6 +29,8 @@ export const de = {
settings: 'Einstellungen',
// INBOX-1 (Kelly): Anfragen-Posteingang
requests: 'Anfragen',
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
waitingList: 'Warteliste',
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation',
@@ -997,6 +999,52 @@ export const de = {
errorTitle: 'Es ist ein Fehler aufgetreten',
restart: 'Neue Datei auswählen',
},
// ── WAITLIST (RennmausPro nachfrage_tb): Warteliste/Nachfrage ──
warteliste: {
title: 'Warteliste',
subtitle: 'Interessenten warten auf bestimmte Tiere',
newButton: 'Neuer Eintrag',
empty: 'Keine Wartelisten-Einträge vorhanden.',
emptyOpen: 'Keine offenen Einträge.',
countLabel: 'Einträge',
onlyOpen: 'Nur offene',
loadError: 'Die Warteliste konnte nicht geladen werden.',
saveError: 'Der Eintrag konnte nicht gespeichert werden.',
deleteError: 'Der Eintrag konnte nicht gelöscht werden.',
deleteConfirm: 'Diesen Wartelisten-Eintrag wirklich löschen?',
// Status-Bezeichnungen (Backend-Werte: offen | erfuellt | storniert)
status: {
offen: 'Offen',
erfuellt: 'Erfüllt',
storniert: 'Storniert',
},
// Geschlechts-Wunsch
wishGender: {
any: 'Egal',
male: 'Männlich',
female: 'Weiblich',
},
fields: {
contact: 'Kontakt',
contactNone: '— kein Kontakt —',
contactName: 'Name (falls kein Kontakt)',
wishColor: 'Wunsch-Farbschlag',
wishColorAny: 'Egal',
wishGender: 'Wunsch-Geschlecht',
requestedAt: 'Angefragt am',
status: 'Status',
note: 'Notiz',
},
// Aktionen je Eintrag
edit: 'Bearbeiten',
delete: 'Löschen',
save: 'Speichern',
cancel: 'Abbrechen',
formTitleNew: 'Neuer Wartelisten-Eintrag',
formTitleEdit: 'Eintrag bearbeiten',
validationName: 'Bitte einen Kontakt wählen oder einen Namen eingeben.',
noContactLink: 'Kein Kontakt verknüpft',
},
},
// ── HELP-1: In-App-Anleitung ──
hilfe: {