feat(warteliste): Nachfrage/Warteliste für Interessenten

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 d25d2ee152
17 changed files with 2724 additions and 0 deletions

View File

@@ -416,6 +416,73 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
// WAITLIST: Warteliste/Nachfrage — GET liefert ein BLANKES Array (kein Gridify-Envelope),
// POST/PUT/DELETE wie eine einfache Kollektion. Vor den generischen Kollektionen, weil
// die GET-Antwort kein paginiertes Envelope ist.
const wlm = path.match(/^\/waiting-list(?:\/([^/]+))?$/)
if (wlm) {
const wlId = wlm[1] ? decodeURIComponent(wlm[1]) : null
const ALLOWED = ['offen', 'erfuellt', 'storniert']
const normStatus = (s: unknown): string | null => {
if (s === undefined || s === null || String(s).trim() === '') return 'offen'
const v = String(s).trim()
return ALLOWED.includes(v) ? v : null
}
if (!wlId) {
if (method === 'GET') {
// Neueste Anfrage zuerst (requestedAt desc, dann createdAt desc).
const sorted = [...db.waitingList].sort((a, b) => {
const ar = String(a.requestedAt ?? '')
const br = String(b.requestedAt ?? '')
if (ar !== br) return ar < br ? 1 : -1
return String(a.createdAt ?? '') < String(b.createdAt ?? '') ? 1 : -1
})
return json(route, 200, sorted)
}
if (method === 'POST') {
const body = request.postDataJSON() as Record<string, unknown>
const status = normStatus(body.status)
if (status === null) return json(route, 400, 'Ungültiger Status.')
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
const created = {
id: newId('wl'),
contactId: body.contactId ?? null,
contactName: body.contactName ?? null,
wishColor: body.wishColor ?? null,
wishGender: body.wishGender ?? null,
requestedAt: body.requestedAt ?? null,
status,
note: body.note ?? null,
createdAt: new Date().toISOString(),
}
db.waitingList.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const wlIdx = db.waitingList.findIndex((r) => r.id === wlId)
if (method === 'GET') {
return wlIdx >= 0 ? json(route, 200, db.waitingList[wlIdx]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'PUT') {
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as Record<string, unknown>
const status = normStatus(body.status)
if (status === null) return json(route, 400, 'Ungültiger Status.')
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
Object.assign(db.waitingList[wlIdx], { ...body, status })
return json(route, 200, db.waitingList[wlIdx])
}
if (method === 'DELETE') {
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
db.waitingList.splice(wlIdx, 1)
return json(route, 204)
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -78,6 +78,8 @@ export interface MockDb {
namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
waitingList: Record<string, unknown>[]
}
function gerbil(
@@ -376,5 +378,29 @@ export function seedDb(): MockDb {
saleAdConfigured: true,
namesConfigured: true,
feedback: [],
waitingList: [
{
id: 'wl-seed-1',
contactId: contacts[0]?.id ?? null,
contactName: contacts[0]?.name ?? 'Familie Sonntag',
wishColor: 'Schwarz',
wishGender: 'female',
requestedAt: '2026-05-01T00:00:00Z',
status: 'offen',
note: 'möchte zwei Weibchen',
createdAt: '2026-05-01T00:00:00Z',
},
{
id: 'wl-seed-2',
contactId: null,
contactName: 'Herr Maier',
wishColor: null,
wishGender: 'male',
requestedAt: '2026-04-10T00:00:00Z',
status: 'erfuellt',
note: null,
createdAt: '2026-04-10T00:00:00Z',
},
],
}
}

View File

@@ -0,0 +1,73 @@
/** WAITLIST: Warteliste/Nachfrage — Liste, Anlegen, Status setzen, "nur offene" filtern. */
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.warteliste
test('Warteliste ist über die Navigation erreichbar und zeigt Seed-Einträge', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.waitingList)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Seed: ein Kontakt-Link + ein freitextlicher Name.
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeVisible()
await expect(page.getByText('Herr Maier')).toBeVisible()
})
test('"Nur offene" filtert erfüllte Einträge aus', async ({ page }) => {
skipUnlessMock()
await page.goto('/warteliste')
await expect(page.getByText('Herr Maier')).toBeVisible() // erfüllt
await page.getByLabel(t.onlyOpen).check()
await expect(page.getByText('Herr Maier')).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeVisible() // offen
})
test('Neuer Eintrag anlegen erscheint in der Liste', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/warteliste')
await page.getByRole('button', { name: `+ ${t.newButton}` }).click()
const form = page.getByRole('form', { name: t.formTitleNew })
await expect(form).toBeVisible()
await form.getByLabel(t.fields.contactName).fill('Neuinteressent Test')
await form.getByLabel(t.fields.wishColor).selectOption({ label: 'Schwarz' })
await form.getByLabel(t.fields.wishGender).selectOption({ label: t.wishGender.male })
await form.getByRole('button', { name: t.save }).click()
await expect(page.getByText(de.common.saved)).toBeVisible()
await expect(page.getByText('Neuinteressent Test')).toBeVisible()
expect(mockDb).not.toBeNull()
const created = mockDb!.waitingList.find((e) => e.contactName === 'Neuinteressent Test')
expect(created).toBeTruthy()
expect(created).toMatchObject({ status: 'offen', wishColor: 'Schwarz', wishGender: 'male' })
})
test('Status je Eintrag direkt umstellbar', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/warteliste')
// Status-Select des offenen Seed-Eintrags (Zoohandlung Meier) auf "erfüllt" stellen.
const statusSelect = page.getByLabel(`${t.fields.status} Zoohandlung Meier`)
await statusSelect.selectOption({ label: t.status.erfuellt })
await expect(page.getByText(de.common.saved)).toBeVisible()
expect(mockDb!.waitingList.find((e) => e.id === 'wl-seed-1')?.status).toBe('erfuellt')
})
test('Eintrag löschen entfernt ihn aus der Liste', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/warteliste')
await expect(page.getByText('Herr Maier')).toBeVisible()
acceptNextDialog(page)
// Löschen-Button im Karten-Block von Herr Maier (zweiter Eintrag).
const card = page.locator('li', { hasText: 'Herr Maier' })
await card.getByRole('button', { name: t.delete }).click()
await expect(page.getByText(de.common.deleted)).toBeVisible()
await expect(page.getByText('Herr Maier')).toHaveCount(0)
expect(mockDb!.waitingList.find((e) => e.id === 'wl-seed-2')).toBeUndefined()
})

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 WartelistePage from './pages/WartelistePage'
function BeckenRedirect() {
const { '*': splat } = useParams()
@@ -90,6 +91,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

@@ -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: '📨' },
// 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

@@ -27,6 +27,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',
@@ -820,6 +822,52 @@ export const de = {
},
},
},
// ── 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: {