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:
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2374
File diff suppressed because it is too large.
Load diff
+175
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user