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:
149
gerbil-manager-web/src/pages/KontaktDetailPage.tsx
Normal file
149
gerbil-manager-web/src/pages/KontaktDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user