diff --git a/.cursor/plans/einab_interaction_edit_79be74e0.plan.md b/.cursor/plans/einab_interaction_edit_79be74e0.plan.md new file mode 100644 index 0000000..eb1a592 --- /dev/null +++ b/.cursor/plans/einab_interaction_edit_79be74e0.plan.md @@ -0,0 +1,155 @@ +--- +name: EinAb Interaction Edit +overview: Add edit mode for EinAb (Einführung) interactions by extending the existing InteractionDialog with an update path, a new ProspectService.UpdateInteraction method, and an edit button in InteractionRow — scoped to EinAb initially but designed for future interaction types. +todos: + - id: service-update + content: Add ProspectService.UpdateInteraction + AuditType.EditInteraction + AuditHelper case + status: completed + - id: dialog-edit-mode + content: Extend InteractionDialog with IsEditMode, IsEditable helper, and unified SaveAsync + status: completed + - id: row-edit-button + content: Add AllowEdit/EditClick to InteractionRow with pencil icon in item list + status: completed + - id: container-wire + content: Add EditInteraction handler in ProspectContainer and enable AllowEdit on EinAb row + status: completed +isProject: false +--- + +# EinAb Interaction Edit Mode + +## Current State + +EinAb interactions ("Einführung") are multi-entry (minimum 3) and support date, business/person fields (`Info1`/`Info2`), and feedback (thumbs up/down/neutral). Users can **add** via [`InteractionDialog`](FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor) and **remove** via the X icon in [`InteractionRow`](FoodsharingSiegen.Server/Controls/InteractionRow.razor). There is no update path at any layer. + +```mermaid +flowchart LR + subgraph current [Current Flow] + AddBtn["+ Button"] --> Dialog["InteractionDialog"] + Dialog --> AddSvc["ProspectService.AddInteraction"] + XBtn["X Button"] --> Confirm["ConfirmDialog"] + Confirm --> RemoveSvc["ProspectService.RemoveInteraction"] + end +``` + +## Target Flow + +```mermaid +flowchart LR + subgraph target [New Edit Flow] + EditBtn["Pencil Button"] --> EditDialog["InteractionDialog (edit mode)"] + EditDialog --> UpdateSvc["ProspectService.UpdateInteraction"] + end + AddBtn["+ Button"] --> AddDialog["InteractionDialog (add mode)"] + AddDialog --> AddSvc["ProspectService.AddInteraction"] + XBtn["X Button"] --> RemoveSvc["ProspectService.RemoveInteraction"] +``` + +Edit and remove will coexist. Only EinAb gets the edit button initially. + +--- + +## 1. Service Layer — `UpdateInteraction` + +**File:** [`FoodsharingSiegen.Server/Data/Service/ProspectService.cs`](FoodsharingSiegen.Server/Data/Service/ProspectService.cs) + +Add `UpdateInteraction(Interaction interaction)` following the existing `UpdateAsync(Prospect)` pattern: + +- Load tracked entity by `interaction.Id`; return error if not found +- Update editable fields only: `Date`, `Info1`, `Info2`, `Feedback`, `FeedbackInfo`, `Alert` +- Do **not** change: `Id`, `Created`, `UserID` (preserves original creator), `Type`, `ProspectID`, `NotNeeded` +- Set parent `Prospect.Modified = DateTime.UtcNow` +- Audit via new `AuditType.EditInteraction` (prospect name + interaction type as data) +- Detach entities after save (consistent with `AddInteraction`) + +--- + +## 2. Audit Support + +**Files:** +- [`FoodsharingSiegen.Contracts/Enums/AuditType.cs`](FoodsharingSiegen.Contracts/Enums/AuditType.cs) — add `EditInteraction = 160` +- [`FoodsharingSiegen.Server/Data/AuditHelper.cs`](FoodsharingSiegen.Server/Data/AuditHelper.cs) — add case: `"hat eine Interaktion bei {Data1} bearbeitet: {Data2}"` + +--- + +## 3. InteractionDialog — Add/Edit Mode + +**Files:** [`InteractionDialog.razor.cs`](FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor.cs), [`InteractionDialog.razor`](FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor) + +Follow the [`EditProspectDialog`](FoodsharingSiegen.Server/Dialogs/EditProspectDialog.razor.cs) pattern (`IsUpdateMode` + conditional save): + +- Add `IsEditMode` parameter and a static extensibility helper: + +```csharp +public static bool IsEditable(InteractionType type) => type switch +{ + InteractionType.EinAb => true, + _ => false +}; +``` + +- Extend `InteractionDialogParameter` with optional `Interaction? ExistingInteraction = null` +- In `ShowAsync`: when `ExistingInteraction` is provided, pass it as the bound `Interaction` and set `IsEditMode = true`; otherwise keep current "new interaction" behavior +- Replace `AddInteractionAsync` with a unified `SaveAsync`: + - **Add mode:** `Interaction.UserID = CurrentUser.Id` → `ProspectService.AddInteraction` + - **Edit mode:** `ProspectService.UpdateInteraction` +- In the razor template, change OK button label: `"OK"` (add) / `"Speichern"` (edit) — same fields render in both modes (date, Betrieb, Mit wem, feedback) + +**Header text** (in `ProspectContainer`): `"Einführung für {Name} bearbeiten"` for edit vs existing `"… eintragen"` for add. + +--- + +## 4. InteractionRow — Edit Button + +**Files:** [`InteractionRow.razor`](FoodsharingSiegen.Server/Controls/InteractionRow.razor), [`InteractionRow.razor.cs`](FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs) + +- Add parameters: + - `bool AllowEdit { get; set; }` — controls visibility + - `Func? EditClick { get; set; }` — callback with interaction Id +- In the per-item loop (lines 46–66), show a pencil icon (`fa-solid fa-pen-to-square`) next to the existing X remove icon when `AllowEdit && AllowInteraction` +- Same visibility guard as remove: hidden when prospect is complete (unless Complete-type interaction) + +--- + +## 5. ProspectContainer — Wire Up Edit + +**Files:** [`ProspectContainer.razor`](FoodsharingSiegen.Server/Controls/ProspectContainer.razor), [`ProspectContainer.razor.cs`](FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs) + +- Add `EditInteraction(Guid interactionId)`: + - Look up interaction from `Prospect.Interactions` + - Guard: only proceed if `InteractionDialog.IsEditable(interaction.Type)` + - Open dialog with existing interaction and header `"Einführung für {Name} bearbeiten"` + - On success → `OnDataChanged()` +- On the EinAb `InteractionRow`, add: + - `AllowEdit="true"` + - `EditClick="@EditInteraction"` + +Other interaction rows remain unchanged (no edit button). + +--- + +## Extensibility Notes + +To enable edit for another type later: +1. Add the type to `InteractionDialog.IsEditable()` +2. Set `AllowEdit="true"` on that row in `ProspectContainer.razor` +3. Ensure field labels in `ShowAsync`'s type switch cover that type (already done for several types) + +No separate edit dialog component is needed — the same form handles both modes. + +--- + +## Files Changed (summary) + +| File | Change | +|------|--------| +| `ProspectService.cs` | New `UpdateInteraction` method | +| `AuditType.cs` | New `EditInteraction` enum value | +| `AuditHelper.cs` | New audit text case | +| `InteractionDialog.razor.cs` | Edit mode, `IsEditable`, unified save | +| `InteractionDialog.razor` | Conditional button label | +| `InteractionRow.razor` / `.cs` | Edit button + parameters | +| `ProspectContainer.razor` / `.cs` | `EditInteraction` handler, EinAb wiring | + +No database migration required — all editable fields already exist on the `Interaction` entity. diff --git a/FoodsharingSiegen.Contracts/Enums/AuditType.cs b/FoodsharingSiegen.Contracts/Enums/AuditType.cs index 6d034fe..b5997ac 100644 --- a/FoodsharingSiegen.Contracts/Enums/AuditType.cs +++ b/FoodsharingSiegen.Contracts/Enums/AuditType.cs @@ -87,7 +87,12 @@ namespace FoodsharingSiegen.Contracts.Enums /// /// The change own password audit type /// - ChangeOwnPassword = 150 + ChangeOwnPassword = 150, + + /// + /// The edit interaction audit type + /// + EditInteraction = 160 #endregion Prospects } diff --git a/FoodsharingSiegen.Server/Controls/InteractionRow.razor b/FoodsharingSiegen.Server/Controls/InteractionRow.razor index 76a2026..e27e793 100644 --- a/FoodsharingSiegen.Server/Controls/InteractionRow.razor +++ b/FoodsharingSiegen.Server/Controls/InteractionRow.razor @@ -45,11 +45,29 @@ else @foreach (var interaction in Interactions) { -
+
@if ((Prospect is not { Complete: true } || interaction.Type == InteractionType.Complete) && AllowInteraction) { - - } else { +
+ @if (AllowEdit) + { + + } + +
+ } + else + { }
diff --git a/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs b/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs index e7d6f44..5910a7c 100644 --- a/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs +++ b/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs @@ -62,6 +62,18 @@ namespace FoodsharingSiegen.Server.Controls [Parameter] public Func? RemoveClick { get; set; } + /// + /// Gets or sets whether editing existing interactions is allowed. + /// + [Parameter] + public bool AllowEdit { get; set; } + + /// + /// Gets or sets the callback invoked when an interaction is edited. + /// + [Parameter] + public Func? EditClick { get; set; } + /// /// Gets or sets the value of the type (ab) /// diff --git a/FoodsharingSiegen.Server/Controls/InteractionRow.razor.css b/FoodsharingSiegen.Server/Controls/InteractionRow.razor.css index f77c863..4392608 100644 --- a/FoodsharingSiegen.Server/Controls/InteractionRow.razor.css +++ b/FoodsharingSiegen.Server/Controls/InteractionRow.razor.css @@ -1,4 +1,38 @@ -tr.done th { +.interaction-row-action-group { + gap: 0.25rem; + min-width: 2.7rem; +} + +.interaction-row-actions { + gap: 0.25rem; +} + +.interaction-row-action { + width: 1rem; + min-width: 1.25rem; + height: 1.25rem; + min-height: 1.25rem; + padding: 0; + display: inline-flex !important; + align-items: center; + justify-content: center; + font-size: 0.7rem; + line-height: 1; +} + +.interaction-row-action i { + font-size: 0.95rem; +} + +.interaction-row-action-edit i { + color: #1f2f3a; +} + +.interaction-row-action-delete i { + color: rgb(153, 0, 0); +} + +tr.done th { color: #64ae24; } diff --git a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor index 29907ca..3217177 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor +++ b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor @@ -81,6 +81,8 @@ AllowInteraction="@(StateFilter == ProspectStateFilter.OnBoarding && CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador))" AddClick="AddInteraction" RemoveClick="@RemoveInteraction" + AllowEdit="true" + EditClick="@EditInteraction" Multiple="true" Minimum="3" ButtonIconClass="fa-solid fa-plus" diff --git a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs index a49fc20..d06abbb 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs +++ b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs @@ -62,6 +62,24 @@ namespace FoodsharingSiegen.Server.Controls #endregion + #region Private Method EditInteraction + + private async Task EditInteraction(Guid interactionId) + { + if (Prospect == null || OnDataChanged == null) return; + + var interaction = Prospect.Interactions.FirstOrDefault(x => x.Id == interactionId); + if (interaction == null || !InteractionDialog.IsEditable(interaction.Type)) return; + + var headerText = $"{interaction.Type.Translate(AppSettings)} für {Prospect.Name} bearbeiten"; + + Func onSuccess = async () => await OnDataChanged(); + + await InteractionDialog.ShowAsync(ModalService, new(interaction.Type, Prospect.Id, headerText, onSuccess, interaction)); + } + + #endregion + #region Private Method DeleteProspectAsync /// diff --git a/FoodsharingSiegen.Server/Data/AuditHelper.cs b/FoodsharingSiegen.Server/Data/AuditHelper.cs index f86df7b..d17054c 100644 --- a/FoodsharingSiegen.Server/Data/AuditHelper.cs +++ b/FoodsharingSiegen.Server/Data/AuditHelper.cs @@ -37,6 +37,8 @@ namespace FoodsharingSiegen.Server.Data return $"hat dem Neuling {audit.Data1} folgendes hinzugefügt: {audit.Data2}"; case AuditType.RemoveInteraction: return $"hat eine Interaktion bei {audit.Data1} gelöscht."; + case AuditType.EditInteraction: + return $"hat eine Interaktion bei {audit.Data1} bearbeitet: {audit.Data2}"; case AuditType.DeleteProspectImages: return $"hat die Bilder von {audit.Data1} gelöscht."; case AuditType.ViewProspectImages: diff --git a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs index 02ca414..117bbab 100644 --- a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs +++ b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs @@ -174,6 +174,53 @@ namespace FoodsharingSiegen.Server.Data.Service #endregion + #region Public Method UpdateInteraction + + /// + /// Updates an existing interaction with the specified values. + /// + /// The interaction with updated values. + /// A task containing an operation result of interaction. + public async Task> UpdateInteraction(Interaction interaction) + { + try + { + var entityInteraction = await Context.Interactions!.FirstOrDefaultAsync(x => x.Id == interaction.Id); + if (entityInteraction == null) return new(new Exception("Interaction not found")); + + entityInteraction.Date = interaction.Date; + entityInteraction.Info1 = interaction.Info1; + entityInteraction.Info2 = interaction.Info2; + entityInteraction.Feedback = interaction.Feedback; + entityInteraction.FeedbackInfo = interaction.FeedbackInfo; + entityInteraction.Alert = interaction.Alert; + + var prospect = await Context.Prospects!.FirstOrDefaultAsync(x => x.Id == entityInteraction.ProspectID); + if (prospect != null) + { + prospect.Modified = DateTime.UtcNow; + } + + await Context.SaveChangesAsync(); + + if (prospect != null) + { + await AuditService.Insert(AuditType.EditInteraction, prospect.Name, entityInteraction.Type.ToString()); + } + + Context.Entry(entityInteraction).State = EntityState.Detached; + if (prospect != null) Context.Entry(prospect).State = EntityState.Detached; + + return new(entityInteraction); + } + catch (Exception e) + { + return new(e); + } + } + + #endregion + #region Public Method UpdateAsync /// diff --git a/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor b/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor index bacb92c..814f4c4 100644 --- a/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor +++ b/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor @@ -76,5 +76,5 @@
- +
diff --git a/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor.cs b/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor.cs index 621876e..563e96b 100644 --- a/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor.cs +++ b/FoodsharingSiegen.Server/Dialogs/InteractionDialog.razor.cs @@ -1,4 +1,5 @@ using Blazorise; +using FoodsharingSiegen.Contracts; using FoodsharingSiegen.Contracts.Entity; using FoodsharingSiegen.Contracts.Enums; using FoodsharingSiegen.Server.BaseClasses; @@ -7,7 +8,7 @@ using Microsoft.AspNetCore.Components; namespace FoodsharingSiegen.Server.Dialogs { - public record InteractionDialogParameter(InteractionType Type, Guid ProspectId, string HeaderText, Func OnSuccess); + public record InteractionDialogParameter(InteractionType Type, Guid ProspectId, string HeaderText, Func OnSuccess, Interaction? ExistingInteraction = null); public partial class InteractionDialog : FsBase { @@ -44,6 +45,19 @@ namespace FoodsharingSiegen.Server.Dialogs [Parameter] public bool ShowNotNeeded { get; set; } + [Parameter] + public bool IsEditMode { get; set; } + + #endregion + + #region Public Method IsEditable + + public static bool IsEditable(InteractionType type) => type switch + { + InteractionType.EinAb => true, + _ => false + }; + #endregion #region Public Method ShowAsync @@ -93,12 +107,29 @@ namespace FoodsharingSiegen.Server.Dialogs _ => false }; - var interaction = new Interaction - { - Type = parameter.Type, - Date = DateTime.UtcNow, - ProspectID = parameter.ProspectId - }; + var isEditMode = parameter.ExistingInteraction != null; + var interaction = isEditMode + ? new Interaction + { + Id = parameter.ExistingInteraction!.Id, + Type = parameter.ExistingInteraction.Type, + Date = parameter.ExistingInteraction.Date, + ProspectID = parameter.ExistingInteraction.ProspectID, + UserID = parameter.ExistingInteraction.UserID, + Info1 = parameter.ExistingInteraction.Info1, + Info2 = parameter.ExistingInteraction.Info2, + Feedback = parameter.ExistingInteraction.Feedback, + FeedbackInfo = parameter.ExistingInteraction.FeedbackInfo, + Alert = parameter.ExistingInteraction.Alert, + NotNeeded = parameter.ExistingInteraction.NotNeeded, + Created = parameter.ExistingInteraction.Created + } + : new Interaction + { + Type = parameter.Type, + Date = DateTime.UtcNow, + ProspectID = parameter.ProspectId + }; await modalService.Show(parameter.HeaderText, p => { @@ -108,29 +139,41 @@ namespace FoodsharingSiegen.Server.Dialogs p.Add(nameof(Info2Name), info2Name); p.Add(nameof(ShowAlert), showAlert); p.Add(nameof(ShowNotNeeded), showNotNeeded); + p.Add(nameof(IsEditMode), isEditMode); p.Add(nameof(OnSuccess), parameter.OnSuccess); }); } #endregion - #region Private Method AddInteractionAsync + #region Private Method SaveAsync /// - /// Adds a new interaction for the current user using the ProspectService and hides the modal. + /// Saves the interaction (add or update) and hides the modal on success. /// - /// - /// A task representing the asynchronous operation. - /// - private async Task AddInteractionAsync() + private async Task SaveAsync() { - Interaction.UserID = CurrentUser.Id; + OperationResult result; - var addR = await ProspectService.AddInteraction(Interaction); + if (IsEditMode) + { + result = await ProspectService.UpdateInteraction(Interaction); + } + else + { + Interaction.UserID = CurrentUser.Id; + result = await ProspectService.AddInteraction(Interaction); + } - await ModalService.Hide(); - - if (addR.Success && OnSuccess != null) await OnSuccess.Invoke(); + if (result.Success) + { + await ModalService.Hide(); + if (OnSuccess != null) await OnSuccess.Invoke(); + } + else + { + await Notification.Error(result.Exception?.Message ?? "Unbekannter Fehler beim Speichern.", "Fehler"); + } } #endregion