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:
118
GerbilManager.Tests/EnclosureEndpointTests.cs
Normal file
118
GerbilManager.Tests/EnclosureEndpointTests.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gehege-Reinigungszyklus (RennmausPro becken_tb): Enclosure trägt Maße (Size),
|
||||||
|
/// Kapazität (Capacity), letzte Reinigung (LastCleanedDate) und Reinigungsintervall
|
||||||
|
/// (CleaningCycleDays). NextCleaningDate = LastCleanedDate + CleaningCycleDays wird
|
||||||
|
/// berechnet ausgegeben. "mark-cleaned" setzt die letzte Reinigung auf heute.
|
||||||
|
/// </summary>
|
||||||
|
public class EnclosureEndpointTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public EnclosureEndpointTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_persists_cleaning_fields_and_computes_next_cleaning()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var resp = await client.PostAsJsonAsync("/enclosures", new
|
||||||
|
{
|
||||||
|
name = "Reinigungs-Becken",
|
||||||
|
notes = "Test",
|
||||||
|
size = "120×50 cm",
|
||||||
|
capacity = 6,
|
||||||
|
lastCleanedDate = "2026-06-01",
|
||||||
|
cleaningCycleDays = 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("120×50 cm", dto.GetProperty("size").GetString());
|
||||||
|
Assert.Equal(6, dto.GetProperty("capacity").GetInt32());
|
||||||
|
Assert.Equal("2026-06-01", dto.GetProperty("lastCleanedDate").GetString());
|
||||||
|
Assert.Equal(14, dto.GetProperty("cleaningCycleDays").GetInt32());
|
||||||
|
// 2026-06-01 + 14 Tage = 2026-06-15
|
||||||
|
Assert.Equal("2026-06-15", dto.GetProperty("nextCleaningDate").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NextCleaning_is_null_without_cycle_or_lastCleaned()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
// Nur letzte Reinigung, kein Zyklus -> keine nächste fällige Reinigung.
|
||||||
|
var resp = await client.PostAsJsonAsync("/enclosures", new
|
||||||
|
{
|
||||||
|
name = "Becken ohne Zyklus",
|
||||||
|
lastCleanedDate = "2026-06-01",
|
||||||
|
});
|
||||||
|
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal(JsonValueKind.Null, dto.GetProperty("nextCleaningDate").ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.Null, dto.GetProperty("cleaningCycleDays").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Put_updates_cleaning_fields()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new
|
||||||
|
{
|
||||||
|
name = "Becken-Edit",
|
||||||
|
cleaningCycleDays = 7,
|
||||||
|
})).Content.ReadAsStringAsync()).RootElement;
|
||||||
|
var id = created.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
var put = await client.PutAsJsonAsync($"/enclosures/{id}", new
|
||||||
|
{
|
||||||
|
name = "Becken-Edit",
|
||||||
|
size = "80×40 cm",
|
||||||
|
capacity = 4,
|
||||||
|
lastCleanedDate = "2026-05-20",
|
||||||
|
cleaningCycleDays = 10,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
var dto = JsonDocument.Parse(await client.GetStringAsync($"/enclosures/{id}")).RootElement;
|
||||||
|
Assert.Equal("80×40 cm", dto.GetProperty("size").GetString());
|
||||||
|
Assert.Equal(4, dto.GetProperty("capacity").GetInt32());
|
||||||
|
Assert.Equal("2026-05-20", dto.GetProperty("lastCleanedDate").GetString());
|
||||||
|
Assert.Equal(10, dto.GetProperty("cleaningCycleDays").GetInt32());
|
||||||
|
Assert.Equal("2026-05-30", dto.GetProperty("nextCleaningDate").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MarkCleaned_sets_lastCleaned_to_today()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new
|
||||||
|
{
|
||||||
|
name = "Becken-Mark",
|
||||||
|
cleaningCycleDays = 21,
|
||||||
|
lastCleanedDate = "2020-01-01",
|
||||||
|
})).Content.ReadAsStringAsync()).RootElement;
|
||||||
|
var id = created.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
var resp = await client.PostAsync($"/enclosures/{id}/mark-cleaned", null);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
|
||||||
|
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
Assert.Equal(today.ToString("yyyy-MM-dd"), dto.GetProperty("lastCleanedDate").GetString());
|
||||||
|
Assert.Equal(today.AddDays(21).ToString("yyyy-MM-dd"), dto.GetProperty("nextCleaningDate").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MarkCleaned_unknown_id_returns_404()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var resp = await client.PostAsync($"/enclosures/{Guid.NewGuid()}/mark-cleaned", null);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,10 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
|
|
||||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||||
|
|
||||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
public record EnclosureDto(
|
||||||
|
Guid Id, string Name, string? Notes,
|
||||||
|
string? Size, int? Capacity,
|
||||||
|
DateOnly? LastCleanedDate, int? CleaningCycleDays, DateOnly? NextCleaningDate);
|
||||||
|
|
||||||
public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder);
|
public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder);
|
||||||
|
|
||||||
@@ -101,7 +104,10 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
|
|
||||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||||
|
|
||||||
public record EnclosureInput(string Name, string? Notes);
|
public record EnclosureInput(
|
||||||
|
string Name, string? Notes,
|
||||||
|
string? Size, int? Capacity,
|
||||||
|
DateOnly? LastCleanedDate, int? CleaningCycleDays);
|
||||||
|
|
||||||
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
|
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
|
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
||||||
|
Apply(e, input);
|
||||||
db.Enclosures.Add(e);
|
db.Enclosures.Add(e);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
|
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
|
||||||
@@ -35,10 +36,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (e is null) return TypedResults.NotFound();
|
if (e is null) return TypedResults.NotFound();
|
||||||
e.Name = input.Name; e.Notes = input.Notes;
|
e.Name = input.Name; e.Notes = input.Notes;
|
||||||
|
Apply(e, input);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reinigung dokumentieren: setzt LastCleanedDate auf heute (NextCleaningDate folgt aus dem Zyklus).
|
||||||
|
group.MapPost("/{id:guid}/mark-cleaned", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (e is null) return TypedResults.NotFound();
|
||||||
|
e.LastCleanedDate = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Ok(ToDto(e));
|
||||||
|
});
|
||||||
|
|
||||||
// 409 if the enclosure still houses gerbils.
|
// 409 if the enclosure still houses gerbils.
|
||||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
@@ -54,6 +66,16 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes);
|
private static void Apply(Enclosure e, EnclosureInput input)
|
||||||
|
{
|
||||||
|
e.Size = input.Size;
|
||||||
|
e.Capacity = input.Capacity;
|
||||||
|
e.LastCleanedDate = input.LastCleanedDate;
|
||||||
|
e.CleaningCycleDays = input.CleaningCycleDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EnclosureDto ToDto(Enclosure e) =>
|
||||||
|
new(e.Id, e.Name, e.Notes, e.Size, e.Capacity,
|
||||||
|
e.LastCleanedDate, e.CleaningCycleDays, e.NextCleaningDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1560
GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.Designer.cs
generated
Normal file
1560
GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEnclosureCleaning : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Capacity",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CleaningCycleDays",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
|
name: "LastCleanedDate",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "date",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Size",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Capacity",
|
||||||
|
table: "Enclosures");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CleaningCycleDays",
|
||||||
|
table: "Enclosures");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastCleanedDate",
|
||||||
|
table: "Enclosures");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Size",
|
||||||
|
table: "Enclosures");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -755,6 +755,15 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int?>("Capacity")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("CleaningCycleDays")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("LastCleanedDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -762,6 +771,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Size")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Enclosures");
|
b.ToTable("Enclosures");
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// Reinigungszyklus (aus RennmausPro becken_tb: _SIZE/_MENGE/_CLEANED/_CYCLUS).
|
||||||
|
/// <summary>Maße als Freitext (z. B. "120×50 cm").</summary>
|
||||||
|
public string? Size { get; set; }
|
||||||
|
/// <summary>Empfohlene/maximale Tieranzahl.</summary>
|
||||||
|
public int? Capacity { get; set; }
|
||||||
|
/// <summary>Datum der letzten Reinigung.</summary>
|
||||||
|
public DateOnly? LastCleanedDate { get; set; }
|
||||||
|
/// <summary>Reinigungsintervall in Tagen.</summary>
|
||||||
|
public int? CleaningCycleDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Nächste fällige Reinigung = LastCleanedDate + CleaningCycleDays (berechnet, nicht persistiert).</summary>
|
||||||
|
public DateOnly? NextCleaningDate =>
|
||||||
|
LastCleanedDate is { } last && CleaningCycleDays is { } cycle and > 0
|
||||||
|
? last.AddDays(cycle)
|
||||||
|
: null;
|
||||||
|
|
||||||
public ICollection<Gerbil> Gerbils { get; } = new List<Gerbil>();
|
public ICollection<Gerbil> Gerbils { get; } = new List<Gerbil>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,35 @@ test.describe('Becken', () => {
|
|||||||
await expect(page.getByText(tb.delete.conflict)).toBeVisible()
|
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 }) => {
|
test('Becken anlegen, bearbeiten und (leer) löschen', async ({ page }) => {
|
||||||
await gotoSection(page, de.nav.enclosures)
|
await gotoSection(page, de.nav.enclosures)
|
||||||
await page.getByRole('link', { name: tb.newButton }).click()
|
await page.getByRole('link', { name: tb.newButton }).click()
|
||||||
|
|||||||
@@ -261,6 +261,22 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
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$/)
|
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
|
||||||
if (m) {
|
if (m) {
|
||||||
const isKruemel = m[1] === 'kruemel'
|
const isKruemel = m[1] === 'kruemel'
|
||||||
@@ -427,6 +443,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
if (method === 'POST') {
|
if (method === 'POST') {
|
||||||
const body = request.postDataJSON() as Row
|
const body = request.postDataJSON() as Row
|
||||||
const created = { id: newId(col.idPrefix), ...body }
|
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)
|
col.rows.push(created)
|
||||||
return json(route, 201, created)
|
return json(route, 201, created)
|
||||||
}
|
}
|
||||||
@@ -440,6 +463,12 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
if (method === 'PUT') {
|
if (method === 'PUT') {
|
||||||
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||||
Object.assign(col.rows[idx], request.postDataJSON() as Row)
|
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])
|
return json(route, 200, col.rows[idx])
|
||||||
}
|
}
|
||||||
if (method === 'DELETE') {
|
if (method === 'DELETE') {
|
||||||
|
|||||||
@@ -232,8 +232,28 @@ export function seedDb(): MockDb {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const enclosures: Enclosure[] = [
|
const enclosures: Enclosure[] = [
|
||||||
{ id: 'enc-gross', name: 'Großbecken', notes: '120×50 cm' },
|
// Reinigung überfällig: letzte Reinigung lange her + Zyklus -> nextCleaningDate in der Vergangenheit.
|
||||||
{ id: 'enc-leer', name: 'Quarantänebecken', notes: null },
|
{
|
||||||
|
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.
|
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import type { Enclosure, Paged } from './types'
|
|||||||
export interface CreateEnclosure {
|
export interface CreateEnclosure {
|
||||||
name: string
|
name: string
|
||||||
notes?: string | null
|
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}. */
|
/** 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> {
|
export function deleteEnclosure(id: string): Promise<void> {
|
||||||
return api.delete(`${resources.enclosures}/${id}`)
|
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
|
id: string
|
||||||
name: string
|
name: string
|
||||||
notes: string | null
|
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 {
|
export interface Contact {
|
||||||
|
|||||||
@@ -7,17 +7,27 @@ import { useMemo, useState } from 'react'
|
|||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { deleteEnclosure, getEnclosure } from '../api/enclosures'
|
import { deleteEnclosure, getEnclosure, markEnclosureCleaned } from '../api/enclosures'
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import { listColorVarieties } from '../api/lookups'
|
import { listColorVarieties } from '../api/lookups'
|
||||||
import { condition } from '../api/gridify'
|
import { condition } from '../api/gridify'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { formatDate } from '../format/labels'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
|
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() {
|
export default function BeckenDetailPage() {
|
||||||
const t = de.pages.becken
|
const t = de.pages.becken
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
|
||||||
const enclosure = useApi(() => getEnclosure(id), [id])
|
const enclosure = useApi(() => getEnclosure(id), [id])
|
||||||
@@ -39,6 +49,17 @@ export default function BeckenDetailPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removal = useMutation(() => deleteEnclosure(id))
|
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() {
|
async function onDelete() {
|
||||||
if (!window.confirm(t.delete.confirmMessage)) return
|
if (!window.confirm(t.delete.confirmMessage)) return
|
||||||
@@ -101,15 +122,62 @@ export default function BeckenDetailPage() {
|
|||||||
|
|
||||||
{deleteError && <div className="alert alert--error">{deleteError}</div>}
|
{deleteError && <div className="alert alert--error">{deleteError}</div>}
|
||||||
|
|
||||||
{e.notes && (
|
{(e.notes || e.size || e.capacity != null) && (
|
||||||
<dl className="def-list">
|
<dl className="def-list">
|
||||||
|
{e.notes && (
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{t.fields.notes}</dt>
|
<dt>{t.fields.notes}</dt>
|
||||||
<dd>{e.notes}</dd>
|
<dd>{e.notes}</dd>
|
||||||
</div>
|
</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>
|
</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>
|
<h3>{t.photosTitle}</h3>
|
||||||
<EnclosurePhotosSection enclosureId={e.id} />
|
<EnclosurePhotosSection enclosureId={e.id} />
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,32 @@ import { useToast } from '../components/toast'
|
|||||||
interface FormState {
|
interface FormState {
|
||||||
name: string
|
name: string
|
||||||
notes: 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. */
|
/** "" -> null, sonst der Wert. */
|
||||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
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() {
|
export default function BeckenFormPage() {
|
||||||
const t = de.pages.becken
|
const t = de.pages.becken
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -32,7 +51,14 @@ export default function BeckenFormPage() {
|
|||||||
// Vorbefüllen im Bearbeiten-Modus (adjust-state-during-render, wie FEAT-1).
|
// Vorbefüllen im Bearbeiten-Modus (adjust-state-during-render, wie FEAT-1).
|
||||||
if (existing.data && initializedFor !== existing.data.id) {
|
if (existing.data && initializedFor !== existing.data.id) {
|
||||||
setInitializedFor(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) =>
|
const mutation = useMutation((body: CreateEnclosure) =>
|
||||||
@@ -46,7 +72,14 @@ export default function BeckenFormPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setErrors({})
|
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) {
|
if (result.ok) {
|
||||||
toast.success(de.common.saved)
|
toast.success(de.common.saved)
|
||||||
navigate(`/gehege/${result.value.id}`)
|
navigate(`/gehege/${result.value.id}`)
|
||||||
@@ -82,6 +115,48 @@ export default function BeckenFormPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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>}
|
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
|
|||||||
@@ -368,6 +368,26 @@ export const de = {
|
|||||||
fields: {
|
fields: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
notes: 'Notizen',
|
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.
|
// Bilder-Sektion auf der Gehege-Detailseite.
|
||||||
photosTitle: 'Bilder',
|
photosTitle: 'Bilder',
|
||||||
|
|||||||
Reference in New Issue
Block a user