feat(gehege): Reinigungszyklus (Maße, Kapazität, letzte/nächste Reinigung)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,14 @@ import type { Enclosure, Paged } from './types'
|
||||
export interface CreateEnclosure {
|
||||
name: string
|
||||
notes?: string | null
|
||||
/** Maße als Freitext, z. B. "120×50 cm". */
|
||||
size?: string | null
|
||||
/** Empfohlene/maximale Tieranzahl. */
|
||||
capacity?: number | null
|
||||
/** Datum der letzten Reinigung (ISO yyyy-MM-dd). */
|
||||
lastCleanedDate?: string | null
|
||||
/** Reinigungsintervall in Tagen. */
|
||||
cleaningCycleDays?: number | null
|
||||
}
|
||||
|
||||
/** Payload for PUT /enclosures/{id}. */
|
||||
@@ -31,3 +39,8 @@ export function updateEnclosure(id: string, body: UpdateEnclosure): Promise<Encl
|
||||
export function deleteEnclosure(id: string): Promise<void> {
|
||||
return api.delete(`${resources.enclosures}/${id}`)
|
||||
}
|
||||
|
||||
/** Reinigung dokumentieren: setzt die letzte Reinigung auf heute. */
|
||||
export function markEnclosureCleaned(id: string): Promise<Enclosure> {
|
||||
return api.post<Enclosure>(`${resources.enclosures}/${id}/mark-cleaned`, {})
|
||||
}
|
||||
|
||||
@@ -143,6 +143,16 @@ export interface Enclosure {
|
||||
id: string
|
||||
name: string
|
||||
notes: string | null
|
||||
/** Maße als Freitext, z. B. "120×50 cm". */
|
||||
size: string | null
|
||||
/** Empfohlene/maximale Tieranzahl. */
|
||||
capacity: number | null
|
||||
/** Datum der letzten Reinigung (ISO yyyy-MM-dd). */
|
||||
lastCleanedDate: string | null
|
||||
/** Reinigungsintervall in Tagen. */
|
||||
cleaningCycleDays: number | null
|
||||
/** Berechnet: lastCleanedDate + cleaningCycleDays (ISO yyyy-MM-dd). */
|
||||
nextCleaningDate: string | null
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
|
||||
@@ -7,17 +7,27 @@ 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 { deleteEnclosure, getEnclosure, markEnclosureCleaned } from '../api/enclosures'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { condition } from '../api/gridify'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate } from '../format/labels'
|
||||
import { useToast } from '../components/toast'
|
||||
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
|
||||
|
||||
/** true, wenn die nächste fällige Reinigung am/vor heute liegt. */
|
||||
function isCleaningDue(nextCleaningDate: string | null): boolean {
|
||||
if (!nextCleaningDate) return false
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return nextCleaningDate <= today
|
||||
}
|
||||
|
||||
export default function BeckenDetailPage() {
|
||||
const t = de.pages.becken
|
||||
const { id = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
|
||||
const enclosure = useApi(() => getEnclosure(id), [id])
|
||||
@@ -39,6 +49,17 @@ export default function BeckenDetailPage() {
|
||||
)
|
||||
|
||||
const removal = useMutation(() => deleteEnclosure(id))
|
||||
const cleaning = useMutation(() => markEnclosureCleaned(id))
|
||||
|
||||
async function onMarkCleaned() {
|
||||
const result = await cleaning.run()
|
||||
if (result.ok) {
|
||||
toast.success(t.cleaning.marked)
|
||||
enclosure.reload()
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!window.confirm(t.delete.confirmMessage)) return
|
||||
@@ -101,15 +122,62 @@ export default function BeckenDetailPage() {
|
||||
|
||||
{deleteError && <div className="alert alert--error">{deleteError}</div>}
|
||||
|
||||
{e.notes && (
|
||||
{(e.notes || e.size || e.capacity != null) && (
|
||||
<dl className="def-list">
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.notes}</dt>
|
||||
<dd>{e.notes}</dd>
|
||||
</div>
|
||||
{e.notes && (
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.notes}</dt>
|
||||
<dd>{e.notes}</dd>
|
||||
</div>
|
||||
)}
|
||||
{e.size && (
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.size}</dt>
|
||||
<dd>{e.size}</dd>
|
||||
</div>
|
||||
)}
|
||||
{e.capacity != null && (
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.capacity}</dt>
|
||||
<dd>{t.cleaning.capacityUnit(e.capacity)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<h3>{t.cleaning.title}</h3>
|
||||
{isCleaningDue(e.nextCleaningDate) && (
|
||||
<div className="alert alert--warning" role="status">
|
||||
{e.nextCleaningDate
|
||||
? t.cleaning.dueSince(formatDate(e.nextCleaningDate))
|
||||
: t.cleaning.due}
|
||||
</div>
|
||||
)}
|
||||
<dl className="def-list">
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.lastCleanedDate}</dt>
|
||||
<dd>{e.lastCleanedDate ? formatDate(e.lastCleanedDate) : t.cleaning.neverCleaned}</dd>
|
||||
</div>
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.cleaningCycleDays}</dt>
|
||||
<dd>{e.cleaningCycleDays != null ? t.cleaning.cycleUnit(e.cleaningCycleDays) : t.cleaning.noCycle}</dd>
|
||||
</div>
|
||||
{e.nextCleaningDate && (
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.nextCleaningDate}</dt>
|
||||
<dd>{formatDate(e.nextCleaningDate)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={onMarkCleaned}
|
||||
disabled={cleaning.pending}
|
||||
>
|
||||
{cleaning.pending ? t.cleaning.marking : t.cleaning.markCleaned}
|
||||
</button>
|
||||
|
||||
<h3>{t.photosTitle}</h3>
|
||||
<EnclosurePhotosSection enclosureId={e.id} />
|
||||
|
||||
|
||||
@@ -9,13 +9,32 @@ import { useToast } from '../components/toast'
|
||||
interface FormState {
|
||||
name: string
|
||||
notes: string
|
||||
size: string
|
||||
capacity: string
|
||||
lastCleanedDate: string
|
||||
cleaningCycleDays: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { name: '', notes: '' }
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
notes: '',
|
||||
size: '',
|
||||
capacity: '',
|
||||
lastCleanedDate: '',
|
||||
cleaningCycleDays: '',
|
||||
}
|
||||
|
||||
/** "" -> null, sonst der Wert. */
|
||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
|
||||
/** "" -> null, sonst die geparste Ganzzahl (NaN -> null). */
|
||||
const ni = (s: string): number | null => {
|
||||
const t = s.trim()
|
||||
if (t === '') return null
|
||||
const n = Number.parseInt(t, 10)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
export default function BeckenFormPage() {
|
||||
const t = de.pages.becken
|
||||
const navigate = useNavigate()
|
||||
@@ -32,7 +51,14 @@ export default function BeckenFormPage() {
|
||||
// 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 ?? '' })
|
||||
setForm({
|
||||
name: existing.data.name,
|
||||
notes: existing.data.notes ?? '',
|
||||
size: existing.data.size ?? '',
|
||||
capacity: existing.data.capacity?.toString() ?? '',
|
||||
lastCleanedDate: existing.data.lastCleanedDate ?? '',
|
||||
cleaningCycleDays: existing.data.cleaningCycleDays?.toString() ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = useMutation((body: CreateEnclosure) =>
|
||||
@@ -46,7 +72,14 @@ export default function BeckenFormPage() {
|
||||
return
|
||||
}
|
||||
setErrors({})
|
||||
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
||||
const result = await mutation.run({
|
||||
name: form.name.trim(),
|
||||
notes: nn(form.notes),
|
||||
size: nn(form.size),
|
||||
capacity: ni(form.capacity),
|
||||
lastCleanedDate: nn(form.lastCleanedDate),
|
||||
cleaningCycleDays: ni(form.cleaningCycleDays),
|
||||
})
|
||||
if (result.ok) {
|
||||
toast.success(de.common.saved)
|
||||
navigate(`/gehege/${result.value.id}`)
|
||||
@@ -82,6 +115,48 @@ export default function BeckenFormPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.size}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.size}
|
||||
placeholder="120×50 cm"
|
||||
onChange={(e) => setForm((f) => ({ ...f, size: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.capacity}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.capacity}
|
||||
onChange={(e) => setForm((f) => ({ ...f, capacity: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.lastCleanedDate}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={form.lastCleanedDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, lastCleanedDate: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.cleaningCycleDays}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.cleaningCycleDays}
|
||||
onChange={(e) => setForm((f) => ({ ...f, cleaningCycleDays: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
||||
|
||||
<div className="form-actions">
|
||||
|
||||
@@ -368,6 +368,26 @@ export const de = {
|
||||
fields: {
|
||||
name: 'Name',
|
||||
notes: 'Notizen',
|
||||
// Reinigungszyklus (RennmausPro becken_tb).
|
||||
size: 'Maße',
|
||||
capacity: 'Empf. Tieranzahl',
|
||||
lastCleanedDate: 'Zuletzt gereinigt',
|
||||
cleaningCycleDays: 'Reinigungszyklus (Tage)',
|
||||
nextCleaningDate: 'Nächste Reinigung',
|
||||
},
|
||||
// Reinigungszyklus: Hinweise + Aktion auf der Detailseite.
|
||||
cleaning: {
|
||||
title: 'Reinigung',
|
||||
due: 'Reinigung fällig',
|
||||
dueSince: (date: string) => `Reinigung fällig (seit ${date})`,
|
||||
nextOn: (date: string) => `Nächste Reinigung am ${date}`,
|
||||
noCycle: 'Kein Reinigungszyklus hinterlegt.',
|
||||
neverCleaned: 'Noch nie als gereinigt vermerkt.',
|
||||
markCleaned: 'Als gereinigt markieren',
|
||||
marking: 'Wird gespeichert …',
|
||||
marked: 'Reinigung vermerkt.',
|
||||
capacityUnit: (n: number) => `${n} ${n === 1 ? 'Tier' : 'Tiere'}`,
|
||||
cycleUnit: (n: number) => `alle ${n} Tage`,
|
||||
},
|
||||
// Bilder-Sektion auf der Gehege-Detailseite.
|
||||
photosTitle: 'Bilder',
|
||||
|
||||
Reference in New Issue
Block a user