@@ -174,6 +172,9 @@ else
@(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount)
+
@@ -209,6 +210,79 @@ else
}
+
+ @if (showEditModal)
+ {
+
+ }
}
@code {
@@ -319,6 +393,47 @@ else
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
+ // Edit modal state
+ private bool showEditModal;
+ private int? editingTransactionId;
+ private bool editIsPartOfSeries;
+ private TransactionForm editModel = new();
+
+ private void OpenEditModal(Transaction t)
+ {
+ editingTransactionId = t.Id;
+ editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
+ editModel = new TransactionForm
+ {
+ Description = t.Description,
+ Amount = t.Amount,
+ Date = t.Date,
+ Type = t.Type,
+ AccountId = t.AccountId,
+ CategoryId = t.CategoryId,
+ RecurrenceType = t.RecurrenceType,
+ RecurrenceEndDate = t.RecurrenceEndDate
+ };
+ showEditModal = true;
+ }
+
+ private void CloseEditModal() => showEditModal = false;
+
+ private async Task SaveEdit()
+ {
+ if (userId is null || editingTransactionId is null) return;
+
+ await TransactionService.UpdateTransactionAsync(
+ editingTransactionId.Value, userId,
+ editModel.Description, editModel.Amount, editModel.Date,
+ editModel.Type, editModel.AccountId, editModel.CategoryId,
+ editModel.RecurrenceType, editModel.RecurrenceEndDate);
+
+ showEditModal = false;
+ editingTransactionId = null;
+ await LoadData();
+ }
+
private void ConfirmDelete(Transaction t)
{
pendingDeleteTransaction = t;
diff --git a/BudgetApp/Components/ToastContainer.razor b/BudgetApp/Components/ToastContainer.razor
new file mode 100644
index 0000000..7bdd182
--- /dev/null
+++ b/BudgetApp/Components/ToastContainer.razor
@@ -0,0 +1,51 @@
+@inject ToastService ToastService
+@implements IDisposable
+@rendermode InteractiveServer
+
+
+ @foreach (var toast in toasts)
+ {
+
+ ⚠
+ @toast.Message
+
+ }
+
+
+@code {
+ private readonly List toasts = [];
+
+ protected override void OnInitialized()
+ {
+ ToastService.OnShow += ShowToast;
+ }
+
+ private async void ShowToast(string message)
+ {
+ var toast = new ToastItem { Message = message };
+ toasts.Add(toast);
+ await InvokeAsync(StateHasChanged);
+ _ = DismissAfterDelay(toast);
+ }
+
+ private async Task DismissAfterDelay(ToastItem toast)
+ {
+ await Task.Delay(3500);
+ toast.Dismissing = true;
+ await InvokeAsync(StateHasChanged);
+ await Task.Delay(300);
+ toasts.Remove(toast);
+ await InvokeAsync(StateHasChanged);
+ }
+
+ public void Dispose()
+ {
+ ToastService.OnShow -= ShowToast;
+ }
+
+ private sealed class ToastItem
+ {
+ public string Message { get; set; } = "";
+ public bool Dismissing { get; set; }
+ }
+}
diff --git a/BudgetApp/Components/ToastContainer.razor.css b/BudgetApp/Components/ToastContainer.razor.css
new file mode 100644
index 0000000..f7c0fa4
--- /dev/null
+++ b/BudgetApp/Components/ToastContainer.razor.css
@@ -0,0 +1,61 @@
+.toast-stack {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ z-index: 1080;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ pointer-events: none;
+}
+
+.toast-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: #dc3545;
+ color: white;
+ padding: 0.75rem 1rem;
+ border-radius: 0.375rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ animation: toastSlideIn 0.3s ease;
+ pointer-events: auto;
+ max-width: 400px;
+ font-size: 0.875rem;
+}
+
+.toast-dismiss {
+ animation: toastSlideOut 0.3s ease forwards;
+}
+
+.toast-icon {
+ font-size: 1.1rem;
+ flex-shrink: 0;
+}
+
+.toast-message {
+ flex: 1;
+ min-width: 0;
+}
+
+@keyframes toastSlideIn {
+ from {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes toastSlideOut {
+ from {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+}
diff --git a/BudgetApp/Components/ToastValidator.razor b/BudgetApp/Components/ToastValidator.razor
new file mode 100644
index 0000000..3a7ebe3
--- /dev/null
+++ b/BudgetApp/Components/ToastValidator.razor
@@ -0,0 +1,47 @@
+@implements IDisposable
+
+
+
+@code {
+ [CascadingParameter]
+ private EditContext? CurrentEditContext { get; set; }
+
+ [Inject]
+ private ToastService ToastService { get; set; } = default!;
+
+ private bool validationRequested;
+
+ protected override void OnInitialized()
+ {
+ if (CurrentEditContext is not null)
+ {
+ CurrentEditContext.OnValidationRequested += OnValidationRequested;
+ CurrentEditContext.OnValidationStateChanged += OnValidationStateChanged;
+ }
+ }
+
+ private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
+ {
+ validationRequested = true;
+ }
+
+ private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
+ {
+ if (!validationRequested || CurrentEditContext is null) return;
+ validationRequested = false;
+
+ foreach (var message in CurrentEditContext.GetValidationMessages())
+ {
+ ToastService.Show(message);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (CurrentEditContext is not null)
+ {
+ CurrentEditContext.OnValidationRequested -= OnValidationRequested;
+ CurrentEditContext.OnValidationStateChanged -= OnValidationStateChanged;
+ }
+ }
+}
diff --git a/BudgetApp/Program.cs b/BudgetApp/Program.cs
index 1ad43fa..1baa51d 100644
--- a/BudgetApp/Program.cs
+++ b/BudgetApp/Program.cs
@@ -39,6 +39,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddCascadingAuthenticationState();
diff --git a/BudgetApp/Services/ToastService.cs b/BudgetApp/Services/ToastService.cs
new file mode 100644
index 0000000..e4ed236
--- /dev/null
+++ b/BudgetApp/Services/ToastService.cs
@@ -0,0 +1,8 @@
+namespace BudgetApp.Services;
+
+public sealed class ToastService
+{
+ public event Action? OnShow;
+
+ public void Show(string message) => OnShow?.Invoke(message);
+}
diff --git a/BudgetApp/Services/TransactionService.cs b/BudgetApp/Services/TransactionService.cs
index 1a15d89..b631057 100644
--- a/BudgetApp/Services/TransactionService.cs
+++ b/BudgetApp/Services/TransactionService.cs
@@ -116,14 +116,43 @@ public class TransactionService(ApplicationDbContext db)
if (transaction is null) return;
+ var isPartOfSeries = transaction.RecurrenceType != RecurrenceType.None
+ || transaction.ParentTransactionId is not null;
+
transaction.Description = description;
transaction.Amount = amount;
transaction.Date = date;
transaction.Type = type;
transaction.AccountId = accountId;
transaction.CategoryId = categoryId;
- transaction.RecurrenceType = recurrenceType;
- transaction.RecurrenceEndDate = recurrenceEndDate;
+
+ if (isPartOfSeries)
+ {
+ // Cascade edits to all future occurrences in the series
+ var parentId = transaction.ParentTransactionId ?? transaction.Id;
+ var cutoffDate = transaction.Date.Date;
+
+ var futureSiblings = await _db.Transactions
+ .Where(t => (t.Id == parentId || t.ParentTransactionId == parentId)
+ && t.Id != id
+ && t.Date.Date >= cutoffDate)
+ .ToListAsync();
+
+ foreach (var sibling in futureSiblings)
+ {
+ sibling.Description = description;
+ sibling.Amount = amount;
+ sibling.Type = type;
+ sibling.AccountId = accountId;
+ sibling.CategoryId = categoryId;
+ }
+ }
+ else
+ {
+ // Standalone transaction — preserve existing recurrence state (None)
+ transaction.RecurrenceType = recurrenceType;
+ transaction.RecurrenceEndDate = recurrenceEndDate;
+ }
await _db.SaveChangesAsync();
}
diff --git a/BudgetApp/budgetapp.db-shm b/BudgetApp/budgetapp.db-shm
index 87f06e1..99b6a1c 100644
Binary files a/BudgetApp/budgetapp.db-shm and b/BudgetApp/budgetapp.db-shm differ
diff --git a/BudgetApp/budgetapp.db-wal b/BudgetApp/budgetapp.db-wal
index 129b6c6..3b9f63d 100644
Binary files a/BudgetApp/budgetapp.db-wal and b/BudgetApp/budgetapp.db-wal differ
diff --git a/BudgetApp/wwwroot/app.css b/BudgetApp/wwwroot/app.css
index c3deba7..c217d23 100644
--- a/BudgetApp/wwwroot/app.css
+++ b/BudgetApp/wwwroot/app.css
@@ -1,5 +1,6 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ scrollbar-gutter: stable;
}
a, .btn-link {
@@ -29,11 +30,21 @@ h1:focus {
}
.invalid {
- outline: 1px solid #e50000;
+ outline: 2px solid #dc3545;
+ outline-offset: -2px;
+}
+
+input.invalid:not([type="checkbox"]):not([type="radio"]):not([type="color"]),
+textarea.invalid {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23dc3545' viewBox='0 0 16 16'%3E%3Cpath d='M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 1rem 1rem;
+ padding-right: 2.25rem;
}
.validation-message {
- color: #e50000;
+ display: none;
}
.blazor-error-boundary {
@@ -83,23 +94,13 @@ h1:focus {
/* Cards */
.card {
- transition: box-shadow 0.25s ease, transform 0.25s ease;
+ transition: box-shadow 0.25s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
-/* Balance chips */
-.balance-chip {
- transition: box-shadow 0.25s ease, transform 0.25s ease;
-}
-
-.balance-chip:hover {
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
- transform: translateY(-2px);
-}
-
/* List group items */
.list-group-item {
transition: background-color 0.2s ease, border-left-color 0.2s ease, box-shadow 0.2s ease;
@@ -121,12 +122,19 @@ h1:focus {
/* Modal */
.modal.d-block {
animation: modalFadeIn 0.2s ease;
+ overflow-y: auto;
}
.modal.d-block .modal-content {
animation: modalSlideDown 0.3s ease;
}
+/* Prevent body scroll shift when modal is open */
+body:has(.modal.d-block) {
+ overflow: hidden;
+ padding-right: var(--scrollbar-width, 0px);
+}
+
/* Buttons */
.btn {
transition: transform 0.15s ease, box-shadow 0.15s ease,
@@ -136,6 +144,9 @@ h1:focus {
.btn:active {
transform: scale(0.96);
+ /* Scale from center so layout dimensions stay fixed */
+ transform-origin: center;
+ will-change: transform;
}
/* Nav tabs */
@@ -156,4 +167,9 @@ h1:focus {
/* Chart canvas */
canvas {
transition: opacity 0.3s ease;
+}
+
+/* Prevent scrollbar appearance from shifting content width */
+.card-body[style*="overflow-y"] {
+ scrollbar-gutter: stable;
}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 394ef4e..49dec8a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,8 +14,13 @@ FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
+LABEL org.opencontainers.image.title="Budgeteer" \
+ org.opencontainers.image.description="A personal finance dashboard built with Blazor Server (.NET 10). Multi-account support, recurring transactions, category management, balance trend charts, and ASP.NET Identity authentication." \
+ org.opencontainers.image.url="https://github.com/edgeofsanity76/Budgeteer" \
+ org.opencontainers.image.source="https://github.com/edgeofsanity76/Budgeteer" \
+ org.opencontainers.image.licenses="MIT"
+
# Ensure the SQLite data directory exists and is writable
RUN mkdir -p /app/data
-ENV ConnectionStrings__DefaultConnection="Data Source=/app/data/budgetapp.db"
ENTRYPOINT ["dotnet", "BudgetApp.dll"]
|