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:
2026-06-22 22:43:19 +02:00
parent 5e14124322
commit 4e7cd21b70
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'
@@ -427,6 +443,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)
}
@@ -440,6 +463,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

@@ -232,8 +232,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.