feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll
Detailseite (Rennmausakte): - Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen, Charakter als Chips, getabte Fotos/Gesundheit/Gewicht. - Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen). - Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular). Listen: - Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge, Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button. - Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header. Zucht-Nachname: - Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix". - „Eigene Zucht" ist die Standard-Herkunft neuer Tiere. Weiteres: - Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage). - Toast-Rückmeldungen für alle Speichern-Aktionen. - Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
@@ -133,6 +134,10 @@ public class ApplicationContext : DbContext
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
modelBuilder.Entity<EnclosurePhoto>(e =>
|
||||
e.HasOne<Enclosure>().WithMany()
|
||||
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||
modelBuilder.Entity<SaleContract>(e =>
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||
|
||||
@@ -97,7 +97,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
public record EnclosureInput(string Name, string? Notes);
|
||||
|
||||
|
||||
@@ -29,5 +29,6 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Phone,
|
||||
string Email,
|
||||
string Homepage,
|
||||
string City);
|
||||
string City,
|
||||
string NameSuffix);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver };
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
||||
db.Contacts.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
@@ -35,7 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver;
|
||||
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -55,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver);
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
|
||||
app.MapGet("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
var photos = await db.EnclosurePhotos.AsNoTracking()
|
||||
.Where(p => p.EnclosureId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapPost("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName);
|
||||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||
var root = PhotoRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
|
||||
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||
|
||||
var photo = new EnclosurePhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EnclosureId = id,
|
||||
FileName = fileName,
|
||||
Caption = caption,
|
||||
SortOrder = nextSort,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.EnclosurePhotos.Add(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
|
||||
}).WithTags("Photos").DisableAntiforgery();
|
||||
|
||||
app.MapDelete("/enclosure-photos/{id:guid}",
|
||||
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return TypedResults.NotFound();
|
||||
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
db.EnclosurePhotos.Remove(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapGet("/photos/files/{fileName}",
|
||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
|
||||
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
s.Email = input.Email ?? "";
|
||||
s.Homepage = input.Homepage ?? "";
|
||||
s.City = input.City ?? "";
|
||||
s.NameSuffix = input.NameSuffix ?? "";
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
|
||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBreedingNameSuffix : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSuffix",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSuffix",
|
||||
table: "BreederSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "BreederSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
||||
column: "NameSuffix",
|
||||
value: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSuffix",
|
||||
table: "Contacts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSuffix",
|
||||
table: "BreederSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.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 AddEnclosurePhotos : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EnclosurePhotos",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EnclosureId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
Caption = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EnclosurePhotos", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EnclosurePhotos_Enclosures_EnclosureId",
|
||||
column: x => x.EnclosureId,
|
||||
principalTable: "Enclosures",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EnclosurePhotos_EnclosureId",
|
||||
table: "EnclosurePhotos",
|
||||
column: "EnclosureId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EnclosurePhotos");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,10 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("NameSuffix")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -185,6 +189,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
Email = "",
|
||||
Homepage = "",
|
||||
Name = "",
|
||||
NameSuffix = "",
|
||||
Phone = "",
|
||||
ZuchtName = ""
|
||||
});
|
||||
@@ -727,6 +732,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("NameSuffix")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -756,6 +764,35 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Enclosures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EnclosureId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnclosureId");
|
||||
|
||||
b.ToTable("EnclosurePhotos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1302,6 +1339,15 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EnclosureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
||||
|
||||
@@ -18,6 +18,13 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
public string ZuchtName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Namens-Anhängsel der eigenen Zucht (wie ein „Nachname"), z. B.
|
||||
/// „von den kleinen Chaoten“. Wird an Namen eigener (selbst gezüchteter)
|
||||
/// Tiere angehängt: „Danako von den kleinen Chaoten“.
|
||||
/// </summary>
|
||||
public string NameSuffix { get; set; } = "";
|
||||
|
||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
|
||||
@@ -18,5 +18,11 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
public bool IsBreeder { get; set; }
|
||||
public bool IsReceiver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Namens-Anhängsel dieser Zucht (wie ein „Nachname"), z. B.
|
||||
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
|
||||
/// </summary>
|
||||
public string? NameSuffix { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>A photo of an enclosure (Gehege). Mirrors <see cref="GerbilPhoto"/>:
|
||||
/// the file lives under the shared photo-storage root; only the file name is in the DB.</summary>
|
||||
public class EnclosurePhoto
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public Guid EnclosureId { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user