feat: edit meal ingredients as a list with a per-ingredient shopping basket button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac38444e1e
commit
7c4e1d2df5
9 files changed
+310
-11
No files matched your search
@@ -87,7 +87,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
|
||||
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
|
||||
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
|
||||
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
|
||||
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice, Paper vorgesehen, CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
|
||||
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice/Paper (Paper je Aufgabe oder für das ganze Blatt in einem Schritt, 12a), CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
|
||||
| Dashboard ✅ | `DashboardNote` (Title, Text, Soft Delete; Freitext der Info-Kachel, wird nur als Text dargestellt) |
|
||||
| Betrieb | `AuditLogEntry` ✅ (ActorName, Action, TargetType/Id/Name, Details, CreatedAt), `PushSubscription` ✅ (UserId, Endpoint + Hash, P256dh, Auth, LastSuccessAt, FailureCount) |
|
||||
|
||||
|
||||
+4
-2
@@ -162,6 +162,10 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- [x] KI-Chat-Kanal: `ChatChannelKind.Ai` (privat je Person, Absender „KI“ ohne Benutzerkonto), `AiPurpose.ChatAssistant` ohne Rückfall auf den Standard-Anbieter, Admin-Schalter und Kinder-Freigabe in den Familieneinstellungen (Standard aus/nur Eltern), fester kindgerechter System-Prompt, kurzer Kontext, Tageslimit, Protokoll ohne Inhalt, Fehlermeldungen ohne Anbietertext; Migration `Phase11bChat`
|
||||
- [x] Tests: Unit (Regeln, Prompt, GIF-Prüfung, Giphy-Adapter mit Stub), bUnit (Emoji, GIF, KI-Kanal), Integration (Rechte je Rolle, Privatsphäre, kein Rückfall, Limit, Fehler, Idempotenz)
|
||||
|
||||
## Phase 12a – Papierblatt und Zutatenliste
|
||||
- [x] Lernen: „Papier-Abgabe“ (Foto) für das gesamte Blatt in einem Schritt einstellen, nicht nur je Aufgabe – „Alle Aufgaben auf Papier stellen“ / „Alle auf Bildschirm“ im Blatt-Editor (`POST /api/learning/sets/{id}/items/paper`, `LearningManage`, Versionsprüfung, nach dem ersten Versuch gesperrt), Schalter „Auf Papier lösen“ im KI-Entwurf (`CreateAiDraftRequest.Paper`); Regeln in `LearningItemRules.TryConvertToPaper`/`ConvertToScreen`, keine Migration (ADR-0021)
|
||||
- [x] Essen: Zutaten als Liste erfassen (statt Freitext) mit Knopf je Zutat „In den Einkaufskorb“ – Zeilen mit Menge und Name im Essenplan-Dialog, bereits offene Artikel der Standardliste werden markiert, Snackbar-Rückmeldung; Speicherformat unverändert (`MealPlanEntry.Ingredients` bleibt Liste von Texten), keine Migration (ADR-0013)
|
||||
|
||||
## Phase 12 – Deployment
|
||||
- [ ] Finale `docker-compose.yml` (inkl. optionalem Caddy), `.env.example`
|
||||
- [ ] Backup-/Restore-Skripte
|
||||
@@ -181,8 +185,6 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- Rezeptdatenbank
|
||||
- Geburtstage und Jahrestage
|
||||
- Wand-Tablet-Modus (Kiosk-Dashboard)
|
||||
- Lernen: „Papier-Abgabe“ (Foto) für das gesamte Blatt in einem Schritt einstellen, nicht nur je Aufgabe
|
||||
- KI: optionale Online-Suche in Prompts (Ja/Nein, Opt-in je Anbieter und je Anfrage; nur für Anbieter, die das unterstützen, z. B. Claude-Web-Suche, Perplexity; Datenschutzhinweis)
|
||||
- KI: vollständiges Protokoll aller abgeschickten Prompts samt Antwort in der Datenbank (Admin-Ansicht, Aufbewahrungsdauer, Löschen; Inhalte des privaten KI-Chats nur auf ausdrückliche Admin-Einstellung)
|
||||
- Essen: Zutaten als Liste erfassen (statt Freitext) mit Knopf je Zutat „In den Einkaufskorb“
|
||||
- Kalender: Freigabe und Zwei-Wege-Synchronisation mit iOS/macOS (CalDAV-Server, ADR-0017) – Outlook über Microsoft Graph als eigener Folgeschritt
|
||||
@@ -45,6 +45,13 @@ Schnellerfassung (gelernte Kategorie, sonst Stichwörter). Bereits **offene** Ar
|
||||
Zutaten werden übersprungen und im Ergebnis gemeldet. Ohne Listenangabe wird die Standardliste verwendet; im Dialog lassen sich
|
||||
Liste und einzelne Zutaten auswählen. Die neuen Artikel werden wie üblich per `ShoppingItemChanged` an alle Clients gesendet.
|
||||
|
||||
**Zutatenliste (Phase 12a):** Im Eintragsdialog sind die Zutaten Zeilen aus Menge (optional) und Name statt eines Textfelds. Gespeichert bleibt
|
||||
unverändert `MealPlanEntry.Ingredients` (eine Zutat = ein Text, z. B. „500 g Hackfleisch“; keine Migration): Beim Laden wird die Menge nur abgespalten,
|
||||
wenn sie wörtlich am Anfang steht, sonst steht alles im Namen – alter Freitext geht so nie verloren. Jede Zeile hat „In den Einkaufskorb“ (nur mit
|
||||
`ShoppingWrite`, nur für gespeicherte Einträge): derselbe Endpunkt `to-shopping` mit genau dieser Zutat, Standardliste, Dubletten erkennt der Server;
|
||||
Rückmeldung per Snackbar. Beim Öffnen werden schon offene Artikel der Standardliste markiert. Offen: Eine dauerhafte „bereits übernommen“-Spur je
|
||||
Zutat gibt es nicht, die Markierung beruht auf den offenen Artikeln der Liste.
|
||||
|
||||
**Live-Updates:** Gruppe `meals` (alle Rollen) für `MealEntryChanged` (DTO) und `MealEntryRemoved` (Id); Wunschänderungen gehen
|
||||
als `MealWishesChanged` **ohne Nutzdaten** an die Gruppe `family` – jeder Client lädt seine (ggf. gefilterte) Sicht neu, damit
|
||||
Kinder keine Wünsche anderer Kinder erhalten.
|
||||
|
||||
@@ -166,6 +166,10 @@ Kinder-DTOs haben weiterhin keine Felder für Prompt/Anbieter/Modell/Quelle; das
|
||||
* Die Anfrage hängt am HttpClient-Zeitlimit des Browsers (Standard 100 s): Antwortet ein langsames lokales Modell später, meldet der Dialog das; ein trotzdem fertig gewordener Entwurf erscheint in der Liste. Anbieter-Zeitlimits über 100 s helfen für diesen Endpunkt also nicht.
|
||||
* Es gibt keine Streaming-Anzeige und keinen Abbruch-Knopf während der Erzeugung.
|
||||
|
||||
### Phase 12a: ganzes Blatt auf Papier
|
||||
* `POST /api/learning/sets/{id}/items/paper` (`SetSheetPaperRequest(Paper, Version)`, `LearningManage`) stellt alle Aufgaben auf `Paper` oder zurück auf den Bildschirm; wie die übrigen Aufgaben-Änderungen nur ohne Versuch (409), mit Versionsprüfung des Blatts (409 liefert den aktuellen Stand) und idempotent. Im KI-Entwurf (`CreateAiDraftRequest.Paper`) werden die geprüften Aufgaben nach dem Parsen umgestellt.
|
||||
* Regeln (`LearningItemRules`): `Paper` verlangt eine Musterlösung (höchstens 200 Zeichen). Bei Auswahlaufgaben wird der Text der richtigen Option zur Musterlösung (die Optionen bleiben gespeichert); Aufgaben ohne brauchbare Musterlösung bleiben unverändert am Bildschirm, der Editor meldet ihre Zahl. „Auf Bildschirm“ stellt eine Auswahl wieder her, wenn die Optionen noch passen, macht sonst eine reine Zahl zu `Number` und alles andere zu `Text` (der ursprüngliche Typ wird nicht gespeichert, daher keine Migration).
|
||||
|
||||
### Erweiterungspunkte für 10c (Papierlösung per Foto)
|
||||
* `LearningAnswerType.Paper`, `LearningVerdict.Open` und `LearningGrader.Ai` sind vorhanden; `LearningGrading.Grade` liefert für `Paper` bereits `Open`, `LearningItemRules.IsSupported` und `CheckSolution` müssen `Paper` freischalten (Validator und Editor-Auswahl).
|
||||
* Neue Entität `LearningPhoto` (Bytes in der Datenbank, an Versuch/Aufgabe gebunden, Signaturprüfung, Aufbewahrungsfrist) und Endpunkte unter `/api/learning/my/attempts/{id}/…` (Hochladen) bzw. `/api/learning/attempts/{id}/…` (Abruf nur für Eltern/Kind); `AttemptRunner`
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
@using FamilyPlanner.Domain.Meals
|
||||
@using FamilyPlanner.Domain.Shopping
|
||||
@using FamilyPlanner.Shared.Meals
|
||||
@using FamilyPlanner.Shared.Shopping
|
||||
@inject IMealsApi MealsApi
|
||||
@inject IShoppingApi ShoppingApi
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@@ -32,9 +37,45 @@
|
||||
<MudTextField @bind-Value="_recipeUrl" Label="Rezeptlink (optional)" InputId="meal-recipe" Variant="Variant.Outlined"
|
||||
InputType="InputType.Url" Placeholder="https://…" Error="@HasError("RecipeUrl")" ErrorText="@ErrorFor("RecipeUrl")" />
|
||||
|
||||
<MudTextField @bind-Value="_ingredients" Label="Zutaten (optional)" InputId="meal-ingredients" Variant="Variant.Outlined" Lines="5"
|
||||
HelperText="Eine Zutat pro Zeile, z. B. „500 g Hackfleisch“ – lassen sich später auf die Einkaufsliste setzen."
|
||||
Error="@HasError("Ingredients")" ErrorText="@ErrorFor("Ingredients")" />
|
||||
<fieldset class="fp-ingredients" aria-label="Zutaten">
|
||||
<legend class="mud-typography mud-typography-subtitle2">Zutaten (optional)</legend>
|
||||
@for (var i = 0; i < _ingredients.Count; i++)
|
||||
{
|
||||
var row = _ingredients[i];
|
||||
var index = i + 1;
|
||||
var onList = row.OnList(_openNames);
|
||||
<div class="d-flex align-center gap-1 mb-1 fp-ingredient-row" data-onlist="@(onList ? "true" : "false")">
|
||||
<MudTextField T="string" @bind-Value="row.Amount" Label="Menge" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||||
Class="fp-ingredient-amount" Style="max-width: 7.5rem" />
|
||||
<MudTextField T="string" @bind-Value="row.Name" Label="Zutat" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||||
Class="flex-grow-1 fp-ingredient-name" />
|
||||
@if (CanAddToShopping)
|
||||
{
|
||||
<MudTooltip Text="@(onList ? "Steht schon auf der Einkaufsliste" : "In den Einkaufskorb")">
|
||||
<MudIconButton Icon="@(onList ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.AddShoppingCart)"
|
||||
Color="@(onList ? Color.Success : Color.Default)"
|
||||
Disabled="@(onList || Entry is null || row.Text.Length == 0 || row.Busy)"
|
||||
OnClick="() => AddToShoppingAsync(row)" Class="fp-ingredient-cart"
|
||||
aria-label="@($"Zutat {index} in den Einkaufskorb")" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="() => _ingredients.Remove(row)" Class="fp-ingredient-remove"
|
||||
aria-label="@($"Zutat {index} entfernen")" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (HasError("Ingredients"))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Error">@ErrorFor("Ingredients")</MudText>
|
||||
}
|
||||
|
||||
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRow"
|
||||
Disabled="@(_ingredients.Count >= MealPlanEntry.MaxIngredients)" Class="fp-ingredient-add">Zutat hinzufügen</MudButton>
|
||||
@if (CanAddToShopping && Entry is null)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="fp-ingredient-hint">Der Einkaufskorb-Knopf geht, sobald das Gericht gespeichert ist.</MudText>
|
||||
}
|
||||
</fieldset>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</DialogContent>
|
||||
@@ -59,6 +100,10 @@
|
||||
[Parameter]
|
||||
public MealPlanEntryDto? Entry { get; set; }
|
||||
|
||||
/// <summary>Darf der Benutzer die Einkaufsliste ergänzen (<c>ShoppingWrite</c>)? Sonst gibt es keinen Einkaufskorb-Knopf je Zutat; die Api prüft es erneut.</summary>
|
||||
[Parameter]
|
||||
public bool CanAddToShopping { get; set; }
|
||||
|
||||
/// <summary>Bisherige Gerichte für die Autovervollständigung.</summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<DishSuggestionDto> Dishes { get; set; } = [];
|
||||
@@ -66,7 +111,9 @@
|
||||
private string _dishName = string.Empty;
|
||||
private string? _note;
|
||||
private string? _recipeUrl;
|
||||
private string? _ingredients;
|
||||
private readonly List<IngredientRow> _ingredients = [];
|
||||
private HashSet<string> _openNames = [];
|
||||
private ShoppingListDto? _list;
|
||||
private byte[]? _version;
|
||||
private List<DishSuggestionDto> _matches = [];
|
||||
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -80,11 +127,68 @@
|
||||
_dishName = entry.DishName;
|
||||
_note = entry.Note;
|
||||
_recipeUrl = entry.RecipeUrl;
|
||||
_ingredients = MealFormat.IngredientsText(entry.Ingredients);
|
||||
_ingredients.AddRange(entry.Ingredients.Select(IngredientRow.From));
|
||||
_version = entry.Version;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!CanAddToShopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Schon offene Artikel der Standardliste vormerken, damit niemand etwas doppelt in den Korb legt (die Api prüft es zusätzlich).
|
||||
var lists = await ShoppingApi.GetListsAsync();
|
||||
_list = lists.Value is { } all ? all.FirstOrDefault(l => l.IsDefault) ?? all.FirstOrDefault() : null;
|
||||
if (_list is not null)
|
||||
{
|
||||
var items = await ShoppingApi.GetItemsAsync(_list.Id);
|
||||
if (items.Value is { } open)
|
||||
{
|
||||
_openNames = open.Where(i => !i.IsDone).Select(i => ShoppingSuggestion.Normalize(i.Name)).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddRow()
|
||||
{
|
||||
if (_ingredients.Count < MealPlanEntry.MaxIngredients)
|
||||
{
|
||||
_ingredients.Add(new IngredientRow());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Eine einzelne Zutat auf die Standardliste setzen (vorhandener Weg <c>to-shopping</c>); Dubletten erkennt die Api.</summary>
|
||||
private async Task AddToShoppingAsync(IngredientRow row)
|
||||
{
|
||||
if (Entry is null || row.Text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
row.Busy = true;
|
||||
var result = await MealsApi.IngredientsToShoppingAsync(Entry.Id, new IngredientsToShoppingRequest(_list?.Id, [row.Text]));
|
||||
row.Busy = false;
|
||||
if (result.Problem is { } problem)
|
||||
{
|
||||
Snackbar.Add(problem.Message ?? "Die Zutat konnte nicht auf die Einkaufsliste gesetzt werden.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_openNames.Add(ShoppingSuggestion.Normalize(QuantityParser.Parse(row.Text).Name));
|
||||
var listName = result.Value?.ListName ?? _list?.Name ?? "Einkaufsliste";
|
||||
if (result.Value is { Added.Count: > 0 })
|
||||
{
|
||||
Snackbar.Add($"„{row.Text}“ steht jetzt auf „{listName}“.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"„{row.Text}“ steht schon auf „{listName}“.", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMatches()
|
||||
{
|
||||
var term = _dishName.Trim();
|
||||
@@ -107,9 +211,10 @@
|
||||
_recipeUrl = dish.RecipeUrl;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_ingredients) && dish.Ingredients.Count > 0)
|
||||
if (_ingredients.All(r => r.Text.Length == 0) && dish.Ingredients.Count > 0)
|
||||
{
|
||||
_ingredients = MealFormat.IngredientsText(dish.Ingredients);
|
||||
_ingredients.Clear();
|
||||
_ingredients.AddRange(dish.Ingredients.Select(IngredientRow.From));
|
||||
}
|
||||
|
||||
_matches = [];
|
||||
@@ -119,7 +224,7 @@
|
||||
{
|
||||
_generalError = null;
|
||||
var request = new SaveMealEntryRequest(Date, MealType, _dishName.Trim(), _note, _recipeUrl?.Trim(),
|
||||
MealFormat.ParseIngredients(_ingredients), _version);
|
||||
MealPlanRules.NormalizeIngredients(_ingredients.Select(r => r.Text)), _version);
|
||||
var validation = new SaveMealEntryRequestValidator().Validate(request);
|
||||
_errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
@@ -162,4 +267,26 @@
|
||||
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
|
||||
|
||||
private void Cancel() => Dialog?.Cancel();
|
||||
|
||||
/// <summary>Eine Zeile der Zutatenliste: Menge (optional) und Name; gespeichert wird beides als ein Text (<see cref="Text"/>).</summary>
|
||||
private sealed class IngredientRow
|
||||
{
|
||||
public string? Amount { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public bool Busy { get; set; }
|
||||
|
||||
public string Text => MealFormat.JoinIngredient(Amount, Name);
|
||||
|
||||
public static IngredientRow From(string text)
|
||||
{
|
||||
var (amount, name) = MealFormat.SplitIngredient(text);
|
||||
return new IngredientRow { Amount = amount, Name = name };
|
||||
}
|
||||
|
||||
/// <summary>Steht der Artikelname schon offen auf der Einkaufsliste?</summary>
|
||||
public bool OnList(HashSet<string> openNames) =>
|
||||
Text.Length > 0 && QuantityParser.Parse(Text).Name is { Length: > 0 } name && openNames.Contains(ShoppingSuggestion.Normalize(name));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,24 @@ public static class MealFormat
|
||||
|
||||
public static string IngredientsText(IEnumerable<string> ingredients) => string.Join("\n", ingredients);
|
||||
|
||||
/// <summary>
|
||||
/// Zerlegt eine gespeicherte Zutat („500 g Hackfleisch“) für die Zeilenbearbeitung in Menge und Name. Gespeichert bleibt ein einzelner Text
|
||||
/// (<see cref="JoinIngredient"/>): Die Menge wird nur abgespalten, wenn sie der Text wörtlich am Anfang hat, sonst steht alles im Namen. So geht
|
||||
/// bestehender Freitext nie verloren oder wird umgeschrieben.
|
||||
/// </summary>
|
||||
public static (string Amount, string Name) SplitIngredient(string? text)
|
||||
{
|
||||
var value = (text ?? string.Empty).Trim();
|
||||
var quantity = FamilyPlanner.Domain.Shopping.QuantityParser.Parse(value).Quantity;
|
||||
return quantity is { Length: > 0 } && value.StartsWith(quantity + " ", StringComparison.OrdinalIgnoreCase)
|
||||
? (value[..quantity.Length], value[(quantity.Length + 1)..].Trim())
|
||||
: (string.Empty, value);
|
||||
}
|
||||
|
||||
/// <summary>Menge und Name zu einer Zutat zusammenfügen („500 g“ + „Hackfleisch“); leer, wenn beides fehlt.</summary>
|
||||
public static string JoinIngredient(string? amount, string? name) =>
|
||||
string.Join(' ', new[] { amount?.Trim(), name?.Trim() }.Where(p => !string.IsNullOrEmpty(p)));
|
||||
|
||||
/// <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)}"
|
||||
|
||||
@@ -314,6 +314,7 @@ else
|
||||
{ d => d.MealType, mealType },
|
||||
{ d => d.Entry, entry },
|
||||
{ d => d.Dishes, await DishesAsync() },
|
||||
{ d => d.CanAddToShopping, CanAddToShopping },
|
||||
};
|
||||
var dialog = await DialogService.ShowAsync<MealEntryDialog>($"{MealFormat.Day(day)} · {mealType}", parameters, DialogDefaults);
|
||||
if (await dialog.Result is { Canceled: false, Data: MealPlanEntryDto saved })
|
||||
|
||||
@@ -283,6 +283,34 @@ public sealed class MealTests(AppFixture fixture)
|
||||
(await admin.PostAsJsonAsync($"/api/meals/{Guid.NewGuid()}/to-shopping", new IngredientsToShoppingRequest(list.Id), Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_ingredient_goes_to_the_default_list_once_and_old_free_text_survives_unchanged()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
|
||||
using var _ = guest;
|
||||
var unique = $"Soße{Guid.NewGuid():N}"[..14];
|
||||
|
||||
// Altbestand: freie Texte (mit Komma-Menge und ohne Menge) bleiben beim Speichern wortgleich erhalten
|
||||
var entry = await CreateAsync(admin, NewWeek(), Dinner, "Eintopf", ["1,5 l Brühe", "Salz nach Geschmack", $"200 g {unique}"]);
|
||||
Assert.Equal(["1,5 l Brühe", "Salz nach Geschmack", $"200 g {unique}"], entry.Ingredients);
|
||||
|
||||
// Gast darf nichts auf die Liste setzen; Admin: eine einzelne Zutat, beim zweiten Mal erkennt der Server die Dublette
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await guest.PostAsJsonAsync($"/api/meals/{entry.Id}/to-shopping", new IngredientsToShoppingRequest(null, [$"200 g {unique}"]), Ct)).StatusCode);
|
||||
var first = (await (await admin.PostAsJsonAsync($"/api/meals/{entry.Id}/to-shopping", new IngredientsToShoppingRequest(null, [$"200 g {unique}"]), Ct))
|
||||
.Content.ReadFromJsonAsync<IngredientsToShoppingResult>(Ct))!;
|
||||
Assert.Equal([$"200 g {unique}"], first.Added);
|
||||
var second = (await (await admin.PostAsJsonAsync($"/api/meals/{entry.Id}/to-shopping", new IngredientsToShoppingRequest(null, [$"200 g {unique}"]), Ct))
|
||||
.Content.ReadFromJsonAsync<IngredientsToShoppingResult>(Ct))!;
|
||||
Assert.Empty(second.Added);
|
||||
Assert.Equal([$"200 g {unique}"], second.Skipped);
|
||||
|
||||
var items = (await admin.GetFromJsonAsync<List<ShoppingItemDto>>($"/api/shopping/lists/{first.ListId}/items", Ct))!;
|
||||
Assert.Single(items, i => i.Name.Equals(unique, StringComparison.OrdinalIgnoreCase) && !i.IsDone);
|
||||
Assert.DoesNotContain(items, i => i.Name.Contains("Salz")); // nur die gewählte Zutat, nicht der ganze Eintrag
|
||||
}
|
||||
|
||||
private static async Task<List<WishlistDishDto>> WishlistAsync(HttpClient client, string? query = null, string? sort = null) =>
|
||||
(await client.GetFromJsonAsync<List<WishlistDishDto>>(
|
||||
$"/api/meals/wishlist?q={Uri.EscapeDataString(query ?? string.Empty)}&sort={sort}", Ct))!;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FamilyPlanner.Web.Client.Components.Meals;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Domain.Meals;
|
||||
@@ -433,4 +434,115 @@ public sealed class MealsPageTests : BunitContext
|
||||
|
||||
public Task<AuthResult> ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// ---------- Zutatenliste (Phase 12a) ----------
|
||||
|
||||
[Theory]
|
||||
[InlineData("500 g Hackfleisch", "500 g", "Hackfleisch")]
|
||||
[InlineData("2 Paprika", "2", "Paprika")]
|
||||
[InlineData("Salz nach Geschmack", "", "Salz nach Geschmack")]
|
||||
[InlineData("Butter 50 g", "", "Butter 50 g")]
|
||||
[InlineData("1,5 l Milch", "1,5 l", "Milch")]
|
||||
public void Ingredients_are_split_into_amount_and_name_without_losing_text(string stored, string amount, string name)
|
||||
{
|
||||
Assert.Equal((amount, name), MealFormat.SplitIngredient(stored));
|
||||
Assert.Equal(stored, MealFormat.JoinIngredient(amount, name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingredients_are_edited_as_rows_and_saved_in_the_old_text_format()
|
||||
{
|
||||
var entry = _api.Seed(Today, "Mittagessen", "Chili", ["500 g Hackfleisch", "Salz nach Geschmack", "1 Dose Bohnen"]);
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<MealsPageHost>();
|
||||
|
||||
cut.Find(".fp-meal-list .fp-meal-edit").Click();
|
||||
cut.WaitForElement(".fp-ingredient-row");
|
||||
Assert.Equal(3, cut.FindAll(".fp-ingredient-row").Count);
|
||||
Assert.Equal("500 g", cut.FindAll(".fp-ingredient-amount input")[0].GetAttribute("value"));
|
||||
Assert.Equal("Hackfleisch", cut.FindAll(".fp-ingredient-name input")[0].GetAttribute("value"));
|
||||
Assert.Equal("Salz nach Geschmack", cut.FindAll(".fp-ingredient-name input")[1].GetAttribute("value"));
|
||||
|
||||
cut.FindAll(".fp-ingredient-remove")[2].Click(); // Bohnen weg
|
||||
cut.Find(".fp-ingredient-add").Click();
|
||||
var rows = cut.FindAll(".fp-ingredient-row");
|
||||
Assert.Equal(3, rows.Count);
|
||||
cut.FindAll(".fp-ingredient-amount input")[2].Change("2");
|
||||
cut.FindAll(".fp-ingredient-name input")[2].Change("Paprika");
|
||||
cut.FindAll(".fp-ingredient-amount input")[0].Change("600 g");
|
||||
cut.Find("#meal-entry-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.Saved));
|
||||
Assert.Equal(["600 g Hackfleisch", "Salz nach Geschmack", "2 Paprika"], _api.Saved[0].Ingredients);
|
||||
Assert.Equal(entry.Id, _api.Entries.Single().Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingredient_rows_without_text_are_dropped_on_save()
|
||||
{
|
||||
_api.Seed(Today, "Mittagessen", "Suppe");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<MealsPageHost>();
|
||||
|
||||
cut.Find(".fp-meal-list .fp-meal-edit").Click();
|
||||
cut.WaitForElement("#meal-entry-form");
|
||||
cut.Find(".fp-ingredient-add").Click();
|
||||
cut.Find("#meal-entry-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.Saved));
|
||||
Assert.Empty(_api.Saved[0].Ingredients!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_ingredient_goes_to_the_default_shopping_list_with_feedback()
|
||||
{
|
||||
var entry = _api.Seed(Today, "Mittagessen", "Chili", ["500 g Hackfleisch", "1 Dose Bohnen"]);
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<MealsPageHost>();
|
||||
|
||||
cut.Find(".fp-meal-list .fp-meal-edit").Click();
|
||||
cut.WaitForElement(".fp-ingredient-cart");
|
||||
cut.FindAll(".fp-ingredient-cart")[1].Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("„1 Dose Bohnen“ steht jetzt auf „Supermarkt“", cut.Markup));
|
||||
var (id, request) = Assert.Single(_api.ToShopping);
|
||||
Assert.Equal(entry.Id, id);
|
||||
Assert.Equal(FakeShoppingApi.SupermarketId, request.ListId);
|
||||
Assert.Equal(["1 Dose Bohnen"], request.Ingredients);
|
||||
cut.WaitForAssertion(() => Assert.Equal("true", cut.FindAll(".fp-ingredient-row")[1].GetAttribute("data-onlist")));
|
||||
Assert.True(cut.FindAll(".fp-ingredient-cart")[1].HasAttribute("disabled")); // nicht doppelt
|
||||
Assert.False(cut.FindAll(".fp-ingredient-cart")[0].HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingredients_already_open_on_the_default_list_are_marked_and_cannot_be_added_again()
|
||||
{
|
||||
_shopping.Seed("Hackfleisch", "Fleisch");
|
||||
_shopping.Seed("Bohnen", "Konserven", done: true); // erledigt zählt nicht
|
||||
_api.Seed(Today, "Mittagessen", "Chili", ["500 g Hackfleisch", "1 Dose Bohnen"]);
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<MealsPageHost>();
|
||||
|
||||
cut.Find(".fp-meal-list .fp-meal-edit").Click();
|
||||
cut.WaitForAssertion(() => Assert.Equal("true", cut.FindAll(".fp-ingredient-row")[0].GetAttribute("data-onlist")));
|
||||
Assert.Equal("false", cut.FindAll(".fp-ingredient-row")[1].GetAttribute("data-onlist"));
|
||||
Assert.True(cut.FindAll(".fp-ingredient-cart")[0].HasAttribute("disabled"));
|
||||
Assert.False(cut.FindAll(".fp-ingredient-cart")[1].HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_entries_can_not_add_to_the_shopping_basket_before_they_are_saved()
|
||||
{
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<MealsPageHost>();
|
||||
|
||||
cut.Find(".fp-meal-list .fp-meal-add").Click();
|
||||
cut.WaitForElement("#meal-entry-form");
|
||||
cut.Find(".fp-ingredient-add").Click();
|
||||
cut.FindAll(".fp-ingredient-name input")[0].Change("Mehl");
|
||||
|
||||
Assert.True(cut.Find(".fp-ingredient-cart").HasAttribute("disabled"));
|
||||
Assert.Contains("sobald das Gericht gespeichert ist", cut.Find(".fp-ingredient-hint").TextContent);
|
||||
Assert.Empty(_api.ToShopping);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user