feat: turn the dish catalog into a permanent meal wishlist (domain, api, migration, ui)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 18:49:58 +02:00
1 parent cb70a2f0a6
commit 019060ccea
13 files changed
+3140 -25

No files matched your search

@@ -41,6 +41,10 @@ public static class MealEndpoints
group.MapPost("/wishes/{id:guid}/reject", RejectWishAsync).RequirePermission(Permission.MealPlanEdit);
group.MapDelete("/wishes/{id:guid}", DeleteWishAsync).RequirePermission(Permission.MealPlanEdit);
group.MapGet("/wishlist", GetWishlistAsync).RequirePermission(Permission.MealPlanRead);
group.MapPost("/wishlist/{id:guid}/plan", PlanWishlistDishAsync).RequirePermission(Permission.MealPlanEdit).Validate<PlanWishlistDishRequest>();
group.MapDelete("/wishlist/{id:guid}", DeleteWishlistDishAsync).RequirePermission(Permission.MealPlanEdit);
return app;
}
@@ -337,6 +341,7 @@ public static class MealEndpoints
AppDbContext db,
ISettingsProvider settingsProvider,
IHubContext<LiveHub> hub,
ILoggerFactory loggerFactory,
CancellationToken ct)
{
if (!MealPlanRules.CanProposeWish(await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider)))
@@ -360,6 +365,7 @@ public static class MealEndpoints
};
db.MealWishes.Add(wish);
await db.SaveChangesAsync(ct);
await UpsertWishedDishAsync(db, wish, user.UserId(), loggerFactory, ct);
await NotifyWishesChangedAsync(hub, ct);
var names = await UserNamesAsync(db, [wish.CreatedBy], ct);
@@ -419,24 +425,7 @@ public static class MealEndpoints
};
wish.Accept(user.UserId() ?? Guid.Empty, timeProvider.GetUtcNow(), entry.Id);
try
{
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);
if (occupied is not null)
{
db.MealPlanEntries.Remove(occupied); // Soft Delete gibt den Platz frei
await db.SaveChangesAsync(ct);
}
db.MealPlanEntries.Add(entry);
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
});
}
catch (DbUpdateException)
if (!await SaveEntryInSlotAsync(db, occupied, entry, ct))
{
return EndpointHelpers.Conflict("Der Essenplan wurde inzwischen geändert. Bitte neu laden und erneut versuchen.");
}
@@ -506,8 +495,177 @@ public static class MealEndpoints
return TypedResults.NoContent();
}
// ---------- Wunschliste ----------
internal static async Task<Results<Ok<List<WishlistDishDto>>, ForbidHttpResult>> GetWishlistAsync(
string? q, string? sort, int? take, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, CancellationToken ct)
{
if (!MealPlanRules.CanSeeWishlist(await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider)))
{
return TypedResults.Forbid();
}
var query = db.Dishes.AsNoTracking();
if (!string.IsNullOrWhiteSpace(q))
{
var term = Dish.Normalize(q);
query = query.Where(d => d.NormalizedName.Contains(term));
}
// SQL Server sortiert NULL (nie geplant) bei absteigender Sortierung ans Ende.
var ordered = MealPlanRules.NormalizeWishlistSort(sort) switch
{
WishlistSort.MostPlanned => query.OrderByDescending(d => d.UseCount).ThenBy(d => d.NormalizedName),
WishlistSort.Name => query.OrderBy(d => d.NormalizedName),
_ => query.OrderByDescending(d => d.LastPlannedAt).ThenBy(d => d.NormalizedName),
};
var dishes = await ordered.Take(Math.Clamp(take ?? 200, 1, 500)).ToListAsync(ct);
var names = await UserNamesAsync(db, dishes.Select(d => d.WishedBy), ct);
return TypedResults.Ok(dishes.Select(d => ToDto(d, names)).ToList());
}
internal static async Task<Results<Ok<MealPlanEntryDto>, NotFound, ForbidHttpResult, ValidationProblem, ProblemHttpResult>> PlanWishlistDishAsync(
Guid id,
PlanWishlistDishRequest request,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
IHubContext<LiveHub> hub,
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
CancellationToken ct)
{
if (!MealPlanRules.CanEditPlan(await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider)))
{
return TypedResults.Forbid(); // Kinder wünschen nur
}
var dish = await db.Dishes.AsNoTracking().SingleOrDefaultAsync(d => d.Id == id, ct);
if (dish is null)
{
return TypedResults.NotFound();
}
var settings = await settingsProvider.GetAsync(ct);
if (!MealPlanRules.IsValidMealType(request.MealType, settings.MealTypes))
{
return UnknownMealType(nameof(PlanWishlistDishRequest.MealType));
}
var occupied = await db.MealPlanEntries.SingleOrDefaultAsync(e => e.Date == request.Date && e.MealType == request.MealType, ct);
if (occupied is not null && !request.Replace)
{
return EndpointHelpers.Conflict($"Für diesen Platz ist bereits „{occupied.DishName}“ geplant.");
}
var entry = new MealPlanEntry
{
Date = request.Date,
MealType = request.MealType,
DishName = dish.Name,
Note = dish.Note,
RecipeUrl = dish.RecipeUrl,
Ingredients = [.. dish.Ingredients],
};
if (!await SaveEntryInSlotAsync(db, occupied, entry, ct))
{
return EndpointHelpers.Conflict("Der Essenplan wurde inzwischen geändert. Bitte neu laden und erneut versuchen.");
}
await TrackDishAsync(db, entry, countUse: true, timeProvider, loggerFactory, ct);
if (occupied is not null)
{
await hub.Clients.Group(LiveHub.MealsGroup).SendAsync(MealEvents.EntryRemoved, occupied.Id, ct);
}
var dto = ToDto(entry);
await hub.Clients.Group(LiveHub.MealsGroup).SendAsync(MealEvents.EntryChanged, dto, ct);
await NotifyWishesChangedAsync(hub, ct);
return TypedResults.Ok(dto);
}
internal static async Task<Results<NoContent, ForbidHttpResult>> DeleteWishlistDishAsync(
Guid id, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, IHubContext<LiveHub> hub, CancellationToken ct)
{
var dish = await db.Dishes.SingleOrDefaultAsync(d => d.Id == id, ct);
if (dish is null)
{
return TypedResults.NoContent(); // idempotent
}
var access = await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider);
var wasPlanned = dish.UseCount > 0 || dish.LastPlannedAt is not null;
if (!MealPlanRules.CanDeleteWishlistEntry(access, dish.WishedBy, wasPlanned, user.UserId() ?? Guid.Empty))
{
return TypedResults.Forbid();
}
db.Dishes.Remove(dish); // Soft Delete über den Interceptor
await db.SaveChangesAsync(ct);
await NotifyWishesChangedAsync(hub, ct);
return TypedResults.NoContent();
}
// ---------- Hilfen ----------
/// <summary>Legt das Gericht eines Wunschs in der Wunschliste an bzw. erhöht den Wunschzähler (gelöschte werden reaktiviert).</summary>
private static async Task UpsertWishedDishAsync(AppDbContext db, MealWish wish, Guid? userId, ILoggerFactory loggerFactory, CancellationToken ct)
{
try
{
var normalized = Dish.Normalize(wish.DishName);
var dish = await db.Dishes.IgnoreQueryFilters().SingleOrDefaultAsync(d => d.NormalizedName == normalized, ct);
if (dish is null)
{
dish = new Dish { NormalizedName = normalized, Name = wish.DishName };
db.Dishes.Add(dish);
}
else if (dish.IsDeleted)
{
dish.Restore();
}
dish.Note = wish.Note ?? dish.Note;
dish.WishedBy = userId ?? dish.WishedBy;
dish.WishCount++;
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
// Der Wunsch selbst ist gespeichert; die Wunschliste ist nicht kritisch (z. B. gleichzeitiges Anlegen).
loggerFactory.CreateLogger(typeof(MealEndpoints)).LogDebug(ex, "Wunschliste konnte nicht aktualisiert werden.");
db.ChangeTracker.Clear();
}
}
/// <summary>Speichert den neuen Eintrag; ein belegter Platz wird in derselben Transaktion freigegeben. <c>false</c> bei Konflikt.</summary>
private static async Task<bool> SaveEntryInSlotAsync(AppDbContext db, MealPlanEntry? occupied, MealPlanEntry entry, CancellationToken ct)
{
try
{
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);
if (occupied is not null)
{
db.MealPlanEntries.Remove(occupied); // Soft Delete gibt den Platz frei
await db.SaveChangesAsync(ct);
}
db.MealPlanEntries.Add(entry);
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
});
return true;
}
catch (DbUpdateException)
{
return false;
}
}
/// <summary>Merkt sich Gericht, Rezeptlink und Zutaten für Autovervollständigung und Vorbelegung.</summary>
private static async Task TrackDishAsync(
AppDbContext db, MealPlanEntry entry, bool countUse, TimeProvider timeProvider, ILoggerFactory loggerFactory, CancellationToken ct)
@@ -515,13 +673,17 @@ public static class MealEndpoints
try
{
var normalized = Dish.Normalize(entry.DishName);
var dish = await db.Dishes.SingleOrDefaultAsync(d => d.NormalizedName == normalized, ct);
var dish = await db.Dishes.IgnoreQueryFilters().SingleOrDefaultAsync(d => d.NormalizedName == normalized, ct);
if (dish is null)
{
dish = new Dish { NormalizedName = normalized };
db.Dishes.Add(dish);
countUse = true;
}
else if (dish.IsDeleted)
{
dish.Restore();
}
dish.Name = entry.DishName;
dish.RecipeUrl = entry.RecipeUrl ?? dish.RecipeUrl;
@@ -534,6 +696,7 @@ public static class MealEndpoints
{
dish.UseCount++;
dish.LastUsedAt = timeProvider.GetUtcNow();
dish.LastPlannedAt = dish.LastUsedAt;
}
await db.SaveChangesAsync(ct);
@@ -571,6 +734,10 @@ public static class MealEndpoints
entry.Id, entry.Date, entry.MealType, entry.DishName, entry.Note, entry.RecipeUrl, [.. entry.Ingredients],
entry.WishId, entry.UpdatedBy, entry.UpdatedAt, entry.RowVersion);
private static WishlistDishDto ToDto(Dish dish, Dictionary<Guid, string> names) => new(
dish.Id, dish.Name, dish.Note, dish.RecipeUrl, [.. dish.Ingredients], dish.WishCount, dish.UseCount, dish.LastPlannedAt,
dish.WishedBy, dish.WishedBy is { } wishedBy ? names.GetValueOrDefault(wishedBy) : null);
private static MealWishDto ToDto(MealWish wish, Dictionary<Guid, string> names) => new(
wish.Id, wish.DishName, wish.Note, wish.PreferredDate, wish.PreferredMealType, wish.Status, wish.CreatedBy,
wish.CreatedBy is { } createdBy ? names.GetValueOrDefault(createdBy) : null, wish.CreatedAt, wish.DecidedAt, wish.PlanEntryId);
+32 -2
View File
@@ -39,22 +39,52 @@ public sealed class MealPlanEntry : AuditableEntity, ISoftDeletable, IHasRowVers
public MealSlot Slot => new(Date, MealType);
}
/// <summary>Katalog bisheriger Gerichte für Autovervollständigung und Vorbelegung (Rezeptlink, Zutaten).</summary>
public sealed class Dish : AuditableEntity
/// <summary>
/// Dauerhafte, durchsuchbare Wunschliste bisheriger, gewünschter und geplanter Gerichte (ADR-0013). Der eindeutige
/// <see cref="NormalizedName"/> dedupliziert; <see cref="UseCount"/> zählt Planungen. Dient zugleich der
/// Autovervollständigung und Vorbelegung (Rezeptlink, Zutaten).
/// </summary>
public sealed class Dish : AuditableEntity, ISoftDeletable
{
public string NormalizedName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Note { get; set; }
public string? RecipeUrl { get; set; }
public List<string> Ingredients { get; set; } = [];
/// <summary>Wie oft das Gericht in den Plan aufgenommen wurde.</summary>
public int UseCount { get; set; }
public DateTimeOffset LastUsedAt { get; set; }
/// <summary>Wer sich das Gericht zuletzt gewünscht hat (optional).</summary>
public Guid? WishedBy { get; set; }
/// <summary>Wie oft das Gericht gewünscht wurde.</summary>
public int WishCount { get; set; }
/// <summary>Zuletzt geplant am (<c>null</c> = noch nie geplant).</summary>
public DateTimeOffset? LastPlannedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
/// <summary>Gelöschtes Gericht wieder in die Wunschliste holen (bei erneutem Wunsch oder Planen).</summary>
public void Restore()
{
IsDeleted = false;
DeletedAt = null;
DeletedBy = null;
}
}
public enum MealWishStatus
@@ -108,6 +108,33 @@ public static class MealPlanRules
AccessLevel.Restricted => wishCreatedBy == userId && status == MealWishStatus.Open,
_ => false,
};
/// <summary>Wunschliste ansehen: Familie, Admin und Kinder; Gäste nicht.</summary>
public static bool CanSeeWishlist(AccessLevel editAccess) => editAccess >= AccessLevel.Restricted;
/// <summary>Familie/Admin löschen jedes Gericht der Wunschliste, Kinder nur selbst gewünschte, die nie geplant wurden.</summary>
public static bool CanDeleteWishlistEntry(AccessLevel editAccess, Guid? wishedBy, bool wasPlanned, Guid userId) => editAccess switch
{
AccessLevel.Full => true,
AccessLevel.Restricted => wishedBy is { } wisher && wisher == userId && !wasPlanned,
_ => false,
};
/// <summary>Sortierung der Wunschliste: <c>lastPlanned</c> (Standard), <c>mostPlanned</c>, <c>name</c>.</summary>
public static string NormalizeWishlistSort(string? sort) => sort?.Trim().ToLowerInvariant() switch
{
"mostplanned" => WishlistSort.MostPlanned,
"name" => WishlistSort.Name,
_ => WishlistSort.LastPlanned,
};
}
/// <summary>Sortierschlüssel der Wunschliste (Query-Parameter <c>sort</c>).</summary>
public static class WishlistSort
{
public const string LastPlanned = "lastPlanned";
public const string MostPlanned = "mostPlanned";
public const string Name = "name";
}
/// <summary>Vorschau einer Zutat für die Übernahme auf die Einkaufsliste.</summary>
@@ -29,8 +29,10 @@ internal sealed class DishConfiguration : IEntityTypeConfiguration<Dish>
builder.ToTable("Dishes");
builder.Property(d => d.NormalizedName).HasMaxLength(MealPlanEntry.DishNameMaxLength).IsRequired();
builder.Property(d => d.Name).HasMaxLength(MealPlanEntry.DishNameMaxLength).IsRequired();
builder.Property(d => d.Note).HasMaxLength(MealWish.NoteMaxLength);
builder.Property(d => d.RecipeUrl).HasMaxLength(MealPlanEntry.RecipeUrlMaxLength);
builder.PrimitiveCollection(d => d.Ingredients).IsRequired();
// Eindeutig über alle Zeilen (auch gelöschte): ein gelöschtes Gericht wird bei erneutem Wunsch/Planen reaktiviert.
builder.HasIndex(d => d.NormalizedName).IsUnique();
builder.HasIndex(d => d.UseCount);
}
@@ -0,0 +1,175 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase11dMealWishlist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletedAt",
table: "Dishes",
type: "datetimeoffset",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "DeletedBy",
table: "Dishes",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "Dishes",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastPlannedAt",
table: "Dishes",
type: "datetimeoffset",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Note",
table: "Dishes",
type: "nvarchar(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WishCount",
table: "Dishes",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<Guid>(
name: "WishedBy",
table: "Dishes",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Dishes_IsDeleted",
table: "Dishes",
column: "IsDeleted");
// Daten: aus dem bisherigen Gerichtekatalog wird die dauerhafte Wunschliste (ADR-0013).
// Jede Anweisung ist ein eigener Batch, damit die neuen Spalten sichtbar sind.
migrationBuilder.Sql(
"""
UPDATE [Dishes] SET [LastPlannedAt] = [LastUsedAt] WHERE [UseCount] > 0;
""");
// Geplante Einträge, die im Katalog fehlen (z. B. wenn die Katalogpflege früher fehlschlug), nachtragen.
migrationBuilder.Sql(
"""
WITH [Planned] AS (
SELECT
LOWER(LTRIM(RTRIM([DishName]))) AS [Key],
LTRIM(RTRIM([DishName])) AS [Name],
[RecipeUrl],
[Ingredients],
[CreatedAt],
ROW_NUMBER() OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName]))) ORDER BY [Date] DESC, [CreatedAt] DESC) AS [Rank],
COUNT(*) OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName])))) AS [Total]
FROM [MealPlanEntries]
WHERE [IsDeleted] = 0 AND LTRIM(RTRIM([DishName])) <> ''
)
INSERT INTO [Dishes] ([Id], [CreatedAt], [UpdatedAt], [NormalizedName], [Name], [RecipeUrl], [Ingredients],
[UseCount], [LastUsedAt], [LastPlannedAt], [WishCount], [IsDeleted])
SELECT NEWID(), [CreatedAt], [CreatedAt], [Key], [Name], [RecipeUrl], COALESCE([Ingredients], N'[]'),
[Total], [CreatedAt], [CreatedAt], 0, 0
FROM [Planned] p
WHERE p.[Rank] = 1 AND NOT EXISTS (SELECT 1 FROM [Dishes] d WHERE d.[NormalizedName] = p.[Key]);
""");
// Offene und übernommene Essenswünsche als Gerichte (dedupliziert über lower(trim(DishName))).
migrationBuilder.Sql(
"""
WITH [Wished] AS (
SELECT
LOWER(LTRIM(RTRIM([DishName]))) AS [Key],
LTRIM(RTRIM([DishName])) AS [Name],
[Note],
[CreatedBy],
[CreatedAt],
ROW_NUMBER() OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName]))) ORDER BY [CreatedAt] DESC) AS [Rank],
COUNT(*) OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName])))) AS [Total]
FROM [MealWishes]
WHERE [IsDeleted] = 0 AND [Status] IN (N'Open', N'Accepted') AND LTRIM(RTRIM([DishName])) <> ''
)
INSERT INTO [Dishes] ([Id], [CreatedAt], [CreatedBy], [UpdatedAt], [NormalizedName], [Name], [Note], [Ingredients],
[UseCount], [LastUsedAt], [WishCount], [WishedBy], [IsDeleted])
SELECT NEWID(), [CreatedAt], [CreatedBy], [CreatedAt], [Key], [Name], [Note], N'[]',
0, [CreatedAt], [Total], [CreatedBy], 0
FROM [Wished] w
WHERE w.[Rank] = 1 AND NOT EXISTS (SELECT 1 FROM [Dishes] d WHERE d.[NormalizedName] = w.[Key]);
""");
// Bereits vorhandene Gerichte: Wunschzähler und Wünscher ergänzen (nur Wünsche, deren Gericht nicht eben neu angelegt wurde).
migrationBuilder.Sql(
"""
UPDATE d
SET d.[WishCount] = d.[WishCount] + w.[Total],
d.[WishedBy] = COALESCE(d.[WishedBy], w.[CreatedBy]),
d.[Note] = COALESCE(d.[Note], w.[Note])
FROM [Dishes] d
INNER JOIN (
SELECT
LOWER(LTRIM(RTRIM([DishName]))) AS [Key],
MAX([Note]) AS [Note],
MAX([CreatedBy]) AS [CreatedBy],
COUNT(*) AS [Total]
FROM [MealWishes]
WHERE [IsDeleted] = 0 AND [Status] IN (N'Open', N'Accepted') AND LTRIM(RTRIM([DishName])) <> ''
GROUP BY LOWER(LTRIM(RTRIM([DishName])))
) w ON w.[Key] = d.[NormalizedName]
WHERE d.[WishCount] = 0;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Dishes_IsDeleted",
table: "Dishes");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Dishes");
migrationBuilder.DropColumn(
name: "DeletedBy",
table: "Dishes");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "Dishes");
migrationBuilder.DropColumn(
name: "LastPlannedAt",
table: "Dishes");
migrationBuilder.DropColumn(
name: "Note",
table: "Dishes");
migrationBuilder.DropColumn(
name: "WishCount",
table: "Dishes");
migrationBuilder.DropColumn(
name: "WishedBy",
table: "Dishes");
}
}
}
@@ -1090,10 +1090,22 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.PrimitiveCollection<string>("Ingredients")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LastPlannedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("LastUsedAt")
.HasColumnType("datetimeoffset");
@@ -1107,6 +1119,10 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Note")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<string>("RecipeUrl")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
@@ -1120,8 +1136,16 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Property<int>("UseCount")
.HasColumnType("int");
b.Property<int>("WishCount")
.HasColumnType("int");
b.Property<Guid?>("WishedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("NormalizedName")
.IsUnique();
@@ -79,6 +79,22 @@ public sealed record CreateMealWishRequest(string DishName, string? Note = null,
/// <summary>Wunsch in einen Platz übernehmen. Ist der Platz belegt, wird nur mit <see cref="Replace"/> ersetzt, sonst 409.</summary>
public sealed record AcceptMealWishRequest(DateOnly Date, string MealType, bool Replace = false);
/// <summary>Gericht der dauerhaften Wunschliste (ADR-0013). <see cref="UseCount"/> = wie oft geplant, <see cref="WishCount"/> = wie oft gewünscht.</summary>
public sealed record WishlistDishDto(
Guid Id,
string Name,
string? Note,
string? RecipeUrl,
IReadOnlyList<string> Ingredients,
int WishCount,
int UseCount,
DateTimeOffset? LastPlannedAt,
Guid? WishedBy,
string? WishedByName);
/// <summary>Gericht der Wunschliste in einen Platz planen. Ist der Platz belegt, wird nur mit <see cref="Replace"/> ersetzt, sonst 409.</summary>
public sealed record PlanWishlistDishRequest(DateOnly Date, string MealType, bool Replace = false);
/// <summary>Namen der SignalR-Ereignisse des Essenplans am <c>/hubs/live</c>-Hub.</summary>
public static class MealEvents
{
@@ -138,6 +154,11 @@ public sealed class AcceptMealWishRequestValidator : AbstractValidator<AcceptMea
public AcceptMealWishRequestValidator() => MealValidation.MealSlot(this, r => r.Date, r => r.MealType);
}
public sealed class PlanWishlistDishRequestValidator : AbstractValidator<PlanWishlistDishRequest>
{
public PlanWishlistDishRequestValidator() => MealValidation.MealSlot(this, r => r.Date, r => r.MealType);
}
public sealed class IngredientsToShoppingRequestValidator : AbstractValidator<IngredientsToShoppingRequest>
{
public IngredientsToShoppingRequestValidator() =>
@@ -0,0 +1,108 @@
@using FamilyPlanner.Domain.Meals
@using FamilyPlanner.Shared.Meals
<section class="fp-wishlist" aria-labelledby="wishlist-title">
<MudText Typo="Typo.h6" HtmlTag="h2" id="wishlist-title" Class="mb-1">
Wunschliste
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="ml-1 fp-wishlist-count">@Items.Count</MudChip>
</MudText>
<MudText Typo="Typo.body2" Class="mb-2">Alle gewünschten und bisher geplanten Gerichte – jederzeit wieder in den Plan übernehmbar.</MudText>
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudTextField T="string" Value="Search" ValueChanged="OnSearchChanged" Label="Wunschliste durchsuchen" Variant="Variant.Outlined"
Margin="Margin.Dense" Clearable="true" InputId="wishlist-search" Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" Class="fp-wishlist-search" />
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" aria-label="Sortierung der Wunschliste">
<MudButton Class="fp-wishlist-sort-lastPlanned" Color="@SortColor(WishlistSort.LastPlanned)" OnClick="@(() => SelectSort(WishlistSort.LastPlanned))">Zuletzt geplant</MudButton>
<MudButton Class="fp-wishlist-sort-mostPlanned" Color="@SortColor(WishlistSort.MostPlanned)" OnClick="@(() => SelectSort(WishlistSort.MostPlanned))">Am häufigsten</MudButton>
<MudButton Class="fp-wishlist-sort-name" Color="@SortColor(WishlistSort.Name)" OnClick="@(() => SelectSort(WishlistSort.Name))">A–Z</MudButton>
</MudButtonGroup>
</div>
@if (Items.Count == 0)
{
<MudText Typo="Typo.body2" Class="fp-empty fp-wishlist-empty">
@(string.IsNullOrWhiteSpace(Search) ? "Die Wunschliste ist noch leer." : "Kein Gericht gefunden.")
</MudText>
}
else
{
<ul class="fp-wish-items fp-wishlist-items">
@foreach (var dish in Items)
{
<li class="fp-wish fp-wishlist-item">
<div class="fp-wish-main">
<span class="fp-wish-dish fp-wishlist-name">@dish.Name</span>
<span class="fp-wish-meta fp-wishlist-meta">@Meta(dish)</span>
@if (dish.Note is not null)
{
<span class="fp-wish-note">„@dish.Note“</span>
}
</div>
@if (CanPlan)
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EventAvailable"
OnClick="@(() => OnPlan.InvokeAsync(dish))" Class="fp-wishlist-plan"
aria-label="@($"„{dish.Name}“ in den Plan")">In den Plan</MudButton>
}
@if (CanDelete(dish))
{
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" OnClick="@(() => OnDelete.InvokeAsync(dish))"
Class="fp-wishlist-delete" aria-label="@($"„{dish.Name}“ aus der Wunschliste löschen")" />
}
</li>
}
</ul>
}
</section>
@code {
[Parameter, EditorRequired]
public IReadOnlyList<WishlistDishDto> Items { get; set; } = [];
[Parameter]
public string? Search { get; set; }
[Parameter]
public string Sort { get; set; } = WishlistSort.LastPlanned;
[Parameter]
public bool CanPlan { get; set; }
[Parameter]
public Func<WishlistDishDto, bool> CanDelete { get; set; } = _ => false;
[Parameter]
public EventCallback<string?> OnSearch { get; set; }
[Parameter]
public EventCallback<string> OnSort { get; set; }
[Parameter]
public EventCallback<WishlistDishDto> OnPlan { get; set; }
[Parameter]
public EventCallback<WishlistDishDto> OnDelete { get; set; }
private Task OnSearchChanged(string? value) => OnSearch.InvokeAsync(value);
private Task SelectSort(string sort) => OnSort.InvokeAsync(sort);
private Color SortColor(string sort) => Sort == sort ? Color.Primary : Color.Default;
private static string Meta(WishlistDishDto dish)
{
var parts = new List<string> { MealFormat.LastPlanned(dish.LastPlannedAt) };
if (dish.UseCount > 0)
{
parts.Add($"{MealFormat.Times(dish.UseCount)} geplant");
}
if (dish.WishCount > 0)
{
parts.Add($"{MealFormat.Times(dish.WishCount)} gewünscht" + (dish.WishedByName is { Length: > 0 } name ? $" (zuletzt {name})" : string.Empty));
}
return string.Join(" · ", parts);
}
}
@@ -30,5 +30,12 @@ public static class MealFormat
public static string IngredientsText(IEnumerable<string> ingredients) => string.Join("\n", ingredients);
/// <summary>„zuletzt geplant am 12.10.2026“ bzw. „noch nie geplant“ (Familienzeitzone).</summary>
public static string LastPlanned(DateTimeOffset? value) => value is { } at
? $"zuletzt geplant am {TimeZoneInfo.ConvertTime(at, FamilyPlanner.Domain.Common.FamilyTime.Berlin).ToString("dd.MM.yyyy", German)}"
: "noch nie geplant";
public static string Times(int count) => count == 1 ? "1-mal" : $"{count}-mal";
public static string IngredientCount(int count) => count == 1 ? "1 Zutat" : $"{count} Zutaten";
}
@@ -0,0 +1,87 @@
@using FamilyPlanner.Domain.Meals
@using FamilyPlanner.Shared.Meals
@inject IMealsApi MealsApi
<MudDialog>
<DialogContent>
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="mb-3 fp-dialog-error">@_error</MudAlert>
}
@if (_pending is { } pending && Occupant(pending) is { } occupant)
{
<MudAlert Severity="Severity.Warning" Class="mb-3 fp-accept-replace">
Am @MealFormat.Day(pending.Date) (@pending.MealType) ist bereits „@occupant.DishName“ geplant.
<div class="d-flex gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Warning" Disabled="_busy" OnClick="@(() => PlanAsync(pending, replace: true))"
Class="fp-accept-replace-confirm">Ersetzen</MudButton>
<MudButton Variant="Variant.Text" Disabled="_busy" OnClick="@(() => _pending = null)">Anderen Platz wählen</MudButton>
</div>
</MudAlert>
}
else
{
<MudText Typo="Typo.body2" Class="mb-3">Wann gibt es „@Dish.Name“?</MudText>
<MealSlotPicker Days="Days" MealTypes="MealTypes" Entries="Entries" Today="Today" OnSelect="SelectAsync" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public WishlistDishDto Dish { get; set; } = default!;
[Parameter, EditorRequired]
public IReadOnlyList<DateOnly> Days { get; set; } = [];
[Parameter, EditorRequired]
public IReadOnlyList<string> MealTypes { get; set; } = [];
[Parameter]
public IReadOnlyList<MealPlanEntryDto> Entries { get; set; } = [];
[Parameter]
public DateOnly Today { get; set; }
private MealSlot? _pending;
private string? _error;
private bool _busy;
private MealPlanEntryDto? Occupant(MealSlot slot) => Entries.FirstOrDefault(e => e.Date == slot.Date && e.MealType == slot.MealType);
private Task SelectAsync(MealSlot slot)
{
if (Occupant(slot) is not null)
{
_pending = slot; // erst bestätigen lassen
return Task.CompletedTask;
}
return PlanAsync(slot, replace: false);
}
private async Task PlanAsync(MealSlot slot, bool replace)
{
_busy = true;
_error = null;
var result = await MealsApi.PlanWishlistDishAsync(Dish.Id, new PlanWishlistDishRequest(slot.Date, slot.MealType, replace));
_busy = false;
if (result.Problem is { } problem)
{
_pending = null;
_error = problem.Message;
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
private void Cancel() => Dialog?.Cancel();
}
+80 -4
View File
@@ -153,6 +153,8 @@ else
{
<MealWishList Wishes="_wishes" CanDecide="CanDecideWish" CanDelete="CanDeleteWish"
OnAccept="AcceptWishAsync" OnReject="RejectWishAsync" OnDelete="DeleteWishAsync" />
<DishWishlist Items="_wishlist" Search="_wishlistSearch" Sort="_wishlistSort" CanPlan="CanEdit" CanDelete="CanDeleteWishlistDish"
OnSearch="SearchWishlistAsync" OnSort="SortWishlistAsync" OnPlan="PlanWishlistDishAsync" OnDelete="DeleteWishlistDishAsync" />
}
}
@@ -170,6 +172,9 @@ else
private MealPlanDto? _plan;
private List<MealPlanEntryDto> _entries = [];
private List<MealWishDto> _wishes = [];
private List<WishlistDishDto> _wishlist = [];
private string? _wishlistSearch;
private string _wishlistSort = WishlistSort.LastPlanned;
private List<DishSuggestionDto>? _dishes;
private DateOnly? _from;
private string? _error;
@@ -193,6 +198,9 @@ else
private bool CanDeleteWish(MealWishDto wish) => MealPlanRules.CanDeleteWish(EditAccess, wish.CreatedBy, wish.Status, UserId);
private bool CanDeleteWishlistDish(WishlistDishDto dish) =>
MealPlanRules.CanDeleteWishlistEntry(EditAccess, dish.WishedBy, dish.UseCount > 0 || dish.LastPlannedAt is not null, UserId);
private IReadOnlyList<string> MealTypes =>
_plan is null ? [] : MealPlanRules.EffectiveMealTypes(_plan.ConfiguredMealTypes, _entries.Select(e => e.MealType));
@@ -204,7 +212,7 @@ else
Live.Connected += OnConnectedAsync;
Live.StateChanged += OnLiveStateChanged;
await Live.EnsureStartedAsync();
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync());
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync(), LoadWishlistAsync());
}
// ---------- Laden ----------
@@ -240,6 +248,20 @@ else
}
}
private async Task LoadWishlistAsync()
{
if (!CanProposeWish)
{
return;
}
var result = await MealsApi.GetWishlistAsync(_wishlistSearch, _wishlistSort);
if (result.Value is { } items)
{
_wishlist = items;
}
}
private async Task<IReadOnlyList<DishSuggestionDto>> DishesAsync()
{
if (_dishes is null)
@@ -400,6 +422,7 @@ else
_wishes.RemoveAll(w => w.Id == wish.Id);
_wishes.Insert(0, wish);
Snackbar.Add($"Dein Wunsch „{wish.DishName}“ wurde gesendet.", Severity.Success);
await LoadWishlistAsync();
}
}
@@ -424,7 +447,7 @@ else
_entries.RemoveAll(e => e.Date == entry.Date && e.MealType == entry.MealType);
Upsert(entry);
Snackbar.Add($"„{wish.DishName}“ ist eingeplant.", Severity.Success);
await LoadWishesAsync();
await Task.WhenAll(LoadWishesAsync(), LoadWishlistAsync());
}
}
@@ -448,6 +471,59 @@ else
Snackbar.Add($"Wunsch „{wish.DishName}“ gelöscht.", Severity.Normal);
}
// ---------- Wunschliste ----------
private async Task SearchWishlistAsync(string? query)
{
_wishlistSearch = query;
await LoadWishlistAsync();
}
private async Task SortWishlistAsync(string sort)
{
_wishlistSort = sort;
await LoadWishlistAsync();
}
private async Task PlanWishlistDishAsync(WishlistDishDto dish)
{
if (_plan is null)
{
return;
}
var parameters = new DialogParameters<PlanDishDialog>
{
{ d => d.Dish, dish },
{ d => d.Days, _plan.Days },
{ d => d.MealTypes, _plan.ConfiguredMealTypes },
{ d => d.Entries, _entries },
{ d => d.Today, _plan.Today },
};
var dialog = await DialogService.ShowAsync<PlanDishDialog>("In den Plan", parameters, DialogDefaults);
if (await dialog.Result is { Canceled: false, Data: MealPlanEntryDto entry })
{
_entries.RemoveAll(e => e.Date == entry.Date && e.MealType == entry.MealType);
Upsert(entry);
Snackbar.Add($"„{dish.Name}“ ist eingeplant.", Severity.Success);
await LoadWishlistAsync();
}
}
private async Task DeleteWishlistDishAsync(WishlistDishDto dish)
{
var result = await MealsApi.DeleteWishlistDishAsync(dish.Id);
if (result.Problem is { } problem)
{
Snackbar.Add(problem.Message, Severity.Error);
return;
}
_wishlist.RemoveAll(d => d.Id == dish.Id);
_dishes = null; // Autovervollständigung neu laden
Snackbar.Add($"„{dish.Name}“ aus der Wunschliste gelöscht.", Severity.Normal);
}
// ---------- Live-Aktualisierung ----------
private void Upsert(MealPlanEntryDto entry)
@@ -481,13 +557,13 @@ else
private Task OnRemoteWishesChangedAsync() => InvokeAsync(async () =>
{
await LoadWishesAsync();
await Task.WhenAll(LoadWishesAsync(), LoadWishlistAsync());
StateHasChanged();
});
private Task OnConnectedAsync() => InvokeAsync(async () =>
{
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync());
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync(), LoadWishlistAsync());
StateHasChanged();
});
@@ -27,6 +27,13 @@ public interface IMealsApi
Task<ApiResult<MealWishDto>> RejectWishAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteWishAsync(Guid id, CancellationToken ct = default);
/// <summary>Dauerhafte Wunschliste; <paramref name="sort"/> = <c>lastPlanned</c>, <c>mostPlanned</c> oder <c>name</c>.</summary>
Task<ApiResult<List<WishlistDishDto>>> GetWishlistAsync(string? query, string sort, CancellationToken ct = default);
Task<ApiResult<MealPlanEntryDto>> PlanWishlistDishAsync(Guid id, PlanWishlistDishRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteWishlistDishAsync(Guid id, CancellationToken ct = default);
}
public sealed class MealsApi(HttpClient http) : IMealsApi
@@ -66,6 +73,16 @@ public sealed class MealsApi(HttpClient http) : IMealsApi
public Task<ApiResult<bool>> DeleteWishAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/wishes/{id}", ct), ct);
public Task<ApiResult<List<WishlistDishDto>>> GetWishlistAsync(string? query, string sort, CancellationToken ct = default) =>
SendAsync<List<WishlistDishDto>>(
() => http.GetAsync($"{Base}/wishlist?sort={Uri.EscapeDataString(sort)}{(string.IsNullOrWhiteSpace(query) ? string.Empty : $"&q={Uri.EscapeDataString(query.Trim())}")}", ct), ct);
public Task<ApiResult<MealPlanEntryDto>> PlanWishlistDishAsync(Guid id, PlanWishlistDishRequest request, CancellationToken ct = default) =>
SendAsync<MealPlanEntryDto>(() => http.PostAsJsonAsync($"{Base}/wishlist/{id}/plan", request, ct), ct);
public Task<ApiResult<bool>> DeleteWishlistDishAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/wishlist/{id}", ct), ct);
/// <summary>Wie <see cref="ApiCall"/>, aber 409 liefert die Fehlermeldung des Servers (kein aktueller Stand im Body).</summary>
private static async Task<ApiResult<T>> SendAsync<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
{