feat(warteliste): Nachfrage/Warteliste für Interessenten
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
56
gerbil-manager-web/src/api/waitingList.ts
Normal file
56
gerbil-manager-web/src/api/waitingList.ts
Normal 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}`)
|
||||
}
|
||||
@@ -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: '📄' },
|
||||
|
||||
375
gerbil-manager-web/src/pages/WartelistePage.tsx
Normal file
375
gerbil-manager-web/src/pages/WartelistePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user