Merge branch 'worktree-agent-a36d202be311b9289'
# Conflicts: # GerbilManagerWebAPI/ApplicationContext.cs # GerbilManagerWebAPI/Program.cs # gerbil-manager-web/e2e/mock-data.ts
This commit is contained in:
214
GerbilManager.Tests/SaleReservationEndpointTests.cs
Normal file
214
GerbilManager.Tests/SaleReservationEndpointTests.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal file
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal 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);
|
||||||
|
}
|
||||||
124
GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs
Normal file
124
GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1596
GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs
generated
Normal file
1596
GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
58
GerbilManagerWebAPI/Models/SaleReservation.cs
Normal file
58
GerbilManagerWebAPI/Models/SaleReservation.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ app.MapRequestEndpoints();
|
|||||||
app.MapNamesEndpoints();
|
app.MapNamesEndpoints();
|
||||||
app.MapFeedbackEndpoints();
|
app.MapFeedbackEndpoints();
|
||||||
app.MapAcquisitionEndpoints();
|
app.MapAcquisitionEndpoints();
|
||||||
|
app.MapSaleReservationEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
gerbil-manager-web/e2e/reservierungen.spec.ts
Normal file
78
gerbil-manager-web/e2e/reservierungen.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
66
gerbil-manager-web/src/api/reservations.ts
Normal file
66
gerbil-manager-web/src/api/reservations.ts
Normal 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}`)
|
||||||
|
}
|
||||||
@@ -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: '📊' },
|
||||||
|
|||||||
417
gerbil-manager-web/src/pages/ReservierungenPage.tsx
Normal file
417
gerbil-manager-web/src/pages/ReservierungenPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
gerbil-manager-web/src/pages/reservierungen.css
Normal file
172
gerbil-manager-web/src/pages/reservierungen.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user