FEAT-2: Becken pages - list (Gridify search+paging), detail w/ occupancy, form, delete w/ 409 conflict message
This commit is contained in:
143
gerbil-manager-web/src/pages/BeckenDetailPage.tsx
Normal file
143
gerbil-manager-web/src/pages/BeckenDetailPage.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* FEAT-2: Becken-Detailseite — Stammdaten, BELEGUNG (welche Tiere wohnen
|
||||||
|
* aktuell hier, mit Farbschlag + Link zur Rennmaus), Löschen mit
|
||||||
|
* Bestätigung; Konflikt (Becken nicht leer) 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 { deleteEnclosure, getEnclosure } from '../api/enclosures'
|
||||||
|
import { listGerbils } from '../api/gerbils'
|
||||||
|
import { listColorVarieties } from '../api/lookups'
|
||||||
|
import { condition } from '../api/gridify'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
|
||||||
|
export default function BeckenDetailPage() {
|
||||||
|
const t = de.pages.becken
|
||||||
|
const { id = '' } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const enclosure = useApi(() => getEnclosure(id), [id])
|
||||||
|
// Belegung: alle Tiere, deren aktuelles Becken dieses ist.
|
||||||
|
const occupants = useApi(
|
||||||
|
() =>
|
||||||
|
listGerbils({
|
||||||
|
filter: condition({ field: 'enclosureId', op: '==', value: id }),
|
||||||
|
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(() => deleteEnclosure(id))
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
if (!window.confirm(t.delete.confirmMessage)) return
|
||||||
|
setDeleteError(null)
|
||||||
|
try {
|
||||||
|
await removal.run()
|
||||||
|
navigate('/becken')
|
||||||
|
} catch (err) {
|
||||||
|
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setDeleteError(t.delete.conflict)
|
||||||
|
} else {
|
||||||
|
setDeleteError(removal.error ?? de.api.errors.unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enclosure.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
if (enclosure.error || !enclosure.data) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
|
||||||
|
<Link to="/becken" className="btn">
|
||||||
|
{t.detail.back}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = enclosure.data
|
||||||
|
const animals = occupants.data?.items ?? []
|
||||||
|
const count = occupants.data?.totalCount ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{e.name}</h2>
|
||||||
|
{occupants.data && (
|
||||||
|
<p className="muted">
|
||||||
|
{count} {count === 1 ? t.occupancy.countOne : t.occupancy.countMany}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="head-actions">
|
||||||
|
<Link to={`/becken/${e.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="/becken" className="btn">
|
||||||
|
{t.detail.back}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{deleteError && <div className="alert alert--error">{deleteError}</div>}
|
||||||
|
|
||||||
|
{e.notes && (
|
||||||
|
<dl className="def-list">
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{t.fields.notes}</dt>
|
||||||
|
<dd>{e.notes}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>{t.occupancy.title}</h3>
|
||||||
|
{occupants.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{occupants.error && (
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{occupants.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={occupants.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!occupants.loading && !occupants.error && animals.length === 0 && (
|
||||||
|
<p className="muted">{t.occupancy.empty}</p>
|
||||||
|
)}
|
||||||
|
{animals.length > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{animals.map((g) => (
|
||||||
|
<li key={g.id}>
|
||||||
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||||
|
<span className="gerbil-card__name">{g.name}</span>
|
||||||
|
<span className="gerbil-card__meta">
|
||||||
|
{g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
gerbil-manager-web/src/pages/BeckenFormPage.tsx
Normal file
90
gerbil-manager-web/src/pages/BeckenFormPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/** FEAT-2: Becken anlegen/bearbeiten — Name (Pflicht) + Notizen. */
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { createEnclosure, getEnclosure, updateEnclosure, type CreateEnclosure } from '../api/enclosures'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: FormState = { name: '', notes: '' }
|
||||||
|
|
||||||
|
/** "" -> null, sonst der Wert. */
|
||||||
|
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||||
|
|
||||||
|
export default function BeckenFormPage() {
|
||||||
|
const t = de.pages.becken
|
||||||
|
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 ? getEnclosure(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, notes: existing.data.notes ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation((body: CreateEnclosure) =>
|
||||||
|
isEdit && id ? updateEnclosure(id, body) : createEnclosure(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(), notes: nn(form.notes) })
|
||||||
|
navigate(`/becken/${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) => setForm((f) => ({ ...f, 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.notes}</span>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, 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 ? `/becken/${id}` : '/becken'} className="btn">
|
||||||
|
{t.form.cancel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
gerbil-manager-web/src/pages/BeckenPage.tsx
Normal file
109
gerbil-manager-web/src/pages/BeckenPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { listEnclosuresPaged } from '../api/enclosures'
|
||||||
|
import { condition, type GridifyQuery } from '../api/gridify'
|
||||||
|
import { useApi } from '../hooks/useApi'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export default function BeckenPage() {
|
||||||
|
const t = de.pages.becken
|
||||||
|
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 enclosures = useApi(() => listEnclosuresPaged(query), [filter, page])
|
||||||
|
|
||||||
|
const total = enclosures.data?.totalCount ?? 0
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
const items = enclosures.data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
{enclosures.data && (
|
||||||
|
<p className="muted">
|
||||||
|
{total} {t.countLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link to="/becken/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>
|
||||||
|
|
||||||
|
{enclosures.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{enclosures.error && (
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{enclosures.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={enclosures.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!enclosures.loading && !enclosures.error && items.length === 0 && (
|
||||||
|
<p className="muted">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{items.map((e) => (
|
||||||
|
<li key={e.id}>
|
||||||
|
<Link to={`/becken/${e.id}`} className="gerbil-card">
|
||||||
|
<span className="gerbil-card__name">{e.name}</span>
|
||||||
|
<span className="gerbil-card__meta">{e.notes ?? ''}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user