diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 95bed93..686a041 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,13 +41,25 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/budgeteer + tags: | + type=raw,value=latest + type=sha,prefix= + type=raw,value={{date 'YYYY.MM.DD'}} + labels: | + 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. + - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true - tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/budgeteer:latest - ${{ secrets.DOCKERHUB_USERNAME }}/budgeteer:${{ github.sha }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/BudgetApp/Components/Layout/MainLayout.razor b/BudgetApp/Components/Layout/MainLayout.razor index 7ab6ccb..6ba0afc 100644 --- a/BudgetApp/Components/Layout/MainLayout.razor +++ b/BudgetApp/Components/Layout/MainLayout.razor @@ -21,6 +21,8 @@ + +
An unhandled error has occurred. Reload diff --git a/BudgetApp/Components/Layout/NavMenu.razor b/BudgetApp/Components/Layout/NavMenu.razor index 913d3af..9ef0852 100644 --- a/BudgetApp/Components/Layout/NavMenu.razor +++ b/BudgetApp/Components/Layout/NavMenu.razor @@ -1,4 +1,8 @@ -
- +
-
diff --git a/BudgetApp/Components/Pages/Categories.razor b/BudgetApp/Components/Pages/Categories.razor index cff9346..669a56e 100644 --- a/BudgetApp/Components/Pages/Categories.razor +++ b/BudgetApp/Components/Pages/Categories.razor @@ -14,12 +14,11 @@
- +
-
@@ -49,31 +48,38 @@ else {
- @if (editingCategoryId == category.Id) - { -
+
+ @if (editingCategoryId == category.Id) + { - -
+
+ +
+ } + else + { +
@(string.IsNullOrEmpty(category.Icon) ? "📁" : category.Icon)
+
+
@category.Name
+
+ } +
+ - } - else - { -
-
@(string.IsNullOrEmpty(category.Icon) ? "📁" : category.Icon)
-
@category.Name
-
- - } +
} diff --git a/BudgetApp/Components/Pages/Home.razor b/BudgetApp/Components/Pages/Home.razor index 77df351..34686ea 100644 --- a/BudgetApp/Components/Pages/Home.razor +++ b/BudgetApp/Components/Pages/Home.razor @@ -2,7 +2,6 @@ @attribute [Authorize] @inject DashboardService DashboardService @inject TransactionService TransactionService -@inject AccountService AccountService @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JS @implements IAsyncDisposable @@ -25,53 +24,25 @@ else if (data.Accounts.Count == 0) } else { -
- @foreach (var account in data.Accounts) - { -
- @account.Name - @if (editingBalanceAccountId == account.Id) - { -
- @account.GetSymbol() - - - -
- } - else - { -
- - @account.FormatAmount(account.CurrentBalance) - -
- } -
- } -
- -
- Window: -
- - - - -
- - to - -
-
+
+
Balance Trend
+
+
+ + + + +
+ + to + +
+
-
Balance Trend
-
+
@@ -88,12 +59,11 @@ else
- + @@ -341,6 +319,7 @@ else // Modal state private bool showModal; private int? editingTransactionId; + private bool editIsPartOfSeries; private ModalTransactionForm modalForm = new(); // Chart redraw tracking @@ -422,30 +401,6 @@ else } } - // Balance editing state - private int? editingBalanceAccountId; - private decimal editingBalanceValue; - - private void StartBalanceEdit(Account account) - { - editingBalanceAccountId = account.Id; - editingBalanceValue = account.CurrentBalance; - } - - private void CancelBalanceEdit() - { - editingBalanceAccountId = null; - } - - private async Task SaveBalance(int accountId) - { - if (userId is null) return; - - await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue); - editingBalanceAccountId = null; - await RefreshData(); - } - private void OpenAddModal() { editingTransactionId = null; @@ -459,6 +414,7 @@ else private void OpenEditModal(Transaction t) { editingTransactionId = t.Id; + editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null; modalForm = new ModalTransactionForm { Description = t.Description, @@ -493,6 +449,18 @@ else RecurrenceEndDate = modalForm.RecurrenceEndDate }; await TransactionService.CreateTransactionAsync(transaction); + await RefreshData(); + + // Keep modal open and reset form for the next entry + var previousAccountId = modalForm.AccountId; + var previousCategoryId = modalForm.CategoryId; + var previousType = modalForm.Type; + modalForm = new ModalTransactionForm + { + AccountId = previousAccountId, + CategoryId = previousCategoryId, + Type = previousType + }; } else { @@ -501,10 +469,10 @@ else modalForm.Description, modalForm.Amount, modalForm.Date, modalForm.Type, modalForm.AccountId, modalForm.CategoryId, modalForm.RecurrenceType, modalForm.RecurrenceEndDate); - } - showModal = false; - await RefreshData(); + showModal = false; + await RefreshData(); + } } // Delete confirmation state diff --git a/BudgetApp/Components/Pages/Home.razor.css b/BudgetApp/Components/Pages/Home.razor.css new file mode 100644 index 0000000..83c7cc4 --- /dev/null +++ b/BudgetApp/Components/Pages/Home.razor.css @@ -0,0 +1,8 @@ +.date-picker { + width: auto; +} + +.chart-container { + height: clamp(200px, 40vw, 350px); + position: relative; +} diff --git a/BudgetApp/Components/Pages/Settings.razor b/BudgetApp/Components/Pages/Settings.razor index 9c3e2e0..9f60069 100644 --- a/BudgetApp/Components/Pages/Settings.razor +++ b/BudgetApp/Components/Pages/Settings.razor @@ -30,22 +30,19 @@ } - +
-
-
-
diff --git a/BudgetApp/Components/Pages/Transactions.razor b/BudgetApp/Components/Pages/Transactions.razor index f86eda1..d99e4cd 100644 --- a/BudgetApp/Components/Pages/Transactions.razor +++ b/BudgetApp/Components/Pages/Transactions.razor @@ -24,12 +24,11 @@ else { - +
-
@@ -86,7 +85,6 @@
-
}
@@ -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"]