FEAT-2: Kontakte pages - list (Gridify search+paging), detail w/ linked animals (Herkunft/Abnehmer), form, delete w/ 409 conflict message

This commit is contained in:
2026-06-06 00:09:43 +02:00
parent d8e94f6d05
commit 92c930c165
3 changed files with 367 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
/**
* FEAT-2: Kontakt-Detailseite — Stammdaten + verknüpfte Tiere
* (Herkunft von / Abnehmer von, mit Farbschlag + Link zur Rennmaus),
* Löschen mit Bestätigung; Referenz-Konflikt wird deutsch gemeldet.
*/
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { deleteContact, getContact } from '../api/contacts'
import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi'
export default function KontaktDetailPage() {
const t = de.pages.kontakte
const { id = '' } = useParams()
const navigate = useNavigate()
const [deleteError, setDeleteError] = useState<string | null>(null)
const contact = useApi(() => getContact(id), [id])
// Verknüpfte Tiere: Kontakt ist Herkunft ODER Abnehmer (Gridify-OR via |).
const linked = useApi(
() =>
listGerbils({
filter: [
condition({ field: 'originContactId', op: '==', value: id }),
condition({ field: 'receiverContactId', op: '==', value: id }),
].join('|'),
orderBy: 'name',
page: 1,
pageSize: 500,
}),
[id],
)
const colorVarieties = useApi(() => listColorVarieties(), [])
const colorNameById = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const removal = useMutation(() => deleteContact(id))
async function onDelete() {
if (!window.confirm(t.delete.confirmMessage)) return
setDeleteError(null)
try {
await removal.run()
navigate('/kontakte')
} catch (err) {
// Backend meldet Konflikt, wenn der Kontakt noch referenziert wird.
if (err instanceof ApiError && err.status === 409) {
setDeleteError(t.delete.conflict)
} else {
setDeleteError(removal.error ?? de.api.errors.unknown)
}
}
}
if (contact.loading) return <p className="muted">{de.common.loading}</p>
if (contact.error || !contact.data) {
return (
<section className="page">
<p className="muted">{contact.error ?? t.detail.notFound}</p>
<Link to="/kontakte" className="btn">
{t.detail.back}
</Link>
</section>
)
}
const c = contact.data
const animals = linked.data?.items ?? []
return (
<section className="page">
<header className="page-head">
<h2>{c.name}</h2>
<div className="head-actions">
<Link to={`/kontakte/${c.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<button
type="button"
className="btn btn--danger"
onClick={onDelete}
disabled={removal.pending}
>
{t.delete.action}
</button>
<Link to="/kontakte" className="btn">
{t.detail.back}
</Link>
</div>
</header>
{deleteError && <div className="alert alert--error">{deleteError}</div>}
<dl className="def-list">
<div className="def-row">
<dt>{t.fields.contactInfo}</dt>
<dd>{c.contactInfo ?? '—'}</dd>
</div>
<div className="def-row">
<dt>{t.fields.notes}</dt>
<dd>{c.notes ?? '—'}</dd>
</div>
</dl>
<h3>{t.linked.title}</h3>
{linked.loading && <p className="muted">{de.common.loading}</p>}
{linked.error && (
<div className="alert alert--error">
<span>{linked.error}</span>
<button type="button" className="btn" onClick={linked.reload}>
{de.common.retry}
</button>
</div>
)}
{!linked.loading && !linked.error && animals.length === 0 && (
<p className="muted">{t.linked.empty}</p>
)}
{animals.length > 0 && (
<ul className="card-list">
{animals.map((g) => (
<li key={g.id}>
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
{/* z. B. "Herkunft von Flecki" bzw. "Herkunft von · Abnehmer von Flecki" */}
<span className="gerbil-card__name">
{[
g.originContactId === id ? t.linked.originRole : null,
g.receiverContactId === id ? t.linked.receiverRole : null,
]
.filter(Boolean)
.join(' · ')}{' '}
{g.name}
</span>
<span className="gerbil-card__meta">
{g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -0,0 +1,109 @@
/** FEAT-2: Kontakt anlegen/bearbeiten — Name (Pflicht), Kontaktdaten, Notizen. */
import { useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createContact, getContact, updateContact, type CreateContact } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
interface FormState {
name: string
contactInfo: string
notes: string
}
const EMPTY: FormState = { name: '', contactInfo: '', notes: '' }
/** "" -> null, sonst der Wert. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
export default function KontaktFormPage() {
const t = de.pages.kontakte
const navigate = useNavigate()
const { id } = useParams()
const isEdit = Boolean(id)
const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const [initializedFor, setInitializedFor] = useState<string | null>(null)
const existing = useApi(() => (id ? getContact(id) : Promise.resolve(null)), [id])
// Vorbefüllen im Bearbeiten-Modus (adjust-state-during-render, wie FEAT-1).
if (existing.data && initializedFor !== existing.data.id) {
setInitializedFor(existing.data.id)
setForm({
name: existing.data.name,
contactInfo: existing.data.contactInfo ?? '',
notes: existing.data.notes ?? '',
})
}
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const mutation = useMutation((body: CreateContact) =>
isEdit && id ? updateContact(id, body) : createContact(body),
)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (form.name.trim() === '') {
setErrors({ name: t.validation.nameRequired })
return
}
setErrors({})
const saved = await mutation.run({
name: form.name.trim(),
contactInfo: nn(form.contactInfo),
notes: nn(form.notes),
})
navigate(`/kontakte/${saved.id}`)
}
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
return (
<section className="page">
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
<form className="form" onSubmit={onSubmit} noValidate>
<label className="field">
<span>{t.fields.name} *</span>
<input
className="input"
value={form.name}
onChange={(e) => set('name', e.target.value)}
aria-invalid={Boolean(errors.name)}
/>
{errors.name && <small className="error-text">{errors.name}</small>}
</label>
<label className="field">
<span>{t.fields.contactInfo}</span>
<input
className="input"
value={form.contactInfo}
onChange={(e) => set('contactInfo', e.target.value)}
/>
<small className="muted">{t.form.contactInfoHint}</small>
</label>
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label>
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
{mutation.pending ? t.form.saving : t.form.save}
</button>
<Link to={isEdit && id ? `/kontakte/${id}` : '/kontakte'} className="btn">
{t.form.cancel}
</Link>
</div>
</form>
</section>
)
}

View File

@@ -0,0 +1,109 @@
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listContactsPaged } from '../api/contacts'
import { condition, type GridifyQuery } from '../api/gridify'
import { useApi } from '../hooks/useApi'
const PAGE_SIZE = 20
export default function KontaktePage() {
const t = de.pages.kontakte
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const filter = search.trim()
? condition({ field: 'name', op: 'contains', value: search.trim() })
: undefined
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
const contacts = useApi(() => listContactsPaged(query), [filter, page])
const total = contacts.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = contacts.data?.items ?? []
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
{contacts.data && (
<p className="muted">
{total} {t.countLabel}
</p>
)}
</div>
<Link to="/kontakte/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
<div className="filters">
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
aria-label={t.fields.name}
/>
</div>
{contacts.loading && <p className="muted">{de.common.loading}</p>}
{contacts.error && (
<div className="alert alert--error">
<span>{contacts.error}</span>
<button type="button" className="btn" onClick={contacts.reload}>
{de.common.retry}
</button>
</div>
)}
{!contacts.loading && !contacts.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{items.length > 0 && (
<ul className="card-list">
{items.map((c) => (
<li key={c.id}>
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
<span className="gerbil-card__name">{c.name}</span>
<span className="gerbil-card__meta">{c.contactInfo ?? ''}</span>
</Link>
</li>
))}
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label="Seitennavigation">
<button
type="button"
className="btn"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
)}
</section>
)
}