Merge branch 'worktree-agent-a5f48334696288159'
# Conflicts: # gerbil-manager-web/e2e/mock-data.ts
This commit is contained in:
175
GerbilManager.Tests/AcquisitionEndpointTests.cs
Normal file
175
GerbilManager.Tests/AcquisitionEndpointTests.cs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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>
|
||||||
|
/// ERWERB: acquisition data per animal (purchase date/price/note).
|
||||||
|
/// - POST creates, GET ?gerbilId= filters, PUT updates, DELETE removes.
|
||||||
|
/// - CRITICAL: acquisition rows survive the import re-ingest wipe (loose, FK-free
|
||||||
|
/// GerbilId/SourceContactId), exactly like Feedback.
|
||||||
|
/// </summary>
|
||||||
|
public class AcquisitionEndpointTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public AcquisitionEndpointTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Crud_roundtrip_create_filter_update_delete()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var gerbilId = Guid.NewGuid();
|
||||||
|
var contactId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
var resp = await client.PostAsJsonAsync("/acquisitions", new
|
||||||
|
{
|
||||||
|
gerbilId,
|
||||||
|
sourceContactId = contactId,
|
||||||
|
date = "2025-03-14",
|
||||||
|
price = 25.50m,
|
||||||
|
note = "Auf der Börse gekauft.",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
var id = created.GetProperty("id").GetString()!;
|
||||||
|
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||||
|
Assert.Equal(25.50m, created.GetProperty("price").GetDecimal());
|
||||||
|
Assert.Equal("2025-03-14", created.GetProperty("date").GetString());
|
||||||
|
|
||||||
|
// GET ?gerbilId= returns it
|
||||||
|
var listed = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions?gerbilId={gerbilId}")).RootElement;
|
||||||
|
Assert.Contains(listed.EnumerateArray(),
|
||||||
|
a => a.GetProperty("note").GetString() == "Auf der Börse gekauft.");
|
||||||
|
|
||||||
|
// a different gerbilId yields nothing
|
||||||
|
var other = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||||
|
Assert.Empty(other.EnumerateArray());
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
var put = await client.PutAsJsonAsync($"/acquisitions/{id}", new
|
||||||
|
{
|
||||||
|
gerbilId,
|
||||||
|
sourceContactId = (Guid?)null,
|
||||||
|
date = "2025-04-01",
|
||||||
|
price = 30m,
|
||||||
|
note = "Korrigiert.",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
var afterPut = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions/{id}")).RootElement;
|
||||||
|
Assert.Equal(30m, afterPut.GetProperty("price").GetDecimal());
|
||||||
|
Assert.Equal("Korrigiert.", afterPut.GetProperty("note").GetString());
|
||||||
|
Assert.True(afterPut.GetProperty("sourceContactId").ValueKind == JsonValueKind.Null);
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
var del = await client.DeleteAsync($"/acquisitions/{id}");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||||
|
var gone = await client.GetAsync($"/acquisitions/{id}");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquisition_survives_ingest_wipe()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "acq-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("acq-ingest-" + Guid.NewGuid().ToString("N"))
|
||||||
|
.Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
// An acquisition referencing the gerbil + contact the wipe will delete.
|
||||||
|
var acqId = Guid.NewGuid();
|
||||||
|
db.AcquisitionRecords.Add(new AcquisitionRecord
|
||||||
|
{
|
||||||
|
Id = acqId,
|
||||||
|
GerbilId = fatherId,
|
||||||
|
SourceContactId = contactId,
|
||||||
|
Date = new DateOnly(2025, 3, 14),
|
||||||
|
Price = 25.50m,
|
||||||
|
Note = "Auf der Börse gekauft.",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
Assert.Contains("Ingestion successful!", result);
|
||||||
|
|
||||||
|
// The acquisition row survives even though the gerbil + contact were wiped/recreated.
|
||||||
|
var survivor = await db.AcquisitionRecords.SingleAsync(a => a.Id == acqId);
|
||||||
|
Assert.Equal(fatherId, survivor.GerbilId);
|
||||||
|
Assert.Equal(contactId, survivor.SourceContactId);
|
||||||
|
Assert.Equal(25.50m, survivor.Price);
|
||||||
|
Assert.Equal("Auf der Börse gekauft.", survivor.Note);
|
||||||
|
Assert.Equal(1, await db.AcquisitionRecords.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Request> Requests => Set<Request>();
|
public DbSet<Request> Requests => Set<Request>();
|
||||||
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>();
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -212,6 +213,16 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasIndex(f => f.CreatedAt);
|
e.HasIndex(f => f.CreatedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ERWERB: like Feedback, deliberately relationship-free. GerbilId/SourceContactId
|
||||||
|
// are plain nullable Guid columns (no navigation properties → EF creates NO foreign
|
||||||
|
// key), so the import re-ingest wipe of Gerbils/Contacts never cascades into — or
|
||||||
|
// breaks — acquisition rows. They survive re-ingest, which is the whole point.
|
||||||
|
modelBuilder.Entity<AcquisitionRecord>(e =>
|
||||||
|
{
|
||||||
|
e.Property(a => a.Price).HasPrecision(10, 2);
|
||||||
|
e.HasIndex(a => a.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)
|
||||||
{
|
{
|
||||||
|
|||||||
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal file
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Dtos
|
||||||
|
{
|
||||||
|
/// <summary>ERWERB: payload for POST/PUT /acquisitions (acquisition data per animal).</summary>
|
||||||
|
public record AcquisitionInput(
|
||||||
|
Guid? GerbilId,
|
||||||
|
Guid? SourceContactId,
|
||||||
|
DateOnly? Date,
|
||||||
|
decimal? Price,
|
||||||
|
string? Note);
|
||||||
|
|
||||||
|
/// <summary>ERWERB: response DTO for a stored acquisition record.</summary>
|
||||||
|
public record AcquisitionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid? GerbilId,
|
||||||
|
Guid? SourceContactId,
|
||||||
|
DateOnly? Date,
|
||||||
|
decimal? Price,
|
||||||
|
string? Note,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
}
|
||||||
89
GerbilManagerWebAPI/Endpoints/AcquisitionEndpoints.cs
Normal file
89
GerbilManagerWebAPI/Endpoints/AcquisitionEndpoints.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ERWERB: acquisition data per animal (purchase date/price/note).
|
||||||
|
/// GET /acquisitions?gerbilId=… -> records for one animal (or all), newest date first.
|
||||||
|
/// GET /acquisitions/{id} -> a single record.
|
||||||
|
/// POST /acquisitions -> create, returns 201.
|
||||||
|
/// PUT /acquisitions/{id} -> update, returns 204.
|
||||||
|
/// DELETE /acquisitions/{id} -> delete, returns 204.
|
||||||
|
/// Decoupled from gerbils/contacts (loose nullable Guid columns, no FK), so rows survive
|
||||||
|
/// the import re-ingest wipe (mirrors Feedback).
|
||||||
|
/// </summary>
|
||||||
|
public static class AcquisitionEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAcquisitionEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/acquisitions").WithTags("Acquisitions");
|
||||||
|
|
||||||
|
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||||
|
{
|
||||||
|
var rows = await db.AcquisitionRecords.AsNoTracking()
|
||||||
|
.Where(a => gerbilId == null || a.GerbilId == gerbilId)
|
||||||
|
.ToListAsync();
|
||||||
|
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column,
|
||||||
|
// and DateOnly ordering stays consistent across providers this way.
|
||||||
|
return TypedResults.Ok(rows
|
||||||
|
.OrderByDescending(a => a.Date ?? DateOnly.MinValue)
|
||||||
|
.ThenByDescending(a => a.CreatedAt)
|
||||||
|
.Select(ToDto)
|
||||||
|
.ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{id:guid}", async Task<Results<Ok<AcquisitionDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var a = await db.AcquisitionRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
return a is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(a));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (AcquisitionInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var a = new AcquisitionRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GerbilId = input.GerbilId,
|
||||||
|
SourceContactId = input.SourceContactId,
|
||||||
|
Date = input.Date,
|
||||||
|
Price = input.Price,
|
||||||
|
Note = string.IsNullOrWhiteSpace(input.Note) ? null : input.Note.Trim(),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.AcquisitionRecords.Add(a);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Created($"/acquisitions/{a.Id}", ToDto(a));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, AcquisitionInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var a = await db.AcquisitionRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (a is null) return TypedResults.NotFound();
|
||||||
|
a.GerbilId = input.GerbilId;
|
||||||
|
a.SourceContactId = input.SourceContactId;
|
||||||
|
a.Date = input.Date;
|
||||||
|
a.Price = input.Price;
|
||||||
|
a.Note = string.IsNullOrWhiteSpace(input.Note) ? null : input.Note.Trim();
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var a = await db.AcquisitionRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (a is null) return TypedResults.NotFound();
|
||||||
|
db.AcquisitionRecords.Remove(a);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AcquisitionDto ToDto(AcquisitionRecord a) =>
|
||||||
|
new(a.Id, a.GerbilId, a.SourceContactId, a.Date, a.Price, a.Note, a.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
1580
GerbilManagerWebAPI/Migrations/20260622201453_AddAcquisitionRecord.Designer.cs
generated
Normal file
1580
GerbilManagerWebAPI/Migrations/20260622201453_AddAcquisitionRecord.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAcquisitionRecord : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AcquisitionRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
SourceContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
Date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AcquisitionRecords", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AcquisitionRecords_GerbilId",
|
||||||
|
table: "AcquisitionRecords",
|
||||||
|
column: "GerbilId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AcquisitionRecords");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,38 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.AcquisitionRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("Date")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GerbilId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Price")
|
||||||
|
.HasPrecision(10, 2)
|
||||||
|
.HasColumnType("numeric(10,2)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SourceContactId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GerbilId");
|
||||||
|
|
||||||
|
b.ToTable("AcquisitionRecords");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
38
GerbilManagerWebAPI/Models/AcquisitionRecord.cs
Normal file
38
GerbilManagerWebAPI/Models/AcquisitionRecord.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ERWERB: how/when an animal was acquired (purchase date, price, note) — sourced from
|
||||||
|
/// RennmausPro's <c>herktier_tb</c> (_TID/_DATE/_PRICE/_BEM). The seller is already linked
|
||||||
|
/// via Gerbil.OriginContactId; this record adds the date/price/note around that.
|
||||||
|
///
|
||||||
|
/// Decoupled from the rest of the model ON PURPOSE — GerbilId/SourceContactId are plain
|
||||||
|
/// nullable Guid columns (NOT enforced foreign keys), so the import re-ingest wipe
|
||||||
|
/// (IngestResolvedService, which deletes Gerbils/Contacts) never cascades into — or
|
||||||
|
/// breaks — acquisition rows. They survive re-ingest, mirroring the Feedback pattern.
|
||||||
|
/// </summary>
|
||||||
|
public class AcquisitionRecord
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loose reference (no FK) to the acquired gerbil.</summary>
|
||||||
|
public Guid? GerbilId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loose reference (no FK) to the source/seller contact, if known.</summary>
|
||||||
|
public Guid? SourceContactId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the animal was acquired (purchase date).</summary>
|
||||||
|
public DateOnly? Date { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Acquisition price (purchase cost), if recorded.</summary>
|
||||||
|
public decimal? Price { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free-text note about the acquisition.</summary>
|
||||||
|
public string? Note { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Server-side creation time.</summary>
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
|
|||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
app.MapNamesEndpoints();
|
app.MapNamesEndpoints();
|
||||||
app.MapFeedbackEndpoints();
|
app.MapFeedbackEndpoints();
|
||||||
|
app.MapAcquisitionEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
45
gerbil-manager-web/e2e/erwerb.spec.ts
Normal file
45
gerbil-manager-web/e2e/erwerb.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/** ERWERB: Erwerb/Kauf je Tier — Sektion in der Rennmausakte (anlegen / bearbeiten / löschen). */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.tierTabs.acquisition
|
||||||
|
|
||||||
|
test('Tierakte: Erwerb erfassen, bearbeiten und löschen', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
|
||||||
|
const section = page.locator('section.ak-card', { hasText: t.sectionTitle })
|
||||||
|
await expect(section).toBeVisible()
|
||||||
|
await expect(section).toContainText(t.empty)
|
||||||
|
|
||||||
|
// Anlegen: Formular öffnen, Felder füllen, speichern.
|
||||||
|
await section.getByRole('button', { name: t.addButton }).click()
|
||||||
|
await section.getByLabel(t.fields.date).fill('2025-03-14')
|
||||||
|
await section.getByLabel(t.fields.price).fill('25.50')
|
||||||
|
await section.getByLabel(t.fields.note).fill('Auf der Börse gekauft.')
|
||||||
|
await section.getByRole('button', { name: t.addButton }).click()
|
||||||
|
|
||||||
|
// Eintrag erscheint in der Liste, im Mock gespeichert.
|
||||||
|
await expect(section).toContainText('25,50')
|
||||||
|
await expect(section).toContainText('Auf der Börse gekauft.')
|
||||||
|
expect(mockDb).not.toBeNull()
|
||||||
|
expect(mockDb!.acquisitions.length).toBe(1)
|
||||||
|
expect(mockDb!.acquisitions[0]).toMatchObject({
|
||||||
|
gerbilId: 'kruemel',
|
||||||
|
date: '2025-03-14',
|
||||||
|
price: 25.5,
|
||||||
|
note: 'Auf der Börse gekauft.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bearbeiten: Preis ändern.
|
||||||
|
await section.getByRole('button', { name: t.edit }).click()
|
||||||
|
await section.getByLabel(t.fields.price).fill('30')
|
||||||
|
await section.getByRole('button', { name: t.saveButton }).click()
|
||||||
|
await expect(section).toContainText('30,00')
|
||||||
|
expect(mockDb!.acquisitions[0]).toMatchObject({ price: 30 })
|
||||||
|
|
||||||
|
// Löschen (window.confirm bestätigen).
|
||||||
|
page.once('dialog', (d) => d.accept())
|
||||||
|
await section.getByRole('button', { name: t.delete }).click()
|
||||||
|
await expect(section).toContainText(t.empty)
|
||||||
|
expect(mockDb!.acquisitions.length).toBe(0)
|
||||||
|
})
|
||||||
@@ -533,6 +533,50 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 200, db.rpro3.execute)
|
return json(route, 200, db.rpro3.execute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERWERB: Erwerb/Kauf je Tier — GET ?gerbilId= (Array, kein Gridify-Paging),
|
||||||
|
// POST/PUT/DELETE. Vor den generischen Kollektionen, weil GET kein {items}-Objekt
|
||||||
|
// liefert und nach gerbilId statt Gridify-filter selektiert.
|
||||||
|
const acqMatch = path.match(/^\/acquisitions(?:\/([^/]+))?$/)
|
||||||
|
if (acqMatch) {
|
||||||
|
const acqId = acqMatch[1] ? decodeURIComponent(acqMatch[1]) : null
|
||||||
|
if (!acqId) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
const gid = url.searchParams.get('gerbilId')
|
||||||
|
const rows = db.acquisitions.filter((a) => !gid || a.gerbilId === gid)
|
||||||
|
return json(route, 200, [...rows].reverse())
|
||||||
|
}
|
||||||
|
if (method === 'POST') {
|
||||||
|
const created = {
|
||||||
|
id: newId('acq'),
|
||||||
|
sourceContactId: null,
|
||||||
|
date: null,
|
||||||
|
price: null,
|
||||||
|
note: null,
|
||||||
|
...(request.postDataJSON() as Row),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
db.acquisitions.push(created)
|
||||||
|
return json(route, 201, created)
|
||||||
|
}
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
const ai = db.acquisitions.findIndex((a) => a.id === acqId)
|
||||||
|
if (method === 'GET') {
|
||||||
|
return ai >= 0 ? json(route, 200, db.acquisitions[ai]) : json(route, 404, { title: 'Not Found' })
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
if (ai < 0) return json(route, 404, { title: 'Not Found' })
|
||||||
|
Object.assign(db.acquisitions[ai], request.postDataJSON() as Row)
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
if (ai < 0) return json(route, 404, { title: 'Not Found' })
|
||||||
|
db.acquisitions.splice(ai, 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
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface MockDb {
|
|||||||
analyze: Record<string, unknown>
|
analyze: Record<string, unknown>
|
||||||
execute: Record<string, unknown>
|
execute: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
// ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — /acquisitions
|
||||||
|
acquisitions: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -541,5 +543,6 @@ export function seedDb(): MockDb {
|
|||||||
message: 'Import erfolgreich: 3694 Tiere, 1678 Würfe, 508 Kontakte.',
|
message: 'Import erfolgreich: 3694 Tiere, 1678 Würfe, 508 Kontakte.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
acquisitions: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
gerbil-manager-web/src/api/acquisitions.ts
Normal file
45
gerbil-manager-web/src/api/acquisitions.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* ERWERB: typed API client for the acquisition (AcquisitionRecord) resource.
|
||||||
|
* Captures when/how an animal was acquired + its price. Decoupled from the gerbil
|
||||||
|
* (loose nullable ids, no FK) so rows survive the import re-ingest wipe.
|
||||||
|
*/
|
||||||
|
import { api } from './client'
|
||||||
|
import type { DateOnlyString } from './types'
|
||||||
|
|
||||||
|
const RESOURCE = '/acquisitions'
|
||||||
|
|
||||||
|
export interface Acquisition {
|
||||||
|
id: string
|
||||||
|
gerbilId: string | null
|
||||||
|
sourceContactId: string | null
|
||||||
|
date: DateOnlyString | null
|
||||||
|
price: number | null
|
||||||
|
note: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for POST/PUT /acquisitions. */
|
||||||
|
export interface AcquisitionInput {
|
||||||
|
gerbilId?: string | null
|
||||||
|
sourceContactId?: string | null
|
||||||
|
date?: DateOnlyString | null
|
||||||
|
price?: number | null
|
||||||
|
note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All acquisition records for one animal (newest acquisition date first). */
|
||||||
|
export function listAcquisitions(gerbilId: string): Promise<Acquisition[]> {
|
||||||
|
return api.get<Acquisition[]>(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAcquisition(body: AcquisitionInput): Promise<Acquisition> {
|
||||||
|
return api.post<Acquisition>(RESOURCE, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAcquisition(id: string, body: AcquisitionInput): Promise<void> {
|
||||||
|
return api.put<void>(`${RESOURCE}/${id}`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAcquisition(id: string): Promise<void> {
|
||||||
|
return api.delete(`${RESOURCE}/${id}`)
|
||||||
|
}
|
||||||
204
gerbil-manager-web/src/components/GerbilAcquisitionSection.tsx
Normal file
204
gerbil-manager-web/src/components/GerbilAcquisitionSection.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* ERWERB: compact, self-contained acquisition section for the Rennmausakte.
|
||||||
|
* Shows when/at what price an animal was acquired (purchase date, price, note),
|
||||||
|
* with inline add / edit / delete. Backed by /acquisitions (loose nullable ids,
|
||||||
|
* no FK) so rows survive the import re-ingest wipe.
|
||||||
|
*
|
||||||
|
* Embedded with a single line in GerbilDetailPage.
|
||||||
|
*/
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import {
|
||||||
|
createAcquisition,
|
||||||
|
deleteAcquisition,
|
||||||
|
listAcquisitions,
|
||||||
|
updateAcquisition,
|
||||||
|
type Acquisition,
|
||||||
|
} from '../api/acquisitions'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { formatDate } from '../format/labels'
|
||||||
|
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
return `${price.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GerbilAcquisitionSection({ gerbilId }: { gerbilId: string }) {
|
||||||
|
const t = de.pages.tierTabs.acquisition
|
||||||
|
const records = useApi(() => listAcquisitions(gerbilId), [gerbilId])
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<string | null>(null) // null = none, '' = new
|
||||||
|
const [date, setDate] = useState('')
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const save = useMutation((id: string | '') => {
|
||||||
|
const body = {
|
||||||
|
gerbilId,
|
||||||
|
date: date || null,
|
||||||
|
price: price.trim() === '' ? null : Number(price),
|
||||||
|
note: note.trim() || null,
|
||||||
|
}
|
||||||
|
return id === '' ? createAcquisition(body) : updateAcquisition(id, body)
|
||||||
|
})
|
||||||
|
const removal = useMutation((id: string) => deleteAcquisition(id))
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
setEditing('')
|
||||||
|
setDate('')
|
||||||
|
setPrice('')
|
||||||
|
setNote('')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(a: Acquisition) {
|
||||||
|
setEditing(a.id)
|
||||||
|
setDate(a.date ?? '')
|
||||||
|
setPrice(a.price === null ? '' : String(a.price))
|
||||||
|
setNote(a.note ?? '')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setEditing(null)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmedNote = note.trim()
|
||||||
|
if (!date && price.trim() === '' && trimmedNote === '') {
|
||||||
|
setError(t.validation.empty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (price.trim() !== '') {
|
||||||
|
const value = Number(price)
|
||||||
|
if (Number.isNaN(value) || value < 0 || value > 9999) {
|
||||||
|
setError(t.validation.priceRange)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
const r = await save.run(editing ?? '')
|
||||||
|
if (r.ok) {
|
||||||
|
setEditing(null)
|
||||||
|
records.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm(t.deleteConfirm)) return
|
||||||
|
await removal.run(id)
|
||||||
|
records.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = records.data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ak-card">
|
||||||
|
<h2 className="ak-h2">{t.sectionTitle}</h2>
|
||||||
|
<p className="ak-empty" style={{ marginTop: 0 }}>
|
||||||
|
{t.intro}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{records.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{records.error && (
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{records.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={records.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{removal.error && <div className="alert alert--error">{removal.error}</div>}
|
||||||
|
|
||||||
|
{!records.loading && !records.error && items.length === 0 && editing === null && (
|
||||||
|
<p className="ak-empty">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{items.map((a) => (
|
||||||
|
<li key={a.id} className="record-card record-card--row">
|
||||||
|
<span className="record-card__date">{a.date ? formatDate(a.date) : t.noDate}</span>
|
||||||
|
<strong>{a.price === null ? t.noPrice : formatPrice(a.price)}</strong>
|
||||||
|
{a.note && <span className="muted">{a.note}</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => startEdit(a)}
|
||||||
|
disabled={editing !== null}
|
||||||
|
>
|
||||||
|
{t.edit}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger"
|
||||||
|
onClick={() => onDelete(a.id)}
|
||||||
|
disabled={removal.pending || editing !== null}
|
||||||
|
>
|
||||||
|
{t.delete}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing !== null ? (
|
||||||
|
<form className="weight-quick-add" onSubmit={onSubmit} noValidate>
|
||||||
|
<h4>{editing === '' ? t.addTitle : t.editTitle}</h4>
|
||||||
|
<div className="weight-quick-add__row">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.date}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.price}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={0}
|
||||||
|
max={9999}
|
||||||
|
step={0.01}
|
||||||
|
className="input"
|
||||||
|
value={price}
|
||||||
|
placeholder="0,00"
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.note}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="ak-saverow">
|
||||||
|
<button type="submit" className="ak-btn primary" disabled={save.pending}>
|
||||||
|
{save.pending ? t.saving : editing === '' ? t.addButton : t.saveButton}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ak-btn" onClick={cancel} disabled={save.pending}>
|
||||||
|
{t.cancel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <small className="error-text">{error}</small>}
|
||||||
|
{save.error && <div className="alert alert--error">{save.error}</div>}
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="ak-saverow">
|
||||||
|
<button type="button" className="ak-btn primary" onClick={startNew}>
|
||||||
|
{t.addButton}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
|||||||
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||||
import type { Gender, GerbilStatus } from '../api/types'
|
import type { Gender, GerbilStatus } from '../api/types'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
|
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
|
||||||
import GerbilHealthTab from '../components/GerbilHealthTab'
|
import GerbilHealthTab from '../components/GerbilHealthTab'
|
||||||
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
||||||
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
||||||
@@ -336,6 +337,8 @@ export default function GerbilDetailPage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<GerbilAcquisitionSection gerbilId={g.id} />
|
||||||
|
|
||||||
<section className="ak-card">
|
<section className="ak-card">
|
||||||
<h2 className="ak-h2">{t.detail.genetics}</h2>
|
<h2 className="ak-h2">{t.detail.genetics}</h2>
|
||||||
{geno ? (
|
{geno ? (
|
||||||
|
|||||||
@@ -758,6 +758,32 @@ export const de = {
|
|||||||
fileRequired: 'Bitte zuerst ein Foto auswählen.',
|
fileRequired: 'Bitte zuerst ein Foto auswählen.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — eigene Sektion in der Akte.
|
||||||
|
acquisition: {
|
||||||
|
sectionTitle: 'Erwerb',
|
||||||
|
intro: 'Wann und zu welchem Preis dieses Tier erworben wurde.',
|
||||||
|
addTitle: 'Erwerb erfassen',
|
||||||
|
editTitle: 'Erwerb bearbeiten',
|
||||||
|
fields: {
|
||||||
|
date: 'Kaufdatum',
|
||||||
|
price: 'Preis (€)',
|
||||||
|
note: 'Notiz',
|
||||||
|
},
|
||||||
|
addButton: 'Hinzufügen',
|
||||||
|
saveButton: 'Speichern',
|
||||||
|
saving: 'Speichern …',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
edit: 'Bearbeiten',
|
||||||
|
delete: 'Löschen',
|
||||||
|
empty: 'Noch keine Erwerbsdaten erfasst.',
|
||||||
|
noDate: 'Ohne Datum',
|
||||||
|
noPrice: '—',
|
||||||
|
deleteConfirm: 'Diesen Erwerbseintrag wirklich löschen?',
|
||||||
|
validation: {
|
||||||
|
empty: 'Bitte mindestens Kaufdatum, Preis oder Notiz angeben.',
|
||||||
|
priceRange: 'Bitte einen plausiblen Preis (0 bis 9999 €) angeben.',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
|
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
|
||||||
datenexport: {
|
datenexport: {
|
||||||
|
|||||||
Reference in New Issue
Block a user