From 92c930c16539e306581df62eb243ef2e68724549 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 00:09:43 +0200 Subject: [PATCH] FEAT-2: Kontakte pages - list (Gridify search+paging), detail w/ linked animals (Herkunft/Abnehmer), form, delete w/ 409 conflict message --- .../src/pages/KontaktDetailPage.tsx | 149 ++++++++++++++++++ .../src/pages/KontaktFormPage.tsx | 109 +++++++++++++ gerbil-manager-web/src/pages/KontaktePage.tsx | 109 +++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 gerbil-manager-web/src/pages/KontaktDetailPage.tsx create mode 100644 gerbil-manager-web/src/pages/KontaktFormPage.tsx create mode 100644 gerbil-manager-web/src/pages/KontaktePage.tsx diff --git a/gerbil-manager-web/src/pages/KontaktDetailPage.tsx b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx new file mode 100644 index 0000000..92490d9 --- /dev/null +++ b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx @@ -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(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

{de.common.loading}

+ if (contact.error || !contact.data) { + return ( +
+

{contact.error ?? t.detail.notFound}

+ + {t.detail.back} + +
+ ) + } + + const c = contact.data + const animals = linked.data?.items ?? [] + + return ( +
+
+

{c.name}

+
+ + {t.detail.edit} + + + + {t.detail.back} + +
+
+ + {deleteError &&
{deleteError}
} + +
+
+
{t.fields.contactInfo}
+
{c.contactInfo ?? '—'}
+
+
+
{t.fields.notes}
+
{c.notes ?? '—'}
+
+
+ +

{t.linked.title}

+ {linked.loading &&

{de.common.loading}

} + {linked.error && ( +
+ {linked.error} + +
+ )} + {!linked.loading && !linked.error && animals.length === 0 && ( +

{t.linked.empty}

+ )} + {animals.length > 0 && ( +
    + {animals.map((g) => ( +
  • + + {/* z. B. "Herkunft von Flecki" bzw. "Herkunft von · Abnehmer von Flecki" */} + + {[ + g.originContactId === id ? t.linked.originRole : null, + g.receiverContactId === id ? t.linked.receiverRole : null, + ] + .filter(Boolean) + .join(' · ')}{' '} + {g.name} + + + {g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'} + + +
  • + ))} +
+ )} +
+ ) +} diff --git a/gerbil-manager-web/src/pages/KontaktFormPage.tsx b/gerbil-manager-web/src/pages/KontaktFormPage.tsx new file mode 100644 index 0000000..bfe4a94 --- /dev/null +++ b/gerbil-manager-web/src/pages/KontaktFormPage.tsx @@ -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(EMPTY) + const [errors, setErrors] = useState>>({}) + const [initializedFor, setInitializedFor] = useState(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 = (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

{de.common.loading}

+ + return ( +
+

{isEdit ? t.form.editTitle : t.form.createTitle}

+ +
+ + + + +