Merge branch 'worktree-agent-a36d202be311b9289'

# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
This commit is contained in:
2026-06-22 22:57:16 +02:00
18 changed files with 3056 additions and 0 deletions

View File

@@ -0,0 +1,214 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests;
/// <summary>
/// ABGABE-STATUS (Reservierungs-/Abgabe-Status): the pre-handover pipeline
/// verfügbar → reserviert → abgegeben.
/// - Full CRUD round-trip via /reservations.
/// - GET ?gerbilId= filters to one animal.
/// - Invalid status falls back to "verfuegbar".
/// - CRITICAL: rows survive the import re-ingest wipe (loose, FK-free GerbilId/ReservedForContactId).
/// </summary>
public class SaleReservationEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public SaleReservationEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Reservation_crud_lifecycle_create_reserve_handover_delete()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var contactId = Guid.NewGuid();
// Create -> defaults to verfuegbar.
var create = await client.PostAsJsonAsync("/reservations", new
{
gerbilId,
gerbilName = "Pippa",
});
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
var id = created.GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(id));
Assert.Equal("verfuegbar", created.GetProperty("status").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
Assert.Equal("Pippa", created.GetProperty("gerbilName").GetString());
// List contains it.
var listed = JsonDocument.Parse(await client.GetStringAsync("/reservations")).RootElement;
Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
// Filter by gerbilId returns exactly this row.
var byGerbil = JsonDocument.Parse(await client.GetStringAsync($"/reservations?gerbilId={gerbilId}")).RootElement;
Assert.All(byGerbil.EnumerateArray(), r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString()));
Assert.Contains(byGerbil.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
// PUT -> reserve for a contact with an appointment + price.
var reserve = await client.PutAsJsonAsync($"/reservations/{id}", new
{
status = "reserviert",
reservedForContactId = contactId,
contactName = "Familie Huber",
appointmentDate = "2026-07-01T10:00:00Z",
price = 25.50m,
note = "Käfig wird mitgebracht.",
});
Assert.Equal(HttpStatusCode.OK, reserve.StatusCode);
var reserved = JsonDocument.Parse(await reserve.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("reserviert", reserved.GetProperty("status").GetString());
Assert.Equal(contactId.ToString(), reserved.GetProperty("reservedForContactId").GetString());
Assert.Equal("Familie Huber", reserved.GetProperty("contactName").GetString());
Assert.Equal(25.50m, reserved.GetProperty("price").GetDecimal());
// PUT -> hand over.
var handover = await client.PutAsJsonAsync($"/reservations/{id}", new
{
status = "abgegeben",
reservedForContactId = contactId,
handedOverDate = "2026-07-01T10:30:00Z",
});
Assert.Equal(HttpStatusCode.OK, handover.StatusCode);
var handed = JsonDocument.Parse(await handover.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("abgegeben", handed.GetProperty("status").GetString());
Assert.NotEqual(JsonValueKind.Null, handed.GetProperty("handedOverDate").ValueKind);
// Delete -> 204, then 404.
Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/reservations/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/reservations/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/reservations/{id}", new { status = "verfuegbar" })).StatusCode);
}
[Fact]
public async Task Post_with_unknown_status_falls_back_to_verfuegbar()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.NewGuid(), status = "bananen" });
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var dto = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("verfuegbar", dto.GetProperty("status").GetString());
}
[Fact]
public async Task Post_without_gerbilId_is_rejected()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.Empty, status = "reserviert" });
Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
}
[Fact]
public async Task Reservation_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "reservation-ingest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
},
Litters = new[]
{
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
},
Gerbils = new[]
{
Animal(fatherId, "Papa", "male", contactId),
Animal(motherId, "Mama", "female", contactId),
},
GerbilPhotos = Array.Empty<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("reservation-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// A reservation referencing the gerbil + contact that the wipe will delete.
var resId = Guid.NewGuid();
db.SaleReservations.Add(new SaleReservation
{
Id = resId,
GerbilId = fatherId,
GerbilName = "Papa",
Status = "reserviert",
ReservedForContactId = contactId,
ContactName = "Test Breeder",
AppointmentDate = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc),
Price = 30m,
Note = "Abholung am Wochenende.",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
.Build();
// Run the ingest wipe + reload.
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/contacts were wiped & re-created, but the reservation is untouched.
var survivor = await db.SaleReservations.SingleAsync(r => r.Id == resId);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved
Assert.Equal(contactId, survivor.ReservedForContactId);
Assert.Equal("Papa", survivor.GerbilName);
Assert.Equal("Test Breeder", survivor.ContactName);
Assert.Equal("reserviert", survivor.Status);
Assert.Equal(30m, survivor.Price);
Assert.Equal(1, await db.SaleReservations.CountAsync());
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
{
Id = id,
Name = name,
Gender = gender,
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-" + name,
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = name.ToLowerInvariant(),
CharacterTraits = Array.Empty<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}

View File

@@ -26,6 +26,7 @@ public class ApplicationContext : DbContext
public DbSet<MailSettings> MailSettings => Set<MailSettings>(); public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>(); public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>(); public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key), // Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity. // so it can never drift from Name regardless of which code path mutates the entity.
@@ -223,6 +224,16 @@ public class ApplicationContext : DbContext
e.HasIndex(a => a.GerbilId); e.HasIndex(a => a.GerbilId);
}); });
// ABGABE-STATUS: deliberately relationship-free (same rationale as Feedback).
// GerbilId/ReservedForContactId are plain Guid columns (no navigation properties →
// EF creates NO foreign key), so the import re-ingest wipe of Gerbils/Contacts never
// cascades into — or breaks — reservation rows. They survive re-ingest by design.
modelBuilder.Entity<SaleReservation>(e =>
{
e.Property(r => r.Price).HasPrecision(10, 2);
e.HasIndex(r => r.GerbilId);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql) if (isNpgsql)
{ {

View File

@@ -0,0 +1,43 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>ABGABE-STATUS: response DTO for a stored reservation/sale status.</summary>
public record SaleReservationDto(
Guid Id,
Guid GerbilId,
string? GerbilName,
string Status,
Guid? ReservedForContactId,
string? ContactName,
DateTime? AppointmentDate,
decimal? Price,
string? Note,
DateTime? HandedOverDate,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>ABGABE-STATUS: payload for POST /reservations.</summary>
public record SaleReservationInput(
Guid GerbilId,
string? GerbilName,
string? Status,
Guid? ReservedForContactId,
string? ContactName,
DateTime? AppointmentDate,
decimal? Price,
string? Note,
DateTime? HandedOverDate);
/// <summary>
/// ABGABE-STATUS: payload for PUT /reservations/{id}. All fields optional — only the
/// provided ones are changed. A null/blank Status is ignored.
/// </summary>
public record SaleReservationUpdate(
string? GerbilName,
string? Status,
Guid? ReservedForContactId,
string? ContactName,
DateTime? AppointmentDate,
decimal? Price,
string? Note,
DateTime? HandedOverDate);
}

View File

@@ -0,0 +1,124 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale
/// pipeline — verfügbar → reserviert → abgegeben.
/// GET /reservations -> list all, newest-updated first.
/// GET /reservations?gerbilId= -> the (single) reservation status for one animal, if any.
/// POST /reservations -> create/establish a status, returns 201.
/// PUT /reservations/{id} -> change status / reservation details. 404 on missing id.
/// DELETE /reservations/{id} -> remove. 404 on missing id.
/// Decoupled from gerbils/contacts (loose nullable Guid columns, no FK), so rows survive
/// the import re-ingest wipe — like Feedback.
/// </summary>
public static class SaleReservationEndpoints
{
private static readonly string[] AllowedStatuses = { "verfuegbar", "reserviert", "abgegeben" };
/// <summary>Normalize a status string to one of the three canonical values; default "verfuegbar".</summary>
private static string NormalizeStatus(string? raw)
{
var s = raw?.Trim().ToLowerInvariant();
return AllowedStatuses.Contains(s) ? s! : "verfuegbar";
}
public static IEndpointRouteBuilder MapSaleReservationEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/reservations").WithTags("Reservations");
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
var rows = await db.SaleReservations.AsNoTracking().ToListAsync();
var filtered = gerbilId is { } gid
? rows.Where(r => r.GerbilId == gid)
: rows;
return TypedResults.Ok(filtered
.OrderByDescending(r => r.UpdatedAt)
.Select(ToDto)
.ToList());
});
group.MapPost("/", async Task<Results<Created<SaleReservationDto>, BadRequest<string>>> (
SaleReservationInput input, ApplicationContext db) =>
{
if (input.GerbilId == Guid.Empty)
return TypedResults.BadRequest("GerbilId ist erforderlich.");
var now = DateTimeOffset.UtcNow;
var entity = new SaleReservation
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
GerbilName = Trim(input.GerbilName),
Status = NormalizeStatus(input.Status),
ReservedForContactId = input.ReservedForContactId,
ContactName = Trim(input.ContactName),
AppointmentDate = input.AppointmentDate,
Price = input.Price,
Note = Trim(input.Note),
HandedOverDate = input.HandedOverDate,
CreatedAt = now,
UpdatedAt = now,
};
db.SaleReservations.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/reservations/{entity.Id}", ToDto(entity));
});
group.MapPut("/{id:guid}", async Task<Results<Ok<SaleReservationDto>, NotFound>> (
Guid id, SaleReservationUpdate input, ApplicationContext db) =>
{
var entity = await db.SaleReservations.FirstOrDefaultAsync(r => r.Id == id);
if (entity is null)
return TypedResults.NotFound();
if (input.Status is not null)
entity.Status = NormalizeStatus(input.Status);
if (input.GerbilName is not null)
entity.GerbilName = Trim(input.GerbilName);
if (input.ContactName is not null)
entity.ContactName = Trim(input.ContactName);
if (input.Note is not null)
entity.Note = Trim(input.Note);
// Value-type/nullable fields are always applied from the payload (a null clears them).
entity.ReservedForContactId = input.ReservedForContactId;
entity.AppointmentDate = input.AppointmentDate;
entity.Price = input.Price;
entity.HandedOverDate = input.HandedOverDate;
entity.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity));
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
Guid id, ApplicationContext db) =>
{
var entity = await db.SaleReservations.FirstOrDefaultAsync(r => r.Id == id);
if (entity is null)
return TypedResults.NotFound();
db.SaleReservations.Remove(entity);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static string? Trim(string? s) =>
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static SaleReservationDto ToDto(SaleReservation r) =>
new(r.Id, r.GerbilId, r.GerbilName, r.Status, r.ReservedForContactId, r.ContactName,
r.AppointmentDate, r.Price, r.Note, r.HandedOverDate, r.CreatedAt, r.UpdatedAt);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddSaleReservation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SaleReservations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
GerbilName = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "text", nullable: false),
ReservedForContactId = table.Column<Guid>(type: "uuid", nullable: true),
ContactName = table.Column<string>(type: "text", nullable: true),
AppointmentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
Note = table.Column<string>(type: "text", nullable: true),
HandedOverDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SaleReservations", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_SaleReservations_GerbilId",
table: "SaleReservations",
column: "GerbilId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SaleReservations");
}
}
}

View File

@@ -1404,6 +1404,54 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("SaleContractAnimal"); b.ToTable("SaleContractAnimal");
}); });
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleReservation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AppointmentDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ContactName")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("GerbilName")
.HasColumnType("text");
b.Property<DateTime?>("HandedOverDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<Guid?>("ReservedForContactId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("SaleReservations");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale
/// pipeline for an animal — verfügbar (available) → reserviert (reserved) → abgegeben
/// (handed over). This is the *informal status* that precedes the legal sale contract
/// (SaleContract) — it does NOT replace it.
///
/// Deliberately decoupled from the rest of the model (same pattern as Feedback):
/// GerbilId and ReservedForContactId are plain nullable Guid columns, NOT enforced
/// foreign keys, so the import re-ingest wipe (IngestResolvedService) can delete and
/// recreate gerbils/contacts without deleting or breaking reservation rows. The
/// captured GerbilName/ContactName keep the row human-readable after a wipe.
/// </summary>
public class SaleReservation
{
[Key]
public Guid Id { get; set; }
/// <summary>Loose reference (no FK) to the animal this status is about.</summary>
public Guid GerbilId { get; set; }
/// <summary>Captured animal name (survives an ingest wipe).</summary>
public string? GerbilName { get; set; }
/// <summary>
/// Status: "verfuegbar" | "reserviert" | "abgegeben". Plain string, no FK —
/// keeps the row decoupled and ingest-surviving.
/// </summary>
public string Status { get; set; } = "verfuegbar";
/// <summary>Loose reference (no FK) to the reserving/receiving contact (Interessent/Abnehmer), if any.</summary>
public Guid? ReservedForContactId { get; set; }
/// <summary>Captured contact name (survives an ingest wipe).</summary>
public string? ContactName { get; set; }
/// <summary>Planned hand-over appointment (RPRO3 abgeben_tb._TERMIN).</summary>
public DateTime? AppointmentDate { get; set; }
/// <summary>Agreed price (RPRO3 abstat_tb._PRICE).</summary>
public decimal? Price { get; set; }
/// <summary>Free-text note (RPRO3 _BEM).</summary>
public string? Note { get; set; }
/// <summary>When the animal was actually handed over (RPRO3 abstat_tb._AM); null until "abgegeben".</summary>
public DateTime? HandedOverDate { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
/// <summary>Server-side last-update time.</summary>
public DateTimeOffset UpdatedAt { get; set; }
}
}

View File

@@ -128,6 +128,7 @@ app.MapRequestEndpoints();
app.MapNamesEndpoints(); app.MapNamesEndpoints();
app.MapFeedbackEndpoints(); app.MapFeedbackEndpoints();
app.MapAcquisitionEndpoints(); app.MapAcquisitionEndpoints();
app.MapSaleReservationEndpoints();
app.Run(); app.Run();

View File

@@ -577,6 +577,68 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405) return json(route, 405)
} }
// ABGABE-STATUS: /reservations — Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben).
if (path === '/reservations') {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!body.gerbilId) return json(route, 400, 'GerbilId ist erforderlich.')
const nowIso = new Date().toISOString()
const created = {
id: newId('reservation'),
gerbilId: body.gerbilId,
gerbilName: body.gerbilName ?? null,
status: normStatus(body.status),
reservedForContactId: body.reservedForContactId ?? null,
contactName: body.contactName ?? null,
appointmentDate: body.appointmentDate ?? null,
price: body.price ?? null,
note: body.note ?? null,
handedOverDate: body.handedOverDate ?? null,
createdAt: nowIso,
updatedAt: nowIso,
}
db.reservations.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = gid ? db.reservations.filter((r) => r.gerbilId === gid) : [...db.reservations]
return json(route, 200, rows.slice().reverse())
}
return json(route, 405)
}
const resm = path.match(/^\/reservations\/([^/]+)$/)
if (resm) {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
const rid = decodeURIComponent(resm[1])
const idx = db.reservations.findIndex((r) => r.id === rid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as Row
const row = db.reservations[idx]
if (typeof body.status === 'string') row.status = normStatus(body.status)
if ('gerbilName' in body) row.gerbilName = body.gerbilName ?? null
if ('contactName' in body) row.contactName = body.contactName ?? null
if ('note' in body) row.note = body.note ?? null
if ('reservedForContactId' in body) row.reservedForContactId = body.reservedForContactId ?? null
if ('appointmentDate' in body) row.appointmentDate = body.appointmentDate ?? null
if ('price' in body) row.price = body.price ?? null
if ('handedOverDate' in body) row.handedOverDate = body.handedOverDate ?? null
row.updatedAt = new Date().toISOString()
return json(route, 200, row)
}
if (method === 'DELETE') {
db.reservations.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id> // Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined const col = m ? collections[m[1]] : undefined

View File

@@ -87,6 +87,8 @@ export interface MockDb {
} }
// ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — /acquisitions // ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — /acquisitions
acquisitions: Record<string, unknown>[] acquisitions: Record<string, unknown>[]
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (POST/PUT/DELETE /reservations)
reservations: Record<string, unknown>[]
} }
function gerbil( function gerbil(
@@ -544,5 +546,50 @@ export function seedDb(): MockDb {
}, },
}, },
acquisitions: [], acquisitions: [],
// ABGABE-STATUS: eine reservierte + eine abgegebene + eine verfügbare Vormerkung.
reservations: [
{
id: 'res-reserviert',
gerbilId: 'sale-balu',
gerbilName: 'Balu Abgabe',
status: 'reserviert',
reservedForContactId: 'con-huber',
contactName: 'Familie Huber',
appointmentDate: '2026-07-01T00:00:00Z',
price: 25,
note: 'Käfig wird mitgebracht.',
handedOverDate: null,
createdAt: '2026-06-15T09:00:00Z',
updatedAt: '2026-06-15T09:00:00Z',
},
{
id: 'res-abgegeben',
gerbilId: 'pup-abgabe',
gerbilName: 'Pippa',
status: 'abgegeben',
reservedForContactId: 'con-huber',
contactName: 'Familie Huber',
appointmentDate: null,
price: 20,
note: null,
handedOverDate: '2025-05-01T00:00:00Z',
createdAt: '2025-04-20T09:00:00Z',
updatedAt: '2025-05-01T09:00:00Z',
},
{
id: 'res-verfuegbar',
gerbilId: 'sale-benny',
gerbilName: 'Benny Abgabe',
status: 'verfuegbar',
reservedForContactId: null,
contactName: null,
appointmentDate: null,
price: null,
note: null,
handedOverDate: null,
createdAt: '2026-06-16T09:00:00Z',
updatedAt: '2026-06-16T09:00:00Z',
},
],
} }
} }

View File

@@ -0,0 +1,78 @@
/**
* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Seite (verfügbar → reserviert → abgegeben).
* Erreichbar über das Menü („Reservierungen"). Liste mit Status-Chips, Tier-/Kontakt-Links,
* Anlegen, Status-Wechsel und Löschen.
*/
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
const tr = de.pages.reservierungen
test.describe('Reservierungen', () => {
test.beforeEach(() => skipUnlessMock())
test('über das Menü erreichbar, zeigt die Seed-Reservierungen', async ({ page }) => {
await gotoSection(page, de.nav.reservations)
await expect(page.getByRole('heading', { name: tr.title })).toBeVisible()
// Reservierte + abgegebene + verfügbare Vormerkung sichtbar.
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await expect(page.getByText('Pippa')).toBeVisible()
await expect(page.getByText('Benny Abgabe')).toBeVisible()
})
test('Status-Filter filtern korrekt (Anzahl-Badges stimmen)', async ({ page }) => {
await page.goto('/reservierungen')
const filters = page.locator('.reservierungen-filter')
await filters.filter({ hasText: tr.filters.reserviert }).click()
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await expect(page.getByText('Benny Abgabe')).toHaveCount(0)
await filters.filter({ hasText: tr.filters.abgegeben }).click()
await expect(page.getByText('Pippa')).toBeVisible()
await expect(page.getByText('Balu Abgabe')).toHaveCount(0)
})
test('Tier- und Kontakt-Bezüge sind verlinkt', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Balu Abgabe' })
await expect(card.getByRole('link', { name: 'Balu Abgabe' })).toHaveAttribute('href', /\/rennmaeuse\/sale-balu/)
await expect(card.getByRole('link', { name: 'Familie Huber' })).toHaveAttribute('href', /\/kontakte\/con-huber/)
})
test('Status wechseln: verfügbares Tier reservieren', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })
await expect(card.locator('.reservation-badge--verfuegbar')).toBeVisible()
await card.getByRole('button', { name: tr.actions.markReserved }).click()
await expect(
page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' }).locator('.reservation-badge--reserviert'),
).toBeVisible()
})
test('neue Reservierung anlegen', async ({ page }) => {
await page.goto('/reservierungen')
await page.getByRole('button', { name: tr.newButton }).click()
const form = page.locator('.reservation-form')
await expect(form).toBeVisible()
// Ein Tier auswählen (Krümel ist in den Seed-Daten vorhanden).
await form.getByLabel(tr.form.animal).selectOption({ label: 'Krümel' })
await form.getByRole('button', { name: tr.form.save }).click()
// Die neue Reservierung erscheint in der Liste.
await expect(page.locator('.reservation-card').filter({ hasText: 'Krümel' })).toBeVisible()
})
test('Reservierung löschen', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })
await expect(card).toBeVisible()
acceptNextDialog(page)
await card.getByRole('button', { name: tr.actions.delete }).click()
await expect(page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })).toHaveCount(0)
})
})

View File

@@ -8,6 +8,7 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
import GerbilFormPage from './pages/GerbilFormPage' import GerbilFormPage from './pages/GerbilFormPage'
import GenetikPage from './pages/GenetikPage' import GenetikPage from './pages/GenetikPage'
import AbgabePage from './pages/AbgabePage' import AbgabePage from './pages/AbgabePage'
import ReservierungenPage from './pages/ReservierungenPage'
import KontaktePage from './pages/KontaktePage' import KontaktePage from './pages/KontaktePage'
import KontaktDetailPage from './pages/KontaktDetailPage' import KontaktDetailPage from './pages/KontaktDetailPage'
import KontaktFormPage from './pages/KontaktFormPage' import KontaktFormPage from './pages/KontaktFormPage'
@@ -71,6 +72,8 @@ export default function App() {
</Route> </Route>
<Route path="genetik" element={<GenetikPage />} /> <Route path="genetik" element={<GenetikPage />} />
<Route path="abgabe" element={<AbgabePage />} /> <Route path="abgabe" element={<AbgabePage />} />
{/* ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben) */}
<Route path="reservierungen" element={<ReservierungenPage />} />
<Route path="statistik" element={<StatistikPage />} /> <Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe"> <Route path="hilfe">
<Route index element={<HilfePage />} /> <Route index element={<HilfePage />} />

View File

@@ -0,0 +1,66 @@
/**
* ABGABE-STATUS: API client for the reservation/sale-status pipeline (/reservations).
* Status verfügbar → reserviert → abgegeben. This is the informal pre-handover status —
* separate from the legal Vertrag (SaleContract).
*/
import { api } from './client'
const RESOURCE = '/reservations'
/** Canonical status values (match the backend contract, case-sensitive). */
export type ReservationStatus = 'verfuegbar' | 'reserviert' | 'abgegeben'
export interface SaleReservation {
id: string
gerbilId: string
gerbilName: string | null
status: ReservationStatus
reservedForContactId: string | null
contactName: string | null
/** ISO-8601 appointment date or null. */
appointmentDate: string | null
price: number | null
note: string | null
/** ISO-8601 hand-over date or null. */
handedOverDate: string | null
createdAt: string
updatedAt: string
}
/** Payload for POST /reservations. */
export interface CreateReservation {
gerbilId: string
gerbilName?: string | null
status?: ReservationStatus
reservedForContactId?: string | null
contactName?: string | null
appointmentDate?: string | null
price?: number | null
note?: string | null
handedOverDate?: string | null
}
/** Payload for PUT /reservations/{id}. */
export type UpdateReservation = Partial<Omit<CreateReservation, 'gerbilId'>>
/** List all reservations, newest-updated first. */
export function listReservations(): Promise<SaleReservation[]> {
return api.get<SaleReservation[]>(RESOURCE)
}
/** List the reservation status rows for one animal. */
export function listReservationsForGerbil(gerbilId: string): Promise<SaleReservation[]> {
return api.get<SaleReservation[]>(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`)
}
export function createReservation(body: CreateReservation): Promise<SaleReservation> {
return api.post<SaleReservation>(RESOURCE, body)
}
export function updateReservation(id: string, body: UpdateReservation): Promise<SaleReservation> {
return api.put<SaleReservation>(`${RESOURCE}/${id}`, body)
}
export function deleteReservation(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -24,6 +24,8 @@ const SECONDARY: NavItem[] = [
{ to: '/gehege', label: de.nav.enclosures, icon: '🏜️' }, { to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' }, { to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' }, { to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// ABGABE-STATUS: Reservierungs-/Abgabe-Status
{ to: '/reservierungen', label: de.nav.reservations, icon: '🔖' },
// INBOX-1: Anfragen-Posteingang // INBOX-1: Anfragen-Posteingang
{ to: '/anfragen', label: de.nav.requests, icon: '📨' }, { to: '/anfragen', label: de.nav.requests, icon: '📨' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' }, { to: '/statistik', label: de.nav.statistics, icon: '📊' },

View File

@@ -0,0 +1,417 @@
/**
* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Verwaltung.
* Liste der (vor allem abzugebenden) Tiere mit Status verfügbar → reserviert → abgegeben,
* inkl. Reservierung für einen Kontakt, Termin, Preis und Notiz. Tier- und Kontakt-Bezüge
* sind als Links hinterlegt. Dieser Status ist die Vormerkung VOR dem rechtlichen Vertrag
* (Verträge-Seite) — er ersetzt ihn nicht.
*/
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
import { listContactsPaged } from '../api/contacts'
import {
createReservation,
deleteReservation,
listReservations,
updateReservation,
type ReservationStatus,
type SaleReservation,
} from '../api/reservations'
import { useApi, useMutation } from '../hooks/useApi'
import type { Contact, Gerbil } from '../api/types'
import './reservierungen.css'
const STATUSES: ReservationStatus[] = ['verfuegbar', 'reserviert', 'abgegeben']
type FilterKey = 'all' | ReservationStatus
/** ISO-Datetime → "TT.MM.JJJJ"; leer/ungültig → Strich. */
function dateOnly(iso: string | null): string {
if (!iso) return de.pages.reservierungen.fields.none
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
/** ISO-Datetime → Wert für <input type="date"> ("YYYY-MM-DD"). */
function toDateInput(iso: string | null): string {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString().slice(0, 10)
}
/** <input type="date">-Wert ("YYYY-MM-DD") → ISO-Datetime oder null. */
function fromDateInput(value: string): string | null {
if (!value) return null
return new Date(`${value}T00:00:00Z`).toISOString()
}
interface FormState {
id: string | null
gerbilId: string
status: ReservationStatus
reservedForContactId: string
appointmentDate: string
price: string
note: string
handedOverDate: string
}
const emptyForm: FormState = {
id: null,
gerbilId: '',
status: 'reserviert',
reservedForContactId: '',
appointmentDate: '',
price: '',
note: '',
handedOverDate: '',
}
export default function ReservierungenPage() {
const t = de.pages.reservierungen
const reservations = useApi(() => listReservations(), [])
const gerbils = useApi(
() => listGerbils({ orderBy: 'name', page: 1, pageSize: 1000 }),
[],
)
const contacts = useApi(
() => listContactsPaged({ orderBy: 'name', page: 1, pageSize: 1000 }),
[],
)
const createM = useMutation(createReservation)
const updateM = useMutation(updateReservation)
const deleteM = useMutation(deleteReservation)
const [filter, setFilter] = useState<FilterKey>('all')
const [form, setForm] = useState<FormState | null>(null)
const [formError, setFormError] = useState<string | null>(null)
const gerbilById = useMemo(
() => new Map((gerbils.data?.items ?? []).map((g: Gerbil) => [g.id, g])),
[gerbils.data],
)
const contactById = useMemo(
() => new Map((contacts.data?.items ?? []).map((c: Contact) => [c.id, c])),
[contacts.data],
)
const rows = useMemo(() => {
const all = reservations.data ?? []
return filter === 'all' ? all : all.filter((r) => r.status === filter)
}, [reservations.data, filter])
const counts = useMemo(() => {
const all = reservations.data ?? []
const c: Record<FilterKey, number> = { all: all.length, verfuegbar: 0, reserviert: 0, abgegeben: 0 }
for (const r of all) c[r.status] += 1
return c
}, [reservations.data])
function gerbilName(r: SaleReservation): string {
return gerbilById.get(r.gerbilId)?.name ?? r.gerbilName ?? t.fields.none
}
function contactName(r: SaleReservation): string {
if (!r.reservedForContactId) return t.fields.none
return contactById.get(r.reservedForContactId)?.name ?? r.contactName ?? t.fields.none
}
function openCreate() {
setFormError(null)
setForm({ ...emptyForm })
}
function openEdit(r: SaleReservation) {
setFormError(null)
setForm({
id: r.id,
gerbilId: r.gerbilId,
status: r.status,
reservedForContactId: r.reservedForContactId ?? '',
appointmentDate: toDateInput(r.appointmentDate),
price: r.price != null ? String(r.price) : '',
note: r.note ?? '',
handedOverDate: toDateInput(r.handedOverDate),
})
}
async function saveForm() {
if (!form) return
if (!form.gerbilId) {
setFormError(t.form.chooseAnimal)
return
}
const g = gerbilById.get(form.gerbilId)
const c = form.reservedForContactId ? contactById.get(form.reservedForContactId) : undefined
const priceNum = form.price.trim() === '' ? null : Number(form.price.replace(',', '.'))
const payload = {
status: form.status,
reservedForContactId: form.reservedForContactId || null,
contactName: c?.name ?? null,
appointmentDate: fromDateInput(form.appointmentDate),
price: priceNum != null && !Number.isNaN(priceNum) ? priceNum : null,
note: form.note.trim() || null,
handedOverDate: fromDateInput(form.handedOverDate),
}
const result = form.id
? await updateM.run(form.id, payload)
: await createM.run({ gerbilId: form.gerbilId, gerbilName: g?.name ?? null, ...payload })
if (result.ok) {
setForm(null)
reservations.reload()
} else {
setFormError(t.form.saveError)
}
}
async function quickStatus(r: SaleReservation, status: ReservationStatus) {
const patch: Parameters<typeof updateReservation>[1] = { status }
if (status === 'abgegeben' && !r.handedOverDate) patch.handedOverDate = new Date().toISOString()
const result = await updateM.run(r.id, patch)
if (result.ok) reservations.reload()
}
async function remove(r: SaleReservation) {
if (!window.confirm(t.actions.confirmDelete)) return
const result = await deleteM.run(r.id)
if (result.ok) reservations.reload()
}
if (reservations.loading) return <p className="muted">{de.common.loading}</p>
if (reservations.error) return <p className="error">{t.loadError}</p>
return (
<section className="page reservierungen">
<h2>{t.title}</h2>
<p className="muted">{t.subtitle}</p>
<p className="muted reservierungen__hint">{t.contractHint}</p>
<div className="reservierungen__toolbar">
<div className="reservierungen__filters" role="group" aria-label={t.fields.status}>
{(['all', ...STATUSES] as FilterKey[]).map((key) => (
<button
key={key}
type="button"
className={
filter === key
? 'reservierungen-filter reservierungen-filter--active'
: 'reservierungen-filter'
}
onClick={() => setFilter(key)}
>
{t.filters[key]}
<span className="reservierungen-filter__count">{counts[key]}</span>
</button>
))}
</div>
<button type="button" className="btn btn--primary" onClick={openCreate}>
{t.newButton}
</button>
</div>
{form && (
<ReservationForm
t={t}
form={form}
setForm={setForm}
gerbils={gerbils.data?.items ?? []}
contacts={contacts.data?.items ?? []}
error={formError}
pending={createM.pending || updateM.pending}
onSave={saveForm}
onCancel={() => setForm(null)}
/>
)}
{rows.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<ul className="reservierungen__list">
{rows.map((r) => (
<li key={r.id} className={`reservation-card reservation-card--${r.status}`}>
<div className="reservation-card__head">
<Link to={`/rennmaeuse/${r.gerbilId}`} className="reservation-card__animal">
{gerbilName(r)}
</Link>
<span className={`reservation-badge reservation-badge--${r.status}`}>
{t.status[r.status]}
</span>
</div>
<dl className="reservation-card__meta">
<div>
<dt>{t.fields.reservedFor}</dt>
<dd>
{r.reservedForContactId ? (
<Link to={`/kontakte/${r.reservedForContactId}`}>{contactName(r)}</Link>
) : (
contactName(r)
)}
</dd>
</div>
<div>
<dt>{t.fields.appointment}</dt>
<dd>{dateOnly(r.appointmentDate)}</dd>
</div>
<div>
<dt>{t.fields.price}</dt>
<dd>{r.price != null ? `${r.price.toFixed(2)}` : t.fields.none}</dd>
</div>
<div>
<dt>{t.fields.handedOver}</dt>
<dd>{dateOnly(r.handedOverDate)}</dd>
</div>
</dl>
{r.note && <p className="reservation-card__note">{r.note}</p>}
<div className="reservation-card__actions">
{r.status !== 'reserviert' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'reserviert')}>
{t.actions.markReserved}
</button>
)}
{r.status !== 'abgegeben' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'abgegeben')}>
{t.actions.markHandedOver}
</button>
)}
{r.status !== 'verfuegbar' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'verfuegbar')}>
{t.actions.markAvailable}
</button>
)}
<button type="button" className="btn" onClick={() => openEdit(r)}>
{t.actions.edit}
</button>
<button type="button" className="btn btn--danger" onClick={() => remove(r)}>
{t.actions.delete}
</button>
</div>
</li>
))}
</ul>
)}
</section>
)
}
interface FormProps {
t: typeof de.pages.reservierungen
form: FormState
setForm: (f: FormState) => void
gerbils: Gerbil[]
contacts: Contact[]
error: string | null
pending: boolean
onSave: () => void
onCancel: () => void
}
function ReservationForm({ t, form, setForm, gerbils, contacts, error, pending, onSave, onCancel }: FormProps) {
const isEdit = form.id != null
return (
<form
className="reservation-form"
onSubmit={(e) => {
e.preventDefault()
onSave()
}}
>
<h3>{isEdit ? t.form.editTitle : t.form.addTitle}</h3>
<label>
{t.form.animal}
<select
value={form.gerbilId}
disabled={isEdit}
onChange={(e) => setForm({ ...form, gerbilId: e.target.value })}
>
<option value="">{t.form.animalPlaceholder}</option>
{gerbils.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</label>
<label>
{t.form.status}
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as ReservationStatus })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t.status[s]}
</option>
))}
</select>
</label>
<label>
{t.form.reservedFor}
<select
value={form.reservedForContactId}
onChange={(e) => setForm({ ...form, reservedForContactId: e.target.value })}
>
<option value="">{t.form.contactNone}</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label>
{t.form.appointment}
<input
type="date"
value={form.appointmentDate}
onChange={(e) => setForm({ ...form, appointmentDate: e.target.value })}
/>
</label>
<label>
{t.form.price}
<input
type="number"
step="0.01"
min="0"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
</label>
<label>
{t.form.handedOver}
<input
type="date"
value={form.handedOverDate}
onChange={(e) => setForm({ ...form, handedOverDate: e.target.value })}
/>
</label>
<label className="reservation-form__note">
{t.form.note}
<textarea
value={form.note}
rows={2}
onChange={(e) => setForm({ ...form, note: e.target.value })}
/>
</label>
{error && <p className="error">{error}</p>}
<div className="reservation-form__actions">
<button type="submit" className="btn btn--primary" disabled={pending}>
{t.form.save}
</button>
<button type="button" className="btn" onClick={onCancel}>
{t.form.cancel}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,172 @@
/* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Seite. */
.reservierungen__hint {
font-size: 0.85rem;
}
.reservierungen__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin: 1rem 0;
}
.reservierungen__filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.reservierungen-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--border, #ccc);
border-radius: 999px;
background: transparent;
cursor: pointer;
font: inherit;
}
.reservierungen-filter--active {
background: var(--accent, #3b6ea5);
color: #fff;
border-color: var(--accent, #3b6ea5);
}
.reservierungen-filter__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
padding: 0 0.35em;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
font-size: 0.8em;
}
.reservierungen-filter--active .reservierungen-filter__count {
background: rgba(255, 255, 255, 0.3);
}
.reservierungen__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.reservation-card {
border: 1px solid var(--border, #ddd);
border-left: 4px solid var(--border, #ddd);
border-radius: 8px;
padding: 0.85rem 1rem;
background: var(--surface, #fff);
}
.reservation-card--verfuegbar {
border-left-color: #2e7d32;
}
.reservation-card--reserviert {
border-left-color: #ef6c00;
}
.reservation-card--abgegeben {
border-left-color: #6d6d6d;
}
.reservation-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.reservation-card__animal {
font-weight: 600;
font-size: 1.05rem;
}
.reservation-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.reservation-badge--verfuegbar {
background: #2e7d32;
}
.reservation-badge--reserviert {
background: #ef6c00;
}
.reservation-badge--abgegeben {
background: #6d6d6d;
}
.reservation-card__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem 1rem;
margin: 0.6rem 0 0;
}
.reservation-card__meta div {
display: flex;
flex-direction: column;
}
.reservation-card__meta dt {
font-size: 0.75rem;
color: var(--muted, #777);
}
.reservation-card__meta dd {
margin: 0;
}
.reservation-card__note {
margin: 0.5rem 0 0;
font-style: italic;
color: var(--muted, #555);
}
.reservation-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.75rem;
}
.reservation-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
border: 1px solid var(--border, #ddd);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
background: var(--surface-alt, #fafafa);
}
.reservation-form h3 {
grid-column: 1 / -1;
margin: 0;
}
.reservation-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.reservation-form__note {
grid-column: 1 / -1;
}
.reservation-form .error {
grid-column: 1 / -1;
}
.reservation-form__actions {
grid-column: 1 / -1;
display: flex;
gap: 0.5rem;
}

View File

@@ -22,6 +22,8 @@ export const de = {
more: 'Mehr', more: 'Mehr',
// FEAT-12a (Kevin): Abgabe // FEAT-12a (Kevin): Abgabe
forSale: 'Abgabe', forSale: 'Abgabe',
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben)
reservations: 'Reservierungen',
// FEAT-13 (Kelly): Verträge + Einstellungen // FEAT-13 (Kelly): Verträge + Einstellungen
contracts: 'Verträge', contracts: 'Verträge',
settings: 'Einstellungen', settings: 'Einstellungen',
@@ -362,6 +364,69 @@ export const de = {
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.', notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
}, },
}, },
// ── ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben) ──
reservierungen: {
title: 'Reservierungen',
subtitle: 'Reservierungs- und Abgabe-Status der Abgabetiere verwalten.',
loadError: 'Reservierungen konnten nicht geladen werden.',
empty: 'Noch keine Reservierungen erfasst.',
newButton: 'Neue Reservierung',
// Status-Werte (Anzeige).
status: {
verfuegbar: 'Verfügbar',
reserviert: 'Reserviert',
abgegeben: 'Abgegeben',
},
// Filter-Chips nach Status.
filters: {
all: 'Alle',
verfuegbar: 'Verfügbar',
reserviert: 'Reserviert',
abgegeben: 'Abgegeben',
},
// Spalten / Feld-Beschriftungen.
fields: {
animal: 'Tier',
status: 'Status',
reservedFor: 'Reserviert für',
appointment: 'Termin',
price: 'Preis',
note: 'Notiz',
handedOver: 'Abgegeben am',
none: '—',
},
// Formular (Anlegen / Bearbeiten).
form: {
addTitle: 'Reservierung anlegen',
editTitle: 'Reservierung bearbeiten',
animal: 'Tier',
animalPlaceholder: 'Tier auswählen …',
status: 'Status',
reservedFor: 'Reserviert für (Kontakt)',
contactNone: '— niemand —',
appointment: 'Termin',
price: 'Preis (€)',
note: 'Notiz',
handedOver: 'Abgegeben am',
save: 'Speichern',
cancel: 'Abbrechen',
saveError: 'Speichern fehlgeschlagen.',
chooseAnimal: 'Bitte ein Tier auswählen.',
},
// Aktionen pro Zeile.
actions: {
edit: 'Bearbeiten',
delete: 'Löschen',
markReserved: 'Reservieren',
markHandedOver: 'Als abgegeben markieren',
markAvailable: 'Wieder freigeben',
confirmDelete: 'Diese Reservierung wirklich löschen?',
deleteError: 'Löschen fehlgeschlagen.',
},
// Hinweis: Status ≠ Vertrag.
contractHint:
'Hinweis: Der Status ist eine Vormerkung. Die rechtliche Abgabe erfolgt weiterhin über einen Vertrag.',
},
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ── // ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
becken: { becken: {
title: 'Gehege', title: 'Gehege',