Merge branch 'worktree-agent-a9d8edbd439109a45'

This commit is contained in:
2026-06-22 22:53:33 +02:00
15 changed files with 2071 additions and 14 deletions

View File

@@ -25,6 +25,35 @@ test.describe('Becken', () => {
await expect(page.getByText(tb.delete.conflict)).toBeVisible()
})
test('Reinigungszyklus: überfälliges Becken zeigt Hinweis + Als gereinigt markieren', async ({ page }) => {
skipUnlessMock()
await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click()
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
// Maße + Kapazität werden angezeigt
await expect(page.getByText('120×50 cm')).toBeVisible()
// Reinigung ist überfällig (nextCleaningDate in der Vergangenheit) -> Warn-Hinweis
await expect(page.getByText(/Reinigung fällig/)).toBeVisible()
// Als gereinigt markieren -> letzte Reinigung = heute, Hinweis verschwindet
await page.getByRole('button', { name: tb.cleaning.markCleaned }).click()
await expect(page.getByText(/Reinigung fällig/)).toBeHidden()
})
test('Reinigungszyklus: Becken mit Zyklus anlegen zeigt nächste Reinigung', async ({ page }) => {
await gotoSection(page, de.nav.enclosures)
await page.getByRole('link', { name: tb.newButton }).click()
const name = uniqueName('Zyklusbecken')
await page.getByLabel(`${tb.fields.name} *`).fill(name)
await page.getByLabel(tb.fields.size).fill('60×40 cm')
await page.getByLabel(tb.fields.capacity).fill('4')
await page.getByLabel(tb.fields.lastCleanedDate).fill('2026-06-01')
await page.getByLabel(tb.fields.cleaningCycleDays).fill('14')
await page.getByRole('button', { name: tb.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
// nächste Reinigung = 2026-06-01 + 14 Tage = 15.06.2026 (in der def-list-Zeile)
await expect(page.getByText('15.06.2026', { exact: true })).toBeVisible()
})
test('Becken anlegen, bearbeiten und (leer) löschen', async ({ page }) => {
await gotoSection(page, de.nav.enclosures)
await page.getByRole('link', { name: tb.newButton }).click()

View File

@@ -261,6 +261,22 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
// Gehege-Reinigungszyklus: nächste fällige Reinigung = letzte Reinigung + Zyklus (Tage).
const nextCleaning = (lastCleaned?: string | null, cycleDays?: number | null): string | null => {
if (!lastCleaned || cycleDays == null || cycleDays <= 0) return null
const d = new Date(`${lastCleaned}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + cycleDays)
return d.toISOString().slice(0, 10)
}
// "Als gereinigt markieren": setzt letzte Reinigung auf heute (wie das echte Backend).
m = path.match(/^\/enclosures\/([^/]+)\/mark-cleaned$/)
if (m && method === 'POST') {
const enc = db.enclosures.find((x) => x.id === m![1])
if (!enc) return json(route, 404, { title: 'Not Found' })
enc.lastCleanedDate = new Date().toISOString().slice(0, 10)
enc.nextCleaningDate = nextCleaning(enc.lastCleanedDate, enc.cleaningCycleDays)
return json(route, 200, enc)
}
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
if (m) {
const isKruemel = m[1] === 'kruemel'
@@ -528,6 +544,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = { id: newId(col.idPrefix), ...body }
// Gehege: berechnetes Feld nextCleaningDate wie das echte Backend ableiten.
if (m[1] === 'enclosures') {
created.nextCleaningDate = nextCleaning(
created.lastCleanedDate as string | null,
created.cleaningCycleDays as number | null,
)
}
col.rows.push(created)
return json(route, 201, created)
}
@@ -541,6 +564,12 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'PUT') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(col.rows[idx], request.postDataJSON() as Row)
if (m[1] === 'enclosures') {
col.rows[idx].nextCleaningDate = nextCleaning(
col.rows[idx].lastCleanedDate as string | null,
col.rows[idx].cleaningCycleDays as number | null,
)
}
return json(route, 200, col.rows[idx])
}
if (method === 'DELETE') {

View File

@@ -239,8 +239,28 @@ export function seedDb(): MockDb {
]
const enclosures: Enclosure[] = [
{ id: 'enc-gross', name: 'Großbecken', notes: '120×50 cm' },
{ id: 'enc-leer', name: 'Quarantänebecken', notes: null },
// Reinigung überfällig: letzte Reinigung lange her + Zyklus -> nextCleaningDate in der Vergangenheit.
{
id: 'enc-gross',
name: 'Großbecken',
notes: null,
size: '120×50 cm',
capacity: 6,
lastCleanedDate: '2020-01-01',
cleaningCycleDays: 14,
nextCleaningDate: '2020-01-15',
},
// Kein Reinigungszyklus hinterlegt.
{
id: 'enc-leer',
name: 'Quarantänebecken',
notes: null,
size: null,
capacity: null,
lastCleanedDate: null,
cleaningCycleDays: null,
nextCleaningDate: null,
},
]
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.

View File

@@ -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`, {})
}

View File

@@ -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 {

View File

@@ -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} />

View File

@@ -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">

View File

@@ -372,6 +372,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',